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.
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.
disable-model-invocation
true
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
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:
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 — 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.
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.
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.
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.