---
name: release
description: Ship rc or direct-track development to main and production.
---

**Host-native invocation:** Claude `/mmi:release` · Codex `$mmi:release` · Cursor `/release` · jervcode `/release` · Hermes skill_view. A backticked `/name` in this doc names the matching workflow (this skill or a sibling), not a literal command.

**Argument:** `/release minor`, `/release major`, or `/release patch` — a bare bump word, never a `--flag` or env var. No argument → `patch`; a `/release patch` from development is a legitimate ordinary release, `/hotfix` stays the cherry-pick promotion lane.

# /release — ship to main + prod

Full-track repos ship exactly what is on `rc` (never `development`): merge `rc → main`, tag `vX.Y.0`, publish the GitHub Release, deploy prod, roll `development` forward. Direct-track repos ship `development → main`, and the release event fires the repo's own deploy/publish workflow (MMI-Hub: `deploy.yml` + `publish.yml`). `rc` is ephemeral: `/rcand` creates its runtime, `/release` retires it after a confirmed prod deploy (`rcRetirement`). A prod release needs the authorized human's explicit go in the current turn — an agent never self-initiates it. The board needs no touch: items reached `Done` when their PRs merged to `development`.

## Lane

Read META and name the lane before describing or executing anything:

```bash
mmi-cli oracle org project get {owner}/{repo} --json
```

`releaseTrack: direct` → `development → main`, no `/rcand`. Absent → full (`rc → main`), except MMI-Hub, which is direct by the Hub-control special case. Full-track `--dev` ships `development → main` skipping rc and fails closed when `origin/rc` carries content not in `development`. Unreadable META → stop; never infer the lane from branch names, tags or release history.

`deployModel: vercel` (always with `releaseTrack: direct`): the same `development → main` merge is the go-live — Vercel builds `main` on the push. The train dispatches nothing; with `--watch` it waits for the **Production** GitHub Deployment the Vercel GitHub app posts for the release SHA, probes the served URL once, then tags, publishes the Release and announces. No version-file bump on a vercel repo — the tag is the version. The receipt's `workflowRuns` carries a `vercel` row with the Vercel log URL; `pending` with "is the Vercel GitHub app connected?" means no Deployment was ever posted — connect the app, never re-tag.

## Preflight

```bash
mmi-cli devops train doctor --lane release --heal --json
```

Read `ready`, then `findings[]`: each carries `code`, `severity` (`blocker` · `warning` · `healed` · `info`), `source` (`local` · `origin`), `remedy` and `anchor`. `ready: false` → stop and quote every blocker's `code` and `remedy`; an unread origin is never green. `--apply` re-runs this doctor as its step 0 with heal on.

## Apply

Run from the primary checkout on the lane's start branch (`rc`; `development` for direct-track and `--dev`). Export the bump intent once from the bare argument — `MMI_BUMP_INTENT=minor|major|patch`, unset → `patch`; `MMI_RELEASE_VERSION=X.Y.Z` only for an exact target the tag math cannot derive.

Two cases need a summary file. (1) **MMI-Hub** — always: write a fresh 3–6 line neutral summary to `f=$(mkdir -p .jerv/tmp && mktemp .jerv/tmp/release-summary.XXXXXX)` — sourced from the range `origin/main..origin/development`, rewritten in Hub-subsystem terms, never a product or brand name. Hub scope is only `mutmutco/MMI-Hub`: never a product's board, `ds-propagate.yml`, or a product's deploy state. **Build and verify every line from ONE command's output**, never from commit subjects or PR titles alone: `git log --stat --format='%h %s%n%b' origin/main..origin/development` gives each commit's subject, its full body (where a bundled PR records `Closes #N`) and the files it changed in a single pass. A line whose claim you cannot point to in that output does not go in the file, and every `#N` a Hub line cites must appear there: the train refuses `--apply` on a citation that ships in no commit of the cut. (2) **Any product repo whose META carries `releaseChannel`** (`mmi-cli oracle org project get {owner}/{repo} --json`) — required too: read `releaseLanguage` (default `en`) and write 3–6 lines IN THAT LANGUAGE, in plain words for the project's audience: what changed and why it matters to a user; no commit prefixes, no issue or PR numbers, no file names, no product-internal jargon. The train refuses `--apply` without the file, and raw release notes are never posted to a project channel. A repo with no `releaseChannel` announces nothing and needs no file.

```bash
mkdir -p .jerv/tmp
mktemp .jerv/tmp/release-receipt.XXXXXX
```

Scratch lives under `.jerv/tmp` in every repo: a repo-root `tmp/` may not exist at all, and an agent host guards the primary checkout against root writes outside `.jerv/tmp` — either one aborts the release before its first command runs.

Copy the returned pathname into `<result-path>` below; do not rely on shell variables surviving another tool call. For a product without a release channel:

```bash
mmi-cli devops release --apply --watch --json --out "<result-path>"
```

For MMI-Hub or a product with a release channel, also substitute the saved summary pathname:

```bash
mmi-cli devops release --apply --watch --announce-summary-file "<summary-path>" --json --out "<result-path>"
```

Add `--dev` for a full-track dev → main release. Keep `--watch` to block on the deploy/publish runs. `mmi-cli devops release --apply --ack <sha>[,<sha>…]` only when the authorized human has verified that a main-only commit the hotfix-coverage guard refused is already in the candidate — never ack to save time. The one command merges, runs the version fold (automatic, inside `mmi-cli devops release --apply`) that bumps the manifests unconditionally before the tag, pushes the tag first (`gate.yml` runs on `v*` tags, so the fold commit earns its required checks), waits for the required contexts, pushes `main`, publishes the Release, dispatches or correlates the deploy (`tenant-deploy.yml` only for tenant-container), announces only after the deploy and publish runs are green (the org alerts channel for the Hub; the project's own release channel for a product repo), re-syncs project info, and rolls `development` and `rc` forward. Nothing here is done by hand.

**Progress narration.**

Describe the release in plain words before each command: “Preparing release”, “Waiting for checks”, “Waiting for publishing”, or “Finishing the existing release”. Keep temporary-file preparation separate from the long-running release command so its tool title cannot become a temporary filename; carry the returned path explicitly into the release command when tool calls do not share shell variables. Keep stderr visible: the CLI prints `Release: ` stages and elapsed updates every 30 seconds during long operations, while JSON and `--out` remain the verification record. Never redirect both streams into the result file.

Use the actual current stage, never a guessed sequence. Explain a pending result as “Publishing is still pending” or name the unfinished step, then narrate the sanctioned resume command as “Checking the remaining release steps”. Say “Release complete” only after the status fields prove every required follow-up complete. Temporary filenames, `XXXXXX`, receipts, and ledger folding are internal bookkeeping, never a user-facing status.

## Verify

Slack announcements use the GitHub-authenticated Hub service and its server-side MMI vault access.
Never install AWS, ask for AWS credentials, or copy the shared Slack key to unblock a developer.
The Hub chooses the registered channel and checks project authority. A failed announcement keeps
its summary file; an uncertain delivery is never automatically replayed.

Read the saved result, never the exit code (exit `1` = a follow-up failed after promotion; a pending follow-up exits `0`, so a clean exit alone never means every leg resolved):

- `releaseVerdict.releaseStatus` + `followUpStatus` — `succeeded` + `pending` is shipped with the follow-up unresolved: neither failed nor done. A `--resume` receipt carries the same `releaseVerdict` (`command: release-resume`), plus its own `mode` and `state`.
- `deployStatus` — `pending` is never terminal; `promoted: true` holds even when a deploy failed.
- `workflowRuns` — every run on the release SHA (deploy-model runs, any `push: main` deploy, the repo's own `gate`). Watch each non-success entry to conclusion: `gh run watch <run-id> --repo {owner}/{repo} --exit-status`. Name which run is red: `deploy.yml` / `publish.yml` / `tenant-deploy.yml` / `jerv-gateway` is a deploy or publish verdict; a `gate` push run is ordinary CI sharing the SHA.
- With `--watch` the train waits for its own alignment PR to land (true merge) and folds the ledger in the same run, so the receipt ends `succeeded` / `complete`. An external `gh run watch` never folds anything: if the receipt still shows a `pending` leg (no `--watch`, the alignment PR outlived the bounded wait, or a release-event `publish.yml` run on a registry-publish repo had not been correlated by the time the receipt was written — `--watch` waits for the alignment PR, not for that run, so a `publish` leg `pending` with no run URL after `--watch` is the expected direct-track shape, not a failure), fold it live before declaring the cut done or starting any new train: `mmi-cli devops release --resume --watch --json --out <fresh-receipt>`; the NEXT train's doctor otherwise refuses with `ledger-pending`.
- `announceNote` — `announced` (to the alerts channel for the Hub, to the project release channel for a product repo), `skipped`, `not announced` (deploy or publish not green; the note says whether a `--resume` carrying the same `--announce-summary-file` will post it), or the failure note. The channel id itself is never printed.
- `devRollForward` / `rcAlignment` — `pushed`, or `pr-pending` with the alignment PR to land by true merge (Merge floor). An enqueued auto-merge is not evidence of the method; before reporting, read the enqueued method back — `gh pr view <n> --json autoMergeRequest --jq .autoMergeRequest.mergeMethod` — and prove it is `MERGE`. An enqueued `SQUASH` is a stop under the Merge floor, not something to wait out.
- `versionFold`, `rcRetirement`, `checkout` — `returned`, or the named reason you are still on `main`.
- A `private` npm surface (`surfaces.json`): the doctor's `npm-token-rejected` finding comes first. A 401 from `npm whoami` through the `_org` `npm/NPM_TOKEN` vault hop means the token is rejected, and every authenticated `npm view` then 404s exactly like an absent package — rotate the token; only after a login is a restricted 404 "unverifiable from here", never "not published" (`publish-private-package`). On a repo whose workflows publish through Trusted Publishing and name no registry token, that row is `info`, not a warning: no lane consumes the org token, so it constrains your manual read only.

Then the bounded Latest Release read; its `tagName` must equal the resolved tag:

```bash
gh api repos/{owner}/{repo}/releases/latest --jq '{tagName:.tag_name,targetCommitish:.target_commitish,publishedAt:.published_at,url:.html_url}'
```

Four facts close the verdict: the `origin/main..origin/development` count, the tag on origin, Latest, and green runs on the release SHA.

## Recovery

Every refusal and every doctor blocker names its `code` and `anchor`: open `docs/Guides/train-troubleshooting.md#<code>` and follow that entry, nothing else. That guide is test-parsed, never free text — the infra drift and plugin-set suites validate every anchor cited here and assert policy from its wording — so an edit to it re-runs the infra suite (`npm --prefix infra test`) in the same PR: a heading or slug change is a code change. A pre-tag abort with no finding code (for example, a transient secrets-preflight timeout) is a transient stop: nothing was promoted and no tag was cut. Confirm no tag reached origin (`git ls-remote --tags origin` or `gh api repos/{owner}/{repo}/tags`), then rerun `mmi-cli devops release --apply` as a fresh cut, never `--resume`; resume is only for a partial release with the tag already on origin. A partial release (tag on origin, later phases pending or failed) continues with `mmi-cli devops release --resume --watch --json --out <fresh-receipt>` — the only continuation form: `--resume` never re-tags or republishes, posts a deferred announcement only when it is the run that first proves deploy and publish green, and `--apply` belongs to a new cut only. A refused train is a stop (Merge floor): fix the cause on `development` through a normal PR, then rerun.

A **content-caused failed deploy leg on a published release** has no rerun that can succeed: the promoted tag is immutable, redeploying it rebuilds the same broken SHA, and the unresolved ledger blocks every lane — release, hotfix and candidate alike — while `--abort` is unavailable because the release is published. When the doctor's ledger blocker names `deploy` as the only unresolved leg and the deploy failure is proven content-caused (the deploy-time validator rejected the shipped content itself), the sanctioned exit is `mmi-cli devops release --supersede-deploy --apply` — an explicitly master-approved closure, run only after the cause fix is merged on `development`. It closes the deploy leg as superseded (the ledger turns phases-green), and the next patch train — usually `/hotfix`, which carries the fix to main — runs normally; its lifecycle gate archives the closed ledger aside. Never hand-edit the ledger file, never delete it to unblock a train.

A **content-caused failed publish leg on a fully promoted release** is the same deadlock with its own exit: the tag is on `main` and the GitHub Release is live, so `--abort` refuses (it only ever deletes a proven unpublished candidate); `--resume` only re-reads the failed publish run — the tag is immutable, so no rerun can pass; `--supersede-deploy` is deploy-hardwired. When the doctor's ledger blocker names `publish` as the only unresolved leg and the publish failure is proven content-caused (a release gate rejected the shipped content — e.g. a platform-dependent distribution BOM — not a transient npm/transport error), the sanctioned exit is `mmi-cli devops release --supersede-publish --apply`, run only after the cause fix is merged on `development`. It closes the publish leg as superseded, the next patch train carries the fix, and its lifecycle gate archives the closed ledger. A transient publish failure (credentials, network, quota) is NOT content-caused: retry `--retry-publish <run-id>` or resolve the run instead. If no CLI path fits and the release must be rebuilt by hand, the manual sequence — proven on the v1.62.1 recovery — is: retire the ledger path FIRST (archive `phases.json` aside, exactly as the ledger-failed receipt names), THEN delete the failed tag and GitHub Release, THEN re-cut the same version with a fresh `--apply` which re-points the tag onto the new bump. The order is a hard constraint: deleting the tag before the ledger path is retired deadlocks both `--abort` and `--resume`, because the ledger then anchors a tag that no longer proves anything.

## Merge floor

- **Never squash-merge a tagged commit (#3167).** A squash re-mints the SHA and orphans the tag from the branch lineage, so every tag-anchored check mis-reads forever after. Any merge that carries an already-tagged commit — the `main → development` roll-forward, an alignment PR, a hotfix fold PR — lands as a true merge: `mmi-cli devops pr merge <number> --auto --merge` (or `--wait --merge`); `--wait` alone still squashes. The hotfix main-base PR itself may squash: its tag lands after, on the merged `main` HEAD, and the `-x` trailer must survive the squash message.
- **A refused train is a stop, never a license to finish by hand.** Every fail-closed halt — doctor blocker, authority probe, coverage guard, untolerated conflict, required checks, stray tag, ledger — ends the run. Fix the cause on `development` through a normal PR and rerun the train; never hand-resolve on `main`, hand-cherry-pick, bare-push a train branch, ack to save time, or bypass a check.
- **Version probes anchor on the latest Release tag, not a branch (#2734).** A branch manifest or a stale local tag is not a version source; a surviving local tag silently mints the next version.
- **One train at a time on a repo (#5380).** Two overlapping runs race the shared tag namespace; the ledger refuses the second — never start one to help the first.
- **Tag and Release deletion are deletion-class:** the authorized human's explicit per-turn go, named in the guide entry that prescribes it.

## Report

Title first, verdict first. Name the resolved tag (never a placeholder like `vX.Y.0`) and the Release URL, the receipt's status fields (`releaseStatus`, `followUpStatus`, `deployStatus`, `promoted`), and every workflow run row with its conclusion and evidence — use its URL when emitted, otherwise the receipt's explicit no-run-URL note — saying which run is red, and whether it is a deploy/publish verdict or ordinary CI — `announceNote`, the alignment note (`devRollForward`, `rcAlignment`, or the hotfix fold PR and whether it landed), the `checkout` clause, and the sanctioned dispatch mechanism you used. A pending follow-up is reported as unverified, never as success. Product names appear only when releasing that product's repo.

## Retro

Before the final report, answer one question honestly: did this skill's own instructions misfire this run — ambiguous wording, a misleading message, or an environment failure it should have warned about? Process only, never the user's code or task. If yes, file one lesson and move on; a clean run is silent (hard cap: one per run). It lands on the owning board, deduplicated, and is fixed only through a reviewed PR — never edit the skill live. The retro is advisory: if the call fails, note it and continue. File through MMI with the command below. The amendment itself stays reference-free: the skill-drift gate rejects issue references in any section outside Merge floor, so carry the evidence in words or point at the guide entry — never inline reference markers.

```bash
mmi-cli learning skill-lesson --skill release --title "<what misfired>" --body "<what; evidence; proposed amendment>"
```
