Release procedure
Pick the bump per semantic versioning: patch for bug-fixes only, minor for any new user-visible feature, major for breaking changes that require user or operator action. Use today's date, read from the shell (date or Get-Date) rather than from memory: a wrong date in a release heading is public and permanent once the tag is pushed.
0. Read the repo profile first
Read Lock: and Docs language: from the ## Repo profile section of the repo's root CLAUDE.md, exactly as committing step 0 describes, including its fallback when the section is missing (infer, confirm once, write the four-line marker yourself). The lock decides whether you cut the release on a feature branch through a pull request or straight on main. The docs language decides the wording of the CHANGELOG bullets and of the Release notes.
1. Pre-flight: run the committing checklist
A release ships the docs, not just the code, so before bumping anything run the committing pass over everything since the last release (the range <last-tag>..HEAD, with <last-tag> from git describe --tags --abbrev=0; on a first release, with no tag yet, the pass covers the whole history): confirm the docs are current and that no change left an existing doc stale, update any CLAUDE.md the work invalidated or extended, and prune comments to the strictly useful. Then make sure [Unreleased] is complete: every user-visible change since the last tag has a bullet, there are no duplicates, and it reads cleanly for a human. A release is the worst moment to find the changelog is missing entries.
If the project has a typecheck, build, or validation step (a lint script or a repo-invariants check counts), run it now and confirm it passes before you bump anything. This matters most when publishing the Release triggers a build (see step 5): a broken build is cheap to find before the tag and painful to find after. When the real build only runs in Docker or CI and is not reachable locally, run the cheapest equivalent checks instead (syntax check the changed sources, parse any JSON or config they touch), and tell the owner the actual build will run in CI when the tag is pushed, so they know it is still unverified at tag time.
2. Bump the version
Find where the version lives and bump it:
Node: bump
versioninpackage.json, and theversionin itspackage-lock.jsonin exactly two places, the top object andpackages."". Edit only thoseversionkeys: a dependency can carry the same number by coincidence, so never touch anode_modules/*or other dependency entry, and never blind-replace the old version string across the lockfile. Two layouts go past the single package:- Workspace monorepo: bump every workspace
package.jsonand theversionof each matchingpackages.<workspace>node in the lockfile. Miss those and the workspace nodes stay behind, which makesnpm cireject the lockfile as out of sync and breaks any Docker build that runs it. - Several independent packages side by side, each with its own
package-lock.jsonand no workspaces (say a root package and aserver/package): apply the same two-occurrence rule to each lockfile separately.
No dependency changes belong in a release commit.
- Workspace monorepo: bump every workspace
Other ecosystems:
pyproject.toml/Cargo.toml/*.csproj/composer.jsonand their lockfiles, as applicable.Claude Code plugin: bump
versionin.claude-plugin/plugin.json, then validate the manifest (claude plugin validate ., ornpx -y @anthropic-ai/claude-code plugin validate .when the CLI is not on PATH).Content-only repos (a skill, docs): there is no manifest. The tag is the version; there is nothing to bump.
3. Promote the CHANGELOG
In CHANGELOG.md, rename [Unreleased] to [X.Y.Z] - YYYY-MM-DD and add a fresh empty [Unreleased] section above it. Re-read every bullet in the section being promoted and apply the tightening rule from the committing skill (short sentences, no implementation detail, grouped under the standard English change-type names). Never rewrite already-released sections.
For example, a ## [Unreleased] carrying this session's bullets becomes ## [1.4.0] - 2026-05-27 (today's date), with a new empty ## [Unreleased] inserted above it.
4. Commit, then follow the lock
Make one commit: chore(release): cut version X.Y.Z (conventional, Co-Authored-By trailer kept). Before tagging, confirm the tag number, the manifest version(s) just bumped, and the new CHANGELOG heading all carry the same X.Y.Z; a mismatch here is the classic release-day bug and is painful to fix once the tag is public. Also confirm the working tree is clean (git status) and HEAD is the commit you mean to tag, since a public tag is cheap to create and costly to move.
In both lock modes, the Release notes are the CHANGELOG section just promoted, minus its ## [X.Y.Z] - YYYY-MM-DD line: the change-type headings and their bullets, verbatim. The Release title already carries the version, so repeating the heading only clutters the page.
Lock: locked:
- Commit on the feature branch and push. If no pull request exists yet, give the owner the title and body to paste.
- The owner merges the pull request. This is the human gate of a locked repo; do not bypass it. Tell the owner explicitly and wait for confirmation before continuing. (The Release itself is published in the last sub-step below, once the tag exists.)
- After merge is confirmed:
git checkout main && git pull, thengit tag -a vX.Y.Z -m "vX.Y.Z"andgit push origin vX.Y.Z. The tag message is just the version, in both lock modes: the story lives in the CHANGELOG and the Release notes, not in the tag. Delete the feature branch, local first then remote (git branch -d <branch>thengit push origin --delete <branch>). On a squash-merge repo the branch commits were flattened into one new commit onmain, so they are not ancestors ofmainandgit branch -dcan refuse with "not fully merged"; the work is safely merged, so usegit branch -Dto force it. - Walk the owner through the GitHub Releases page: select the tag just pushed and paste the Release notes as the body. If
ghis installed and authenticated (gh auth status), you may offer instead to rungh release create vX.Y.Z --notes-file <file>once the owner approves. Always pass the body through--notes-file, never inline--notes "...": a multi-line body inline invites shell quoting accidents (a stray@, an unescaped quote). Without--title, gh uses the tag name as the Release title, which is fine; add--title "..."only if you want a different label.
Lock: free:
- Commit on
main, tagvX.Y.Z, and push bothmainand the tag. - Publish the GitHub Release: walk the owner through the Releases page with the Release notes, or, if
ghis authenticated and the owner approves,gh release create vX.Y.Z --notes-file <file>.
If the tag pushed but the Release did not publish (either lock mode), the tag is fine: do not retag, just re-run the Release step (gh release create vX.Y.Z --notes-file <file> again, or the Releases page). Creating a second tag for the same version is the thing to avoid.
5. Confirm the release automation
If there is no workflow under .github/workflows/, there is no automation to confirm; skip this step. Workflows that only validate (a push or pull_request trigger, no tags: filter and no release: event) are CI, not release automation: the one thing to confirm is that their run on the release commit passed.
Many of these repos publish on a tag or Release event (a Docker image, an npm package, a GitHub Pages build). Before relying on it, read the on: trigger of the publishing workflow under .github/workflows/, because it decides when the automation fires and what to watch:
on: push: tags:fires when you push the tag (step 4), before the Release exists. The Release you publish next is then only a notes wrapper, and the build is likely already running or finished by the time you publish it.on: release: types: [published]fires only when you publish the Release; nothing runs at the tag push.
Either way, confirm the run succeeded instead of only telling the owner it was triggered: gh run list --workflow=<file> (or gh run watch) after the tag push. On a tag trigger especially, a failed build means the image or package never publishes even though the tag and Release already exist, so without this check the failure goes unnoticed.
When the repo is a Claude Code plugin, the release does not propagate on its own: remind the owner that each machine with the plugin installed picks it up through /plugin update <name> or Claude Code's automatic update.