Ship Release
Drives a release from "fixes merged on dev" to "published GitHub release with all platform assets verified". Every irreversible step (merge, tag push, release publish) is gated on explicit user approval.
Invocation
/ship-release 4.15.2— explicit target version./ship-release— infer next version: patch bump over the latest stable tag, or ask if ambiguous.- Alpha:
/ship-release 4.17.0-alpha.1. Stable promotion:/ship-release 4.17.0when the latest tag is4.17.0-alpha.N. Patch:/ship-release 4.16.1when the latest stable tag is4.16.0.
Release types at a glance
| Type | Bump branch cut from | Bump PR targets | Merge method | Tag lands on |
|---|---|---|---|---|
Alpha X.Y.0-alpha.N |
fresh origin/dev |
dev |
squash | dev tip |
Stable X.Y.0 |
fresh origin/dev |
dev, then a release PR dev→master |
bump: squash; release: merge commit (gh pr merge --merge) |
master merge commit |
Patch X.Y.Z |
release/X.Y.x (cut from tag X.Y.0 if it doesn't exist yet) |
release/X.Y.x |
squash | release/X.Y.x tip |
Hard rules
- NEVER merge, tag, or publish without explicit user approval at that gate. Preparing a branch/PR is reversible; merge/tag/publish are not.
- Stable release PRs (
dev→master) must be merged with a true merge commit (gh pr merge --merge), never squash. Squashing forks history permanently —masterstops being a subset ofdev's commit graph. Every other bump PR (alpha ondev, patch onrelease/X.Y.x) still squash-merges as usual. - Tag only AFTER the relevant bump/release PR is merged, and tag the exact commit that carries the target version — a tag pointing anywhere else ships the wrong tree.
- Tag name is the bare version (
4.15.1, novprefix) —build-release.ymltriggers only on semver tag pushes, and the auto-updater feed derives frompackage.jsonversion, which MUST match the tag. - Bump PRs squash-merge (history convention:
chore: bump version to X.Y.Z (#NNNN)). - All three platform jobs (ubuntu / macos / windows) must be green before the release counts as buildable. No partial releases.
- A release without the full asset matrix (below) is NOT done — report exactly which assets are missing.
- Monitor CI in the background (
run_in_backgroundBash orwatcher) — never block the session polling in foreground. - Tags always go through
yarn release:tag(scripts/release-tag.ts), never a baregit tagpush. The script is channel-aware: it verifies HEAD is an ancestor of the allowed ref for the tag's channel (alpha →origin/dev; stable/patch →origin/masteror the matchingorigin/release/*) before tagging. An intentional escape hatch,--allow-unverified-ref, bypasses the ancestor check — only use it with explicit user confirmation that the ref is correct.
Phase 0 — Resolve version & scope
- Fresh state:
git fetch origin dev master --tags(also fetch the relevantrelease/X.Y.xfor a patch). - Latest tags, semver-sorted (not by creation date — an alpha or an older
version created later can otherwise look newest):
git tag --sort=-v:refname | head -5. - Resolve TARGET and its type from the arg, or propose one:
- No pre-release suffix and the latest tag on that
X.Yline is an alpha → stable promotion. - Next
X.Y.Z+1after an existing stable tagX.Y.Z→ patch. - Next
X.(Y+1).0-alpha.1after the latest stable tag → alpha.
- No pre-release suffix and the latest tag on that
- Collect what ships:
- Alpha/stable:
git log <last-tag>..origin/dev --oneline --no-merges. - Patch:
git log <last-tag>..origin/release/X.Y.x --oneline --no-mergesplus the cherry-pick candidates still ondevonly. Filter out chore/version-bump commits.
- Alpha/stable:
- If the relevant branch has nothing new since the last tag → STOP and tell the user there is nothing to release.
Phase 1 — Release notes draft
- Map each shipped commit to its PR (
(#NNNN)suffix) and pull titles:gh pr view NNNN --json title,labels. - Draft notes grouped as: 🐛 Fixes / ✨ Improvements / 🔧 Internal. Straightforward language, what changed and why — no invented metrics.
- Customer-facing framing rule applies: partial-scope fixes are "hardening" / "did not cover path X", never "was broken" / "regression".
- Show the draft to the user. Notes get applied to the GitHub release in Phase 5.
Phase 2 — Bump branch & PR
Alpha
- Create the release worktree off fresh
origin/devand record its path — every command below runs inside it:git worktree add ../Rocket.Chat.Electron-worktrees/release-<version> -b chore/release-<version> origin/dev RELEASE_WT=$(pwd)/../Rocket.Chat.Electron-worktrees/release-<version> cd "$RELEASE_WT" - Bump
"version"inpackage.jsonandmac.bundleVersioninelectron-builder.json(seedocs/release-process.mdfor thebundleVersionformat/increment rule). - GATE: show the diff and STOP for explicit user approval before the first commit + push.
- Commit
chore: bump version to <version>, push, open a PR todev. - Wait for
validate-prchecks. GATE: show PR URL + checks status. STOP until the user says merge. - Squash-merge:
gh pr merge <PR> --squash. Branch protection requires 1 approving review, so this typically needs--admin(the release manager has bypass) — otherwisegh pr mergerefuses with "requirements have not been met".
Stable (promotion)
- Same worktree setup as alpha, off fresh
origin/dev. - Bump
"version"inpackage.jsonto the bare version (drop the pre-release suffix, e.g.4.17.0-alpha.6→4.17.0). - GATE, commit, push, open a bump PR to
dev. Wait for checks. GATE: STOP until the user says merge. Squash-merge (branch protection requires 1 approving review, so this typically needs--admin). git -C "$RELEASE_WT" fetch origin devand confirm the merge commit is HEAD oforigin/devwithpackage.jsonat TARGET.- Open the release PR:
dev→master(gh pr create --base master --head dev --title "chore: release <version>"), body = the shipped-changes list from Phase 1. GATE: show PR URL + checks status. STOP until the user explicitly approves the promotion merge — this is the point where history becomes irreversible.
Patch
- Ensure the patch line exists, cut from the stable tag it patches:
git fetch origin --tags git ls-remote --heads origin release/<X.Y.x> # check if it already exists # if missing: git worktree add ../Rocket.Chat.Electron-worktrees/release-<X.Y.x> -b release/<X.Y.x> <X.Y.0> git push origin release/<X.Y.x> - Cherry-pick the target fixes from
devonto the release branch (in a worktree checked out torelease/<X.Y.x>):
GATE: show the cherry-picked commits and STOP for approval before pushing.git cherry-pick <fix-commit-sha> [...] - Bump
"version"inpackage.jsontoX.Y.Z, commit, push a bump PR targetingrelease/<X.Y.x>. Wait for checks. GATE: STOP until the user says merge. Squash-merge (branch protection requires 1 approving review, so this typically needs--admin).
Phase 3 — Merge & tag
All commands in this phase run inside $RELEASE_WT (git -C "$RELEASE_WT" ...
or stay cd'd in) — never in the user's own checkout.
Alpha
git -C "$RELEASE_WT" fetch origin devand confirm the squash-merge commit is HEAD oforigin/devwithpackage.jsonat TARGET.- GATE: confirm with the user before pushing the tag.
- Detach onto the
devtip and tag:MERGE_SHA=$(git -C "$RELEASE_WT" rev-parse origin/dev) git -C "$RELEASE_WT" checkout "$MERGE_SHA" node -p "require('$RELEASE_WT/package.json').version" # MUST print TARGET (cd "$RELEASE_WT" && yarn release:tag)
Stable
- After the Phase 2 release PR is approved by the user, merge it with a
true merge commit — never squash:
Branch protection requires 1 approving review, so this typically needsgh pr merge <RELEASE_PR> --merge--admin(the release manager has bypass) — otherwisegh pr mergerefuses with "requirements have not been met". git -C "$RELEASE_WT" fetch origin masterand confirm the merge commit is HEAD oforigin/masterand itspackage.jsonhas TARGET.- GATE: confirm with the user before pushing the tag.
- Detach onto the
mastermerge commit and tag:MERGE_SHA=$(git -C "$RELEASE_WT" rev-parse origin/master) git -C "$RELEASE_WT" checkout "$MERGE_SHA" node -p "require('$RELEASE_WT/package.json').version" # MUST print TARGET (cd "$RELEASE_WT" && yarn release:tag)
Patch
git -C "$RELEASE_WT" fetch origin release/<X.Y.x>and confirm the squash-merge commit is HEAD oforigin/release/<X.Y.x>withpackage.jsonat TARGET.- GATE: confirm with the user before pushing the tag.
- Detach onto the release-branch tip and tag:
MERGE_SHA=$(git -C "$RELEASE_WT" rev-parse origin/release/<X.Y.x>) git -C "$RELEASE_WT" checkout "$MERGE_SHA" node -p "require('$RELEASE_WT/package.json').version" # MUST print TARGET (cd "$RELEASE_WT" && yarn release:tag)
All types
yarn release:tag (scripts/release-tag.ts) reads the version from
package.json, runs the channel-aware ancestor guard, refuses if the tag
already exists or isn't greater than the latest tag in-channel, then tags
the current HEAD as the bare version and pushes it. It prompts
Proceed? (y/N) — pipe y for non-interactive (echo y | yarn release:tag).
- node_modules required: a fresh worktree has none, so
yarn release:tagfails withCouldn't find the node_modules state file (findPackageLocation). Runyarn installin the worktree first, or replicate the script's exact guard by hand (fail closed) if a fast tag is unavoidable:cd "$RELEASE_WT" if git rev-parse -q --verify "refs/tags/<version>" >/dev/null; then echo "TAG EXISTS — abort" >&2 exit 1 fi git tag -- <version> test "$(git rev-list -1 <version>)" = "$MERGE_SHA" || { echo "tag does not point at merge SHA — abort" >&2; exit 1; } git push origin refs/tags/<version> - The tag push is the only trigger for
build-release.yml— branch pushes todev/master/release/*no longer start a release build. Find the run:gh run list --workflow=build-release.yml --limit 5 --json databaseId,headBranch,status(the release run'sheadBranchis the tag ref itself).
Phase 4 — Monitor pipeline
- Poll the tag run in the background:
gh run view <run-id> --json status,conclusion,jobs. Full matrix typically takes 40–90 min; macOS is usually last (notarization). - On failure:
gh run view <run-id> --log-failed, report the verbatim error and which platform broke. Known trap (CLAUDE.md): Windows signing is two-phase Google Cloud KMS — MSI failures often trace to KMS CNG provider conflicts. - A failed single job can sometimes be re-run:
gh run rerun <run-id> --failed— ask the user first. - Do not report progress on every poll; surface only completion, failure, or a stall (>2h).
Phase 5 — Verify release & publish
gh release view <version> --json name,isDraft,url,assets.Assert the full asset matrix — missing entries = release NOT done:
Platform Expected assets macOS -mac.dmg(+.blockmap),-mac.pkg,-mac.zip,-mas.pkg,latest-mac.ymlWindows x64/ia32/arm64 × ( .exe+.blockmap,.msi,.appx), universal-win.exe(+.blockmap),latest.ymlLinux .deb,.rpm,.snap,.AppImage,.tar.gz,latest-linux.yml(4.15.1 reference: 27 assets total.)
Apply the Phase 1 release notes:
gh release edit <version> --notes-file <file>.Alphas: mark prerelease (
gh release edit <version> --prerelease) while still a draft — do this before the publish gate, never after, so the alpha is never briefly visible to stable clients.If the release is a draft: GATE — ask before publishing (
gh release edit <version> --draft=false). Publishing exposes the update feed (latest*.yml) to every installed client — this is the point of no return for auto-update.
Phase 6 — Wrap up
- Report: release URL, asset count, platforms green.
- Cleanup: remove the release worktree (
git worktree remove ../Rocket.Chat.Electron-worktrees/release-<version>). - Stable only: confirm
dev'spackage.jsonstill equals TARGET (the version invariant) — it should, since the bump happened ondevbefore promotion. - Patch only: remind the user that if this fix was authored directly on
the release branch (not cherry-picked from
dev), it must be forward-ported todevvia a small cherry-pick PR — the one exception to the never-back-merge rule. - Optional (ask): transition linked Jira tickets to Done (desktop tickets: assignee Jean, component Electron) and comment the release URL on shipped PRs.
Failure modes
| Symptom | Likely cause | Action |
|---|---|---|
Tag run missing from gh run list |
Tag pushed before merge, or push rejected | Verify tag exists on remote and points at the correct branch's HEAD |
| Windows job fails at signing/MSI | KMS CNG provider conflict (two-phase signing) | Read --log-failed; usually re-run, not code |
| macOS job stuck >1h at notarize | Apple notarization queue | Wait; stall threshold 2h before escalating |
| Release exists but assets partial | One platform job failed after others published | Fix/re-run failed job; electron-builder appends to same release |
latest*.yml version ≠ tag |
package.json bump missed before tag | Critical — auto-updater breaks; delete release+tag, redo from Phase 2 |
yarn release:tag guard rejects HEAD |
Tagging from the wrong branch for the channel (e.g. tagging a stable off dev directly, or an alpha off a release/* branch) |
Re-verify you're on the correct branch/commit; only use --allow-unverified-ref with explicit user confirmation |
Release PR (dev→master) accidentally squashed |
Wrong merge method selected in the merge dialog/CLI | Irreversible — history has forked; escalate to the user immediately, do not attempt to "fix" it by force-pushing master |