release-ship · git:20261026.04d24cd · 2026-10-26 · sha256 6eb961af6f7feac7
release-ship git:20261026.04d24cdB
Immutable. This exact content never changes and is served at /api/v1/blob/6eb961af6f7feac7.
--- disable-model-invocation: true description: "Executes the release workflow for a repository after release-gate passes. Use when tagging, cutting, or shipping a release. Follows the prep-PR pattern: version bump, CHANGELOG, and walkthrough ship in one PR before tagging." allowed-tools: - Bash - Read - Edit - Skill --- # Release Final step in the release pipeline. Assumes `release-gate` has already passed and the target version has been decided. This skill follows the **prep-PR pattern**: version bump + CHANGELOG + walkthrough ship as one atomic PR. The tag is applied **after merge**, pointing at the merged commit. Never use a tool that bumps and tags in one step (`npm version`, `bun version`, `hatch version --tag`) — they tag immediately and skip the CHANGELOG and walkthrough entirely. The workflow is a single linear pass — run the phases in order: 0. Read the plan → 1. Prep branch → 2. Version bump → 3. CHANGELOG → 4. Walkthrough → 5. Build, commit & open PR → **(merge)** → 6. Gate, then tag → 7. Push tag → 8. Release notes → 9. Deploy → 10. Verify ## Prerequisites Before starting, confirm: - `release-gate` passed with no FAIL status, **or** exited `3` (NOT STARTED), which is the normal state before a release: it means the current version is already tagged and phases 1-5 below are exactly what it is asking for. Phase 6 re-runs the gate itself and requires exit `0` — that check is a step in the workflow, not a precondition to remember. - Working tree is clean and on the default branch - Target version decided - `jq` is on `PATH`, and so is `gh` when the plan says `is_github=1` or a `release_mode` other than `tag` If any prerequisite is unclear, ask rather than proceeding. ## Phase 0: Read the Plan Every phase below is driven by one command. Run it first with the **target** version — `prep_branch` and `tag` are derived from it: ```bash ~/.claude/skills/release-ship/scripts/release-plan.sh <version> ``` It prints `key=value` lines resolved from the same profile the gate uses, so the two skills cannot disagree about the repo: ```text profile=Obsidian plugin profile=Taskfile (.release-gate) version=2.1.0 version=2.1.0 tag=2.1.0 tag=v2.1.0 last_tag=2.0.5 last_tag=v2.0.0 prep_branch=release/2.1.0 prep_branch=release/2.1.0 primary_version_file=package.json primary_version_file=cx.js primary_version_spec=jq:.version primary_version_spec=grep derived_version_files=manifest.json versions.json version_sync_cmd=npm_package_version="2.1.0" bun run version build_cmd=bun run build build_cmd= changelog=CHANGELOG.md changelog=absent walkthrough=WALKTHROUGH.md walkthrough=WALKTHROUGH.md expected_changes=… expected_changes=cx.js WALKTHROUGH.md release_mode=gh release_mode=gh release_workflow= release_workflow= release_assets=main.js manifest.json release_assets= deploy_cmd= deploy_cmd=task deploy is_github=1 is_github=1 prerelease=0 prerelease=0 ``` Show the plan to the user before starting. An empty value means that step does not apply here — `build_cmd=` is no build, not a missing key. **`release_mode` is the one fork that changes the shape of the work**, and getting it wrong is visible in both directions: `workflow` means pushing the tag triggers a workflow that creates the GitHub release itself, so Phase 8 waits for it and edits the notes onto the release it made; `gh` means nothing is watching the tag, so Phase 8 creates the release. Wait in `gh` mode and you poll for a run that will never appear. Create in `workflow` mode and you race the workflow for the same release. `tag` means the annotated tag and the CHANGELOG are the whole release, so Phase 8 creates nothing. It is the default off GitHub. **`is_github` is the other fork.** At `0` the origin is not GitHub: there is no PR to open or look up, and Phases 5 and 6 work from git alone. ## Phase 1: Prep Branch Confirm the tag is free, then branch off the default branch: ```bash git tag -l "<tag>" # expect empty git checkout -b <prep_branch> ``` If `git tag -l` prints the tag, stop: the version is already released, or a tag was created out of band. Do not proceed. ## Phase 2: Version Bump **Edit `primary_version_file` by hand** — with `Edit`, not a scripted substitution. The plan names the file; it does not name the line, and for `primary_version_spec=grep` the version string may appear in the file more than once (a comment, a compatibility note, a test literal). A blanket `sed s/<old>/<new>/g` rewrites all of them. - `primary_version_spec=jq:.version` — the `version` field of that JSON file. - `primary_version_spec=grep` — the version literal in that source file, usually a constant. Then regenerate the derived files, if the plan names a sync command: ```bash bash -c '<version_sync_cmd>' ``` The plan prints that command with the target version already substituted, so it can be run verbatim. Do not hand-edit anything in `derived_version_files`; that command owns them. Verification is Phase 6's gate check 10, which compares every declared file against the target. There is no need to re-implement it here — but if you want to see it now, run the gate with the target version and read row 10 alone; the other rows are meaningless until the prep is finished. ## Phase 3: CHANGELOG Draft the entry from `git log <last_tag>..HEAD` and present it to the user for review before committing. **If `changelog=absent`, create `CHANGELOG.md` in this phase.** The gate merely skips a missing changelog; ship requires one, because in `gh` mode it is the only source for the release notes and `extract-changelog.sh` exits 1 without it. A first changelog starts with this release's section — do not reconstruct the project's history. Add the `## <version>` section **above** the previous entry: ```markdown ## 1.4.0 ### Added - Support for nested callouts in preview mode ### Fixed - Frontmatter properties no longer duplicate on save ``` Headings are bare `## <version>`, newest first. A trailing date (`## 1.4.0 - 2026-03-08`) is accepted by both the gate and the extractor, but write the bare form. Verify: `grep -n "^## " CHANGELOG.md | head -3` shows the new section above the previous release's. ## Phase 4: Walkthrough If the plan names a walkthrough, regenerate it via the `code-walkthrough` skill so its snippets reflect the release state. **Regenerating the snippets is not enough.** Gate check 8 confirms every quoted snippet is still in the file its label names; nothing checks the prose around them. A release that renames or deletes an identifier leaves the commentary describing something that no longer exists, and check 8 still goes green. After regenerating, grep the prose for what this release changed: ```bash git diff <last_tag>..HEAD --name-only | xargs -n1 basename | sort -u grep -n '<renamed-or-deleted-identifier>' WALKTHROUGH.md ``` Fix the prose in the same commit. Stale commentary is the failure mode the walkthrough exists to prevent. **Verify every quoted snippet after the formatter runs, not before.** Prose reflows and fenced blocks may be rewritten; a snippet checked pre-format is a snippet unchecked. Gate check 8 is that check, so run the gate on the prep branch before opening the PR. This is also the phase where `THEORY.md`, `README.md` and `CLAUDE.md` come current, if the repo has them. They are release-time work by design: documents that cross-reference each other can only be made consistent from a settled state, all at once. ## Phase 5: Build, Commit and Open PR If the plan names a build command, run it **before** committing: ```bash <build_cmd> ``` Where a repo tracks its build output — an Obsidian plugin's `main.js`, say — gate check 16 fails when the committed artifact does not match a fresh build, which is the normal state after any dependency PR that merged without a rebuild. No other phase stages it, so the prep PR is the release's only chance to carry a current one. Stage what actually changed rather than a fixed list: ```bash git add -u git status --porcelain ``` `git add -u` stages every tracked modification and no untracked file. Compare the result against the plan's `expected_changes`: - A path in `expected_changes` — expected. - A path the build rewrote — expected, and the reason the build ran before staging. - **Anything else, or any untracked file** — stop and show the user. An untracked file after a build is a new build output nothing tracks (a source map, a second stylesheet, typically after a bundler bump); it either belongs in `.gitignore` or belongs in the release, and that is the user's call. A fixed file list is how such a file gets silently left out. Then one atomic commit for the whole prep: ```bash git commit -m "chore: prepare release <version>" ``` Run the repo's local gate on the committed prep branch. It is the check; no CI is waited on: ```bash ~/.claude/skills/issue-pr/scripts/pr-cycle.sh gate "<gate>" # e.g. "task check" ``` Exit `3` means the gate failed or rewrote files; fix, commit, and run it again. Push only on `0`. Confirm with the user before pushing, then: ```bash git push -u origin <prep_branch> gh pr create --title "chore: prepare release <version>" --body "..." # is_github=1 only ``` Draft the PR body from the CHANGELOG entry. At `is_github=0` there is no PR: the pushed branch is the review copy, and the user merges it locally or on their forge. Verify: `git show --stat HEAD` lists exactly the files staged above. At `is_github=1`, also check that `gh pr view --json state,url` reports the PR as `OPEN`. Stop here and wait for the prep branch to merge. The user reviews and merges it. ## Phase 6: Gate, Then Tag After Merge Once the PR is merged, sync the default branch: ```bash git checkout <default-branch> git pull --ff-only origin <default-branch> ``` Then re-run the gate against the merged commit and **require exit `0`**: ```bash ~/.claude/skills/release-gate/scripts/release-check.sh <version>; echo "exit=$?" ``` Exit `0` is the only value that proceeds. On `1`, `2` or `3`: show the table, name the rows that block, and stop without tagging. The gate says so itself — exit `2` prints `Result: WARNINGS ... not ready, clear these and re-run`. Now tag the merged commit, using the plan's `tag` (the prefix convention is the repo's, not this skill's): At `is_github=1`, find the prep merge from the PR: ```bash MERGED_SHA=$(gh pr list --state merged --head "<prep_branch>" \ --json mergeCommit --jq '.[0].mergeCommit.oid') ``` At `is_github=0` there is no PR to ask, so use git. Accept `HEAD` if it carries the prep commit's subject, which survives a squash, a rebase or a fast-forward. Also accept it if `HEAD` is a merge commit whose second parent is the pushed prep branch: ```bash MERGED_SHA="" if git log -1 --format=%s HEAD | grep -qE '^chore: prepare release <version>( \(#[0-9]+\))?$'; then MERGED_SHA=$(git rev-parse HEAD) elif [ "$(git rev-parse -q --verify HEAD^2)" = "$(git rev-parse -q --verify origin/<prep_branch>)" ]; then MERGED_SHA=$(git rev-parse HEAD) fi ``` Either way, then: ```bash if [ -z "$MERGED_SHA" ]; then echo "No prep merge found for <prep_branch>." echo "Look at 'git log --oneline -5' and ask which commit is the prep merge." exit 1 fi if [ "$MERGED_SHA" != "$(git rev-parse HEAD)" ]; then echo "Prep merge is not HEAD — the gate did not check the commit this would tag." echo " prep merge: $MERGED_SHA" echo " HEAD: $(git rev-parse HEAD)" echo "Commits that landed after the prep merge:" git log --oneline "$MERGED_SHA"..HEAD echo "Stop. Ask which commit to tag; do not choose one." exit 1 fi git tag -a <tag> -m "Release <version>" "$MERGED_SHA" ``` **The HEAD check is not a formality.** The gate in the step above runs against `HEAD`; this lookup resolves a commit independently. Nothing makes them the same commit, and when they differ the tag publishes a tree no gate ever saw — a green table on screen for one commit while a different one ships. **When it fires, stop and ask — do not pick.** Neither answer is automatically right. Tagging the prep merge publishes an ungated tree; tagging `HEAD` tags a commit that is not a `release/<version>` prep merge, against the tag convention this repo states. Which one is correct depends on what those later commits are, and that is the user's call. Landing them _before_ the prep PR, or re-cutting the prep branch on top, are the two ways to make the question not arise. On GitHub the lookup keys on the **prep branch**, not on a commit message. A human can reword the subject in the squash-merge dialog, while the branch name is set by Phase 1 and survives squash, rebase and subject rewrites. Off GitHub the subject is all git keeps of a squash. If it was reworded, the git lookup finds nothing and stops, which is the safe way to fail. Confirm with the user before pushing the tag. ## Phase 7: Push Tag ```bash git push origin refs/tags/<tag> ``` Spell it `refs/tags/<tag>`. That form can only push a tag, never a branch that happens to share its name, and it is the form the user's permission rules allow. In `release_mode=workflow` this is what starts the release: the push triggers the named workflow, which builds the project and creates a GitHub release whose assets are whatever that workflow's `files:` block lists. Extract the actual list before producing the final output: ```bash yq '.jobs.build.steps[] | select(.uses == "softprops/action-gh-release*") | .with.files' .github/workflows/<release_workflow> ``` If `yq` is unavailable, grep for the `files:` block and read the lines that follow. In `release_mode=gh` the push does nothing on its own, and Phase 8 creates the release. In `release_mode=tag` the pushed tag is the release. ## Phase 8: Release Notes **In `release_mode=tag`, skip this phase.** No release object exists to carry notes: the CHANGELOG at the tag is the release notes, and Phase 3 already wrote and reviewed them. Otherwise, extract this version's CHANGELOG section first. Both GitHub modes need it, and it fails the same way in both if Phase 3 went wrong: ```bash NOTES="$(mktemp -t notes)" ~/.claude/skills/release-ship/scripts/extract-changelog.sh <version> > "$NOTES" ``` It prints everything between `## <version>` and the next `## ` heading, trimmed, and exits 1 if that section does not exist. Avoid `sed -n '/^## X/,/^## /p'` — it prints the next release's heading, and the version's dots act as regex wildcards. **In `release_mode=workflow`**, wait for the workflow to finish, then edit the notes onto the release it created. The poll is bounded by a script that ships with this skill (no `timeout`(1) dependency — macOS BSD userland doesn't ship it): ```bash RELEASE_WORKFLOW=<release_workflow> \ ~/.claude/skills/release-ship/scripts/wait-for-release.sh <tag> gh release edit <tag> --notes-file "$NOTES" ``` - It prints the run's conclusion (e.g. `success`, `failure`) and exits 0 when the run finishes; exits 1 on timeout or if no run is found. - Optional args override the defaults: `wait-for-release.sh <TAG> [MAX_SECONDS] [INTERVAL_SECONDS]` (default `600 15`). - **The tag argument is required.** There is a gap between pushing the tag and the run appearing in the API; without the tag filter the script polls whichever release run is newest, which during that gap is the _previous_ release — already `completed/success`. It would print `success` for a release that never started. If the printed conclusion is not `success`, report the failure and stop. **In `release_mode=gh`**, create the release directly, attaching the plan's `release_assets` (none means a notes-only release): ```bash gh release create <tag> --title "<version>" --notes-file "$NOTES" <release_assets> ``` Add `--prerelease` when the plan says `prerelease=1` (the version carries a `-alpha`, `-beta` or `-rc` suffix). Without it gh marks the release "Latest", above the last stable one. In `workflow` mode, check the release the workflow made, and `gh release edit <tag> --prerelease` if it is not marked. The assets come from the tagged checkout, so run this from the default branch at the tag — the same tree Phase 6 gated. For an Obsidian plugin they are what users install; a release without them is broken, not merely bare. Write the notes from the CHANGELOG, never from memory of the session. Improvised release notes are how a public artifact acquires a sentence nobody checked. ## Phase 9: Deploy If the plan's `deploy_cmd` is empty, skip this phase. Otherwise, deploy runs from the tagged commit, the tree Phase 6 gated. Confirm with the user, then: ```bash git checkout --detach <tag> bash -c '<deploy_cmd>'; echo "exit=$?" git checkout <default-branch> ``` A nonzero exit is reported with its output and not retried. The tag and release already exist, so a failed deploy is a release that has not reached the site yet, not one to undo. Whether to fix and redeploy the same tag or ship a patch is the user's call. ## Phase 10: Verify Confirm the release is live and report. In `release_mode=tag`: ```bash git ls-remote --tags origin <tag> ``` Otherwise: ```bash gh release view <tag> --json tagName,name,body,assets ``` ## Output ```text Release: <version> ==================== Profile: <profile> Prep PR: #<num> (merged <sha>) (or: <prep_branch> merged <sha>, off GitHub) Files bumped: <primary + derived> Gate: exit 0 on <sha> CHANGELOG: ## <version> added Walkthrough: regenerated Tag: <tag> → <sha> Release: <url> created by <release_workflow> | <url> created by gh | tag only Assets: <release_assets, the workflow's files: list, or "none"> Release notes: from CHANGELOG.md Deploy: <deploy_cmd> exit 0 on <tag> | not deployed ``` ## Do not use when - Pre-tag validation hasn't run — use `release-gate` first - The repo has no version source — `release-plan.sh` refuses, and the fix is a `.release-gate` file, not a guessed version