ops-release
Turn a release trigger into a shipped release for this repo: plan it, cut it, publish it, then
put the line's branches back in step.
Visibility: service. A framework loop may command it.
Invocation
ops-release <action> '<context-json>'
Two positional arguments. context is a single JSON object encoded as a string; an
absent context is {}, not an error. Reject any action not listed below — never
guess at one, and never silently succeed.
| Action |
What it must do |
plan |
Turn the trigger into release facts: which line, which version, and which units of work the release contains. Reads only; opens nothing. |
cut |
Branch, bump the version files, write the changelog, and open the release PR. Idempotent: re-running MUST return the existing release PR, never open a second. |
publish |
Realize the release once its PR has landed: tag the commit, push the artifacts to their feed, and publish the release notes. MUST leave nothing half-published on failure. |
sync |
Put the line's branches back in step after a release, so the next change starts from what actually shipped. The step manual releases most often forget. |
Repo facts every action depends on
Two version schemes, and they are not the same thing.
| Scheme |
Looks like |
Where it lives |
What it names |
| Release number |
2026.07.4 |
the release branch and the GitHub release |
one shipping event, date-based |
| Product version |
18.1.0 |
<Product>/version.json (Nerdbank.GitVersioning) |
one package, semver |
So one release ships several products, each at its own semver, under one date-based number. The
version in the trigger issue title (release 2026.08.1) is the release number.
Naming, all of which already exists and must not drift:
- Release branch:
<line>/release/<release-number> — e.g. v18/release/2026.07.3
- Release PR title:
chore(release): Prepare release <release-number> (<line>)
- Per-product tag:
<Product>@<semver> — e.g. Umbraco.Automate@18.1.0
- Release tag:
<release-number> — e.g. 2026.07.4
- GitHub release title:
Release <release-number>
release-manifest.json at the repo root lists the products to package. CI fails without it
on a vN/release/* branch. It is deleted again after the release merges.
CI is Azure Pipelines (azure-pipelines.yml). It builds, tests, packs NuGet and npm, and
uploads them as pipeline artifacts. It does not push packages to any feed. Read CI state
through ops-ci, never by calling Azure directly.
Delegation — and the one thing to watch
Three of the four actions delegate to skills this repo already ships. All three were confirmed
present:
| Action |
Delegates to |
Path |
plan, cut |
/release-management |
.claude/skills/release-management/ |
| (via the above) |
/changelog-management, /release-manifest-management |
.claude/skills/ |
sync |
/post-release-cleanup |
.claude/skills/post-release-cleanup/ |
Both delegated skills are interactive — they were written for a human at a keyboard and
use AskUserQuestion. A release loop has nobody to answer. Always pass the complete context
up front (line, release number, products, per-product bumps) so there is nothing left to ask,
and if a delegated skill still stops to ask a question, treat that as a failure: return
{"ok": false, "detail": "…"} naming the question. Never invent an answer to keep going — a
guessed version bump ships the wrong package.
Action: plan
Turn the trigger into release facts. Reads only; opens nothing, branches nothing.
Context it receives (guidance — never validate it at runtime):
{"trigger":{"issue_number":512,"version_text":"release 2026.08.1"}}
trigger — object — the issue, label or dispatch that asked for a release
Facts to return:
line — string
version — string
units — array — the changes included, for the changelog
Steps
Read the release number out of trigger.version_text — the text after release . If it
is not a YYYY.MM.N release number, stop: {"ok": false, "detail": "…"}.
Resolve the line. If the trigger issue names one ((v17), a v17 label), use it.
Otherwise use lines.primary from .claude/ops-repo-meta.json. The line must be in
lines.live; refuse if it is not.
Detect what changed since each product's last release tag, using
/release-management's change-detection phase (its Phase 1) in read-only mode. That gives you
the changed products, the recommended bump per product, and the cascade — a minor or major on
Core forces a bump on OpenIddict and Slack.
Preview the changelog with /changelog-management to collect units.
Return {"ok": true, "line": "…", "version": "…", "units": [...], "products": [...]}, where
products carries each product's name, current version and proposed new version. Carry that
through to cut — it is what stops the delegated skill needing to ask.
If nothing changed since the last tag, say so: {"ok": false, "detail": "no changes since <tag> — nothing to release"}. An empty release is a mistake, not a no-op.
Idempotency (a MUST). plan has no side effects at all: it reads git, tags and commits and
returns facts. Nothing to detect, nothing to guard. Keep it that way.
Action: cut
Branch, bump the version files, write the changelog, and open the release PR.
Context it receives:
{"plan":{"line":"v18","version":"2026.08.1","units":[],"products":[]}}
plan — object — the output of plan
Facts to return:
pr_number — integer
branch — string
Steps
Idempotency check, before anything else. Look for an open PR whose head branch is
<line>/release/<version> (github-ops). If one exists, return it — pr_number, branch
— and stop. Never open a second release PR.
Also check whether the branch exists on the remote without a PR. That is a half-finished cut:
resume from it rather than starting over.
Run /release-management, passing the full plan so it has nothing to ask: the line, the
release number, and every product with its agreed version. It does the work —
- creates
<line>/release/<version> off the line's integration branch,
- updates each product's
version.json,
- updates the inter-product ranges in
Directory.Packages.props,
- writes
release-manifest.json,
- generates each product's
CHANGELOG.md,
- commits it all to the release branch.
Check its work before opening anything. release-manifest.json must exist and list exactly
the products in the plan — CI fails on a release branch without it. Each version bumped must
match the plan.
Open the release PR onto the line's release base via ops-branching · open-pr — pass the
line, never a branch name. Title it
chore(release): Prepare release <version> (<line>). Body: the changelog sections.
Return {"ok": true, "pr_number": N, "branch": "<line>/release/<version>"}.
Idempotency (a MUST). The branch name is a pure function of line + release number, so the same
plan always targets the same branch. Steps 1 and 2 detect an existing PR or an existing branch and
resume instead of duplicating. If cut fails partway, it leaves a branch and no PR — which step 2
picks up on the next call.
Action: publish
Realize the release once its PR has landed.
Context it receives:
{"plan":{"line":"v18","version":"2026.08.1","products":[]},"merge_commit":"9f1c2ab"}
plan — object — the output of plan
merge_commit — string — the commit the release PR landed as
Facts to return:
tag — string
url — string — the published release
Steps
Idempotency check first. If a GitHub release for <version> already exists, return it and
stop. Publishing is the irreversible action here — check before, not after.
Wait for CI on the merge commit. Poll ops-ci · status. Green: continue. Red: stop with
{"ok": false, "detail": "…"} and the failing stage from ops-ci · log — never publish off a
red build. Still pending after a reasonable wait: stop and say it is still running, so the
caller can come back rather than the release half-happening.
Confirm the packages exist. The pipeline's Pack stage must have produced artifacts for
every product in release-manifest.json. A green run that packed nothing is not a release.
Tag — both schemes, in this order. Skip any tag that already exists; never move one.
- one
<Product>@<semver> tag per released product, on merge_commit
- one
<version> release tag, on merge_commit
Push the per-product tags first: if the run dies between the two, the release tag is missing
and step 1 will correctly see the release as unpublished and retry.
Create the GitHub release on the <version> tag, titled Release <version>, with the
changelog sections as the body. Mark it pre-release if any product version carries a
pre-release suffix.
Push no packages. The pipeline holds them as artifacts and nothing in this repo pushes them
to a feed. Report the pipeline run URL so whoever does can find them.
Return {"ok": true, "tag": "<version>", "url": "…", "product_tags": [...], "artifacts_url": "…"}.
Idempotency (a MUST). Three guards, because this is the action that cannot be undone: an
existing GitHub release short-circuits at step 1; every tag is checked for existence before it is
pushed and never moved; and no package is pushed anywhere, so there is no feed to double-publish
to. A failure part-way leaves tags but no release — which the next call finishes.
Action: sync
Put the line's branches back in step after a release.
Context it receives:
{"line":"v18"}
line — string — the line that was just released
Facts to return:
ok — boolean
pr_number — integer|null — set when the sync needed a PR rather than a fast-forward
Steps
Run /post-release-cleanup for the line, passing the release branch and release number so
it has nothing to ask. It:
- merges the release branch into
<line>/main and <line>/dev,
- bumps
version.json on <line>/dev so nightly builds sort above what just shipped,
- deletes the release branch,
- and, on a major cutover only, creates the next line and moves the default branch.
Remove release-manifest.json from <line>/dev if the merge carried it back. It belongs
only on a release branch, and the repo already does this
(chore(ci): Remove release-manifest.json after merge).
Never force a merge. If either back-merge conflicts, open a PR instead and return its
number in pr_number. A conflicting back-merge is a human's call.
Return {"ok": true, "pr_number": null} on a clean sync, or the PR number when one was needed.
Idempotency (a MUST). Before merging, check whether the release commit is already an ancestor
of <line>/main and <line>/dev — if it is, that half is done, so skip it. Check the version on
<line>/dev is not already above the released version before bumping again. A missing release
branch at step 1 means cleanup already ran: that is success, not an error.
Rules
- Reject an unknown action. Report it; never guess, never silently succeed.
- Every action is idempotent. A loop sweeps on a cadence and will hand you the same
work twice.
- A failed action leaves a safe state — no partial publish, no dangling branch it
created and cannot resume.
- Make success and failure unambiguous. End with a single JSON object:
{"ok": true, ...facts...} or {"ok": false, "detail": "..."}.
- All GitHub work goes through
github-ops by operation name — never a raw gh or
curl here.
- Never publish off a red or pending build. Green CI is the gate; there is no override in
this skill.
- Never delete or move an existing tag. Tags are the record of what shipped.
- Release one line at a time. Lines are never forward-merged, so releasing
v18 says nothing
about v17. Each needs its own trigger issue.
- Do not reimplement the release skills. If
/release-management or
/post-release-cleanup is wrong, fix it there — this skill is the loop's adapter, not a second
copy of the process.
- A delegated skill that stops to ask a question is a failure. Report the question; never
answer it yourself.
1---2name: ops-release3description: Ship an Umbraco.Automate release for one version line. Delegates the mechanics to the repo's existing skills — `/release-management` for planning and cutting, `/post-release-cleanup` for the sync — then waits for the Azure pipeline, tags per product and per release, and publishes the GitHub release. Called by name with (action, context-json). Not model-invoked.4---56# ops-release78Turn a release trigger into a shipped release for this repo: plan it, cut it, publish it, then9put the line's branches back in step.1011**Visibility: service.** A framework loop may command it.1213## Invocation1415```16ops-release <action> '<context-json>'17```1819Two positional arguments. `context` is a single JSON object encoded as a string; an20**absent context is `{}`**, not an error. **Reject any action not listed below** — never21guess at one, and never silently succeed.2223| Action | What it must do |24|---|---|25| `plan` | Turn the trigger into release facts: which line, which version, and which units of work the release contains. Reads only; opens nothing. |26| `cut` | Branch, bump the version files, write the changelog, and open the release PR. Idempotent: re-running MUST return the existing release PR, never open a second. |27| `publish` | Realize the release once its PR has landed: tag the commit, push the artifacts to their feed, and publish the release notes. MUST leave nothing half-published on failure. |28| `sync` | Put the line's branches back in step after a release, so the next change starts from what actually shipped. The step manual releases most often forget. |2930## Repo facts every action depends on3132**Two version schemes, and they are not the same thing.**3334| Scheme | Looks like | Where it lives | What it names |35|---|---|---|---|36| Release number | `2026.07.4` | the release branch and the GitHub release | one shipping event, date-based |37| Product version | `18.1.0` | `<Product>/version.json` (Nerdbank.GitVersioning) | one package, semver |3839So one release ships several products, each at its own semver, under one date-based number. The40version in the trigger issue title (`release 2026.08.1`) is the **release number**.4142**Naming, all of which already exists and must not drift:**4344- Release branch: `<line>/release/<release-number>` — e.g. `v18/release/2026.07.3`45- Release PR title: `chore(release): Prepare release <release-number> (<line>)`46- Per-product tag: `<Product>@<semver>` — e.g. `Umbraco.Automate@18.1.0`47- Release tag: `<release-number>` — e.g. `2026.07.4`48- GitHub release title: `Release <release-number>`4950**`release-manifest.json`** at the repo root lists the products to package. CI **fails without it**51on a `vN/release/*` branch. It is deleted again after the release merges.5253**CI is Azure Pipelines** (`azure-pipelines.yml`). It builds, tests, packs NuGet and npm, and54uploads them as **pipeline artifacts**. It does **not** push packages to any feed. Read CI state55through `ops-ci`, never by calling Azure directly.5657### Delegation — and the one thing to watch5859Three of the four actions delegate to skills this repo already ships. All three were confirmed60present:6162| Action | Delegates to | Path |63|---|---|---|64| `plan`, `cut` | `/release-management` | `.claude/skills/release-management/` |65| (via the above) | `/changelog-management`, `/release-manifest-management` | `.claude/skills/` |66| `sync` | `/post-release-cleanup` | `.claude/skills/post-release-cleanup/` |6768> **Both delegated skills are interactive** — they were written for a human at a keyboard and69> use `AskUserQuestion`. A release loop has nobody to answer. **Always pass the complete context70> up front** (line, release number, products, per-product bumps) so there is nothing left to ask,71> and **if a delegated skill still stops to ask a question, treat that as a failure**: return72> `{"ok": false, "detail": "…"}` naming the question. Never invent an answer to keep going — a73> guessed version bump ships the wrong package.7475## Action: `plan`7677Turn the trigger into release facts. **Reads only; opens nothing, branches nothing.**7879**Context it receives** (guidance — never validate it at runtime):8081```json82{"trigger":{"issue_number":512,"version_text":"release 2026.08.1"}}83```8485- `trigger` — object — the issue, label or dispatch that asked for a release8687**Facts to return:**8889- `line` — string90- `version` — string91- `units` — array — the changes included, for the changelog9293### Steps94951. **Read the release number** out of `trigger.version_text` — the text after `release `. If it96 is not a `YYYY.MM.N` release number, stop: `{"ok": false, "detail": "…"}`.97982. **Resolve the line.** If the trigger issue names one (`(v17)`, a `v17` label), use it.99 Otherwise use `lines.primary` from `.claude/ops-repo-meta.json`. The line **must** be in100 `lines.live`; refuse if it is not.1011023. **Detect what changed** since each product's last release tag, using103 `/release-management`'s change-detection phase (its Phase 1) in read-only mode. That gives you104 the changed products, the recommended bump per product, and the cascade — a minor or major on105 Core forces a bump on OpenIddict and Slack.1061074. **Preview the changelog** with `/changelog-management` to collect `units`.1081095. Return `{"ok": true, "line": "…", "version": "…", "units": [...], "products": [...]}`, where110 `products` carries each product's name, current version and proposed new version. Carry that111 through to `cut` — it is what stops the delegated skill needing to ask.1121136. **If nothing changed since the last tag**, say so: `{"ok": false, "detail": "no changes since114 <tag> — nothing to release"}`. An empty release is a mistake, not a no-op.115116**Idempotency (a MUST).** `plan` has no side effects at all: it reads git, tags and commits and117returns facts. Nothing to detect, nothing to guard. Keep it that way.118119## Action: `cut`120121Branch, bump the version files, write the changelog, and open the release PR.122123**Context it receives:**124125```json126{"plan":{"line":"v18","version":"2026.08.1","units":[],"products":[]}}127```128129- `plan` — object — the output of `plan`130131**Facts to return:**132133- `pr_number` — integer134- `branch` — string135136### Steps1371381. **Idempotency check, before anything else.** Look for an open PR whose head branch is139 `<line>/release/<version>` (`github-ops`). If one exists, **return it** — `pr_number`, `branch`140 — and stop. Never open a second release PR.1411422. Also check whether the branch exists on the remote without a PR. That is a half-finished cut:143 resume from it rather than starting over.1441453. **Run `/release-management`**, passing the full plan so it has nothing to ask: the line, the146 release number, and every product with its agreed version. It does the work —147148 - creates `<line>/release/<version>` off the line's integration branch,149 - updates each product's `version.json`,150 - updates the inter-product ranges in `Directory.Packages.props`,151 - writes `release-manifest.json`,152 - generates each product's `CHANGELOG.md`,153 - commits it all to the release branch.1541554. **Check its work before opening anything.** `release-manifest.json` must exist and list exactly156 the products in the plan — CI fails on a release branch without it. Each version bumped must157 match the plan.1581595. **Open the release PR** onto the line's release base via `ops-branching · open-pr` — pass the160 line, never a branch name. Title it161 `chore(release): Prepare release <version> (<line>)`. Body: the changelog sections.1621636. Return `{"ok": true, "pr_number": N, "branch": "<line>/release/<version>"}`.164165**Idempotency (a MUST).** The branch name is a pure function of line + release number, so the same166plan always targets the same branch. Steps 1 and 2 detect an existing PR or an existing branch and167resume instead of duplicating. If `cut` fails partway, it leaves a branch and no PR — which step 2168picks up on the next call.169170## Action: `publish`171172Realize the release once its PR has landed.173174**Context it receives:**175176```json177{"plan":{"line":"v18","version":"2026.08.1","products":[]},"merge_commit":"9f1c2ab"}178```179180- `plan` — object — the output of `plan`181- `merge_commit` — string — the commit the release PR landed as182183**Facts to return:**184185- `tag` — string186- `url` — string — the published release187188### Steps1891901. **Idempotency check first.** If a GitHub release for `<version>` already exists, return it and191 stop. Publishing is the irreversible action here — check before, not after.1921932. **Wait for CI on the merge commit.** Poll `ops-ci · status`. Green: continue. Red: stop with194 `{"ok": false, "detail": "…"}` and the failing stage from `ops-ci · log` — never publish off a195 red build. Still pending after a reasonable wait: stop and say it is still running, so the196 caller can come back rather than the release half-happening.1971983. **Confirm the packages exist.** The pipeline's Pack stage must have produced artifacts for199 every product in `release-manifest.json`. A green run that packed nothing is not a release.2002014. **Tag — both schemes, in this order.** Skip any tag that already exists; never move one.202 - one `<Product>@<semver>` tag per released product, on `merge_commit`203 - one `<version>` release tag, on `merge_commit`204205 Push the per-product tags first: if the run dies between the two, the release tag is missing206 and step 1 will correctly see the release as unpublished and retry.2072085. **Create the GitHub release** on the `<version>` tag, titled `Release <version>`, with the209 changelog sections as the body. Mark it pre-release if any product version carries a210 pre-release suffix.2112126. **Push no packages.** The pipeline holds them as artifacts and nothing in this repo pushes them213 to a feed. Report the pipeline run URL so whoever does can find them.2142157. Return `{"ok": true, "tag": "<version>", "url": "…", "product_tags": [...], "artifacts_url": "…"}`.216217**Idempotency (a MUST).** Three guards, because this is the action that cannot be undone: an218existing GitHub release short-circuits at step 1; every tag is checked for existence before it is219pushed and never moved; and no package is pushed anywhere, so there is no feed to double-publish220to. A failure part-way leaves tags but no release — which the next call finishes.221222## Action: `sync`223224Put the line's branches back in step after a release.225226**Context it receives:**227228```json229{"line":"v18"}230```231232- `line` — string — the line that was just released233234**Facts to return:**235236- `ok` — boolean237- `pr_number` — integer|null — set when the sync needed a PR rather than a fast-forward238239### Steps2402411. **Run `/post-release-cleanup`** for the line, passing the release branch and release number so242 it has nothing to ask. It:243 - merges the release branch into `<line>/main` and `<line>/dev`,244 - bumps `version.json` on `<line>/dev` so nightly builds sort above what just shipped,245 - deletes the release branch,246 - and, on a major cutover only, creates the next line and moves the default branch.2472482. **Remove `release-manifest.json`** from `<line>/dev` if the merge carried it back. It belongs249 only on a release branch, and the repo already does this250 (`chore(ci): Remove release-manifest.json after merge`).2512523. **Never force a merge.** If either back-merge conflicts, open a PR instead and return its253 number in `pr_number`. A conflicting back-merge is a human's call.2542554. Return `{"ok": true, "pr_number": null}` on a clean sync, or the PR number when one was needed.256257**Idempotency (a MUST).** Before merging, check whether the release commit is already an ancestor258of `<line>/main` and `<line>/dev` — if it is, that half is done, so skip it. Check the version on259`<line>/dev` is not already above the released version before bumping again. A missing release260branch at step 1 means cleanup already ran: that is success, not an error.261262## Rules263264- **Reject an unknown action.** Report it; never guess, never silently succeed.265- **Every action is idempotent.** A loop sweeps on a cadence and will hand you the same266 work twice.267- **A failed action leaves a safe state** — no partial publish, no dangling branch it268 created and cannot resume.269- **Make success and failure unambiguous.** End with a single JSON object:270 `{"ok": true, ...facts...}` or `{"ok": false, "detail": "..."}`.271- **All GitHub work goes through `github-ops`** by operation name — never a raw `gh` or272 `curl` here.273- **Never publish off a red or pending build.** Green CI is the gate; there is no override in274 this skill.275- **Never delete or move an existing tag.** Tags are the record of what shipped.276- **Release one line at a time.** Lines are never forward-merged, so releasing `v18` says nothing277 about `v17`. Each needs its own trigger issue.278- **Do not reimplement the release skills.** If `/release-management` or279 `/post-release-cleanup` is wrong, fix it there — this skill is the loop's adapter, not a second280 copy of the process.281- **A delegated skill that stops to ask a question is a failure.** Report the question; never282 answer it yourself.