Releasing¶
Three GitHub Actions workflows automate versioning end to end — no
manual npm version, no hand-editing the CHANGELOG's links, no manual
git tag. This is a GitHub-only release process (a tag + a GitHub
Release); nothing is published to npm.
The version counter¶
package.json's version is a live "PRs merged since the last
release" counter, not a value anyone chooses per PR. Every same-repo
pull request against main gets its PATCH component bumped by 1 —
automatically, by version-bump.yaml pushing a commit onto the PR's
own branch, so the bump shows up in the normal reviewable diff rather
than as a separate, unreviewed main-branch commit.
0.1.0 (last release) -> PR #42 merges -> 0.1.1 -> PR #43 merges -> 0.1.2 -> ...
Known limitation: GITHUB_TOKEN can only push to branches in this
repository, not to a fork — a PR opened from a fork won't get the
automatic bump. Bump it by hand in that case (edit package.json's
version directly in the PR).
Cutting a release¶
- Go to Actions → release-prepare → Run workflow (
workflow_dispatch, no inputs needed). - The workflow computes the version this release ships as: a MINOR
bump with PATCH reset to 0, relative to the current
major.minor — e.g. if PRs accumulated
package.jsonto0.1.7, the release ships as0.2.0. The accumulated patch count never ships literally; it's discarded once a release happens. - It moves
CHANGELOG.md's[Unreleased]section into a new, dated## [0.2.0] - YYYY-MM-DDsection, inserts a fresh empty[Unreleased]above it, and rewrites the comparison links at the bottom. - It opens a PR (
release/v0.2.0→main) containing exactly those two changes —package.json's version andCHANGELOG.md— with the new section's body as the PR description. - Review and merge that PR like any other. Merging it is what
actually publishes the release:
release-publish.yaml(triggered by that specific PR — branchrelease/v0.2.0— actually merging) tags the merge commitv0.2.0and creates a GitHub Release with notes taken directly from that CHANGELOG section.
No further manual step — once the PR is merged, the tag and the GitHub Release both already exist.
If release-prepare fails immediately¶
[Unreleased] was empty — there's nothing real to release yet. This is
a hard guard, checked before any branch or PR is created: it makes an
accidental content-free release impossible. Add real entries under
[Unreleased] (via a normal PR) first.
If you dispatch release-prepare twice¶
The second run detects the release/vX.Y.Z branch already exists and
exits cleanly without creating a duplicate branch or PR.
After a release ships¶
Immediately after merge, package.json is set to exactly the version
that was just released (0.2.0) — there's no separate "next version"
placeholder. The very next merged PR continues the same counter
(0.2.0 → 0.2.1), exactly as it did before the release.
The three workflows, for debugging a stuck release¶
| Workflow | Trigger | Permissions | Does |
|---|---|---|---|
version-bump.yaml |
pull_request (same-repo) |
contents: write |
Bumps PATCH on the PR branch |
release-prepare.yaml |
workflow_dispatch |
contents: write, pull-requests: write |
Computes the release version, rewrites the CHANGELOG, opens the release PR |
release-publish.yaml |
pull_request (closed), gated on merged == true and a release/v* head branch |
contents: write |
Tags + creates the GitHub Release |
release-publish.yaml only runs when a PR whose branch matches
release/v* is actually merged — it's gated on the PR event itself
(github.event.pull_request.merged == true), not inferred from repo
file state. That's a deliberate correction: an earlier version inferred
"a release just merged" purely from "the CHANGELOG's topmost dated
section isn't tagged yet," which meant any push to main that
happened to leave the repo in that state — not just a real
release-prepare PR merge — would auto-publish a release. That bit
once, for real: the very first push of this automation to main
auto-published v0.1.0 with no PR/review involved, because
CHANGELOG.md/package.json already described an untagged 0.1.0
from before the automation existed. The fix ties publishing to the PR
event itself; the CHANGELOG/package.json state is still cross-checked
against the branch name as defense in depth (fails loudly on
disagreement, never guesses).
Recommended: require branches to be up to date before merging¶
One residual gap: if two PRs are open at once, the second one's bump
target was computed before the first merged — if it merges without an
intervening push (no synchronize event to re-trigger the bump), the
patch counter under-counts by one for that PR. Enabling "Require
branches to be up to date before merging" branch protection on main
forces a rebase/merge (and therefore a fresh bump) before the merge
button is even clickable. This is a repo-settings change, not something
any of the three workflows enable automatically.
The scripts behind all of this (scripts/)¶
Plain Node ESM (.mjs), no build step, runnable locally against any
file with the same --path flags the workflows use:
bump-patch-version.mjs— the PATCH bump, with the exact-match idempotency check that keepsversion-bump.yamlsafe to re-run.compute-release-version.mjs— the MINOR-bump-reset-PATCH release version.update-changelog-release.mjs— the[Unreleased]→ dated-section move + link rewrite; this is where the empty-[Unreleased]guard lives.changelog-notes.mjs <version>— extracts one version's section body (the release PR's description, andgh release create's notes).detect-pending-release.mjs— cross-checksCHANGELOG.md's topmost dated section againstpackage.json's version;release-publish.yamlcompares its output against the merged branch's own name and fails loudly if they disagree.