A release is two separate things, in this order:
- A PR onto
mainthat bumpspackage.jsonand promotes theCHANGELOG. Reversible. - An annotated tag pushed to that merged commit. This is the irreversible, outward-facing step —
release.ymlbuilds and publishes public binaries, and force-updates the Homebrew tap atshaharia-lab/homebrew-tap.
Never do step 2 without explicit human confirmation. See Gate before tagging.
Hard constraints
These are the ways a release goes wrong. Check them, don't rediscover them.
release.ymlfails when the tag disagrees withpackage.json(#82). The bump must be merged intomainbefore the tag is pushed. Fixing it after the fact means bumpingmainand then deleting and re-pushing the tag.mainrequires signed commits, with no bypass actors — an unsigned commit makes the PR unmergeable for anyone, including admins. Verify the release commit carries a signature.- The repository constitution in
CLAUDE.mdapplies to the release PR too: it needs a linked GitHub issue carryingready-for-prbefore the PR is opened.pr-linked-issue.ymlenforces the link as a required check. mainrequires one approving review, and an agent cannot approve its own PR. Sogh pr mergereportsBLOCKED/REVIEW_REQUIREDeven when every check is green. Unblocking it is the maintainer's decision, taken one of two ways — a review, or an explicit instruction to merge with--admin. Never pick--adminon your own initiative; surface the block and let the maintainer choose.dismiss_stale_reviews_on_pushis on, so any push after an approval discards it — get the branch final before asking for review.- Pre-commit hooks block direct commits to
main. Always work on a branch. - A merged PR does not guarantee a
CHANGELOGentry. Reconcile commits against[Unreleased]yourself — the mrkdwn fix in #96 landed with no entry and had to be backfilled at v0.9.0. bunmay not be onPATHin a non-interactive shell. Ifbunis not found, useexport PATH="$HOME/.bun/bin:$PATH".
Procedure
1. Survey what is being released
git describe --tags --abbrev=0 # last released tag
git log <last-tag>..HEAD --pretty=format:'%h %ad %s' --date=short
git diff <last-tag>..HEAD --stat
Separate user-facing commits (feat, fix, perf, anything changing CLI behaviour) from non-shipping ones (ci, chore, docs, dependency bumps). Only the first group justifies a release or drives the version.
Read the bodies of the user-facing commits — they carry the WHAT/WHY the changelog needs:
git show <sha> --pretty=format:'%B' --stat
2. Recommend a version
Follow SemVer against the pre-1.0 convention this repo already uses:
- New commands, new flags, newly accepted input formats → minor (
0.8.0→0.9.0) - Bug fixes only → patch (
0.8.0→0.8.1)
State the recommendation and the reasoning before acting on it. A behaviour change that only alters already-broken output is still a fix, not a breaking change.
3. Reconcile the CHANGELOG
Compare the user-facing commits from step 1 against the [Unreleased] section. Anything shipped but undocumented gets an entry written now, in the voice of the surrounding file: what changed, and why the user cares. Note deliberate behaviour changes explicitly.
4. Open the release issue
Required before the PR. Mirror the WHAT / WHY / HOW structure the repo uses (see #101, #111):
- WHAT — cut release vX.Y.Z, bump
package.jsonfrom A to B. - WHY — the user-facing changes on
mainthat are not yet in a published build, each with its issue/PR number; the SemVer reasoning; therelease.ymltag/version constraint (#82). - HOW — bump, promote the changelog, merge, push the annotated tag.
- Optionally a Not included section naming open
ready-for-prissues deliberately deferred, so the omission reads as a decision.
gh issue create --title "Release vX.Y.Z" --body-file <file> --label ready-for-pr
5. Prepare the branch
git checkout -b chore/release-vX.Y.Z
package.json: bumpversion.CHANGELOG.md: insert a## [X.Y.Z] - YYYY-MM-DDheading below## [Unreleased], leaving[Unreleased]in place and empty, so the existing entries fall under the new version.
Verify before committing:
bun run type-check
bun test
Both must be clean. Commit with Closes #<issue> in the body, then confirm the signature is present:
git cat-file -p HEAD | grep -q gpgsig && echo signed
git log --show-signature may report the signature as unverified purely because gpg.ssh.allowedSignersFile is unset locally; that is a local verification gap, not an unsigned commit. GitHub is the authority — check-signatures on the PR reports the truth.
6. Open the PR
gh pr create --base main --head chore/release-vX.Y.Z --title "chore: release vX.Y.Z" --body-file <file>
The body should carry: summary and the release.yml constraint, Included since listing the user-facing changes, Changes in this PR, the check results, the post-merge release steps, and Closes #<issue>.
Then wait for checks — gh pr checks <pr>. Check linked open issue, check-signatures, test, unit-tests and integration-tests must pass.
7. Gate before tagging
Stop here and ask the human to confirm the merge and tag. Everything up to this point is reversible; the tag is not — it publishes public binaries and rewrites the Homebrew formula.
The maintainer has to unblock the PR regardless — the branch ruleset requires a review the PR's own author cannot supply — so this gate costs nothing extra.
Once the maintainer has approved, or has explicitly asked for --admin:
gh pr merge <pr> --squash # add --admin only when explicitly instructed
git checkout main && git pull
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
The tag must point at the merged bump commit. Tagging a commit where package.json still holds the old version fails verify-version immediately.
8. Verify the release landed
gh run list --workflow=release.yml --limit 3
gh release view vX.Y.Z
Confirm all five binaries plus checksums.txt are attached (slackcli-linux, slackcli-linux-arm64, slackcli-macos, slackcli-macos-arm64, slackcli-windows.exe), and that update-homebrew succeeded. Then check the last job, announce-discord: it posts the release to the Shaharia Lab Discord and is non-blocking, so the run is green even when it failed — read the job's own result (gh run view <run-id> --json jobs --jq '.jobs[] | "\(.name): \(.conclusion) (\(.databaseId))"'). If it failed, re-run that job alone (gh run rerun --job <databaseId>, the id that query prints) or tell the maintainer the announcement is missing; it is skipped by design for a pre-release tag. Report the release URL.