release-ship · git:20261026.65ec569 · 2026-10-26 · sha256 54b3f70dceefe0ad

release-ship git:20261026.65ec569B

Immutable. This exact content never changes and is served at /api/v1/blob/54b3f70dceefe0ad.

---
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_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 15 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 `release.yml`, 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.yml
```

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
~/.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.yml | <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