Releasing

Four steps, and the first one is on your machine.

npm run changeset:version

That needs a GitHub token, because @changesets/changelog-github looks up each change's commit, PR and author through the API — which is most of what makes a generated changelog worth reading. You do not need to do anything about it: scripts/changeset-version.mjs takes GITHUB_TOKEN if you have set one, and otherwise finds the token gh already holds.

This was found by running the steps rather than writing them down. Versioning used to happen inside CI, where GITHUB_TOKEN is always present; moving it to a laptop made the first step of the first release fail, on a machine where a perfectly good token was already sitting in gh. Read-only usage — read:user and repo:status — so nothing in that step can write to the repository.

npm run gate:release
git add -A && git commit -m "release: version packages"
git tag release-$(date +%Y-%m-%d-%H%M) && git push origin main --follow-tags

The time is in the tag because a date alone collides on the second release of a day — which happened on the very first day, when 0.1.1 shipped a few hours after 0.1.0. git tag refuses a duplicate, so the collision is loud rather than dangerous, but there is no reason to walk into it.

The tag is what publishes. Everything before it is reviewable and reversible.

Why the version bump is not in CI

The obvious design — and the one this repo started with — has changesets/action open a version PR, and merging it publishes. It never worked here, and the reason is worth recording because it will come up again:

The MassingCloud organisation has "Allow GitHub Actions to create and approve pull requests" turned off. That setting is hierarchical — enterprise, then organisation, then repository — and a lower level can never grant what a higher level withholds. So the repository checkbox is greyed out, the job's pull-requests: write grant is honoured, and the API call is refused anyway.

That policy is off by default for a good reason: a workflow that can open a PR is a privilege-escalation path, because a second workflow can approve it. Loosening an org-wide policy affecting every current and future repository, to gain a convenience PR in one, is the wrong trade for a repository that SHA-pins every action, runs gitleaks with an empty ignore file, and publishes with provenance.

So the release stopped needing the permission rather than asking for it. .github/workflows/release.yml now has strictly less authority than the version it replaces:

Before Now
pull-requests write none
contents write — version commit, tags, PR branch write — per-package tags only
id-token write write
Writes a version yes, in CI no

The review step the version PR provided is not lost — it moved. npm run changeset:version produces a diff you read before committing, which is more review than approving a bot's PR.

The gate this design needs

Moving changeset version onto a laptop introduces exactly one new failure mode: tagging without having run it. That fails silently, which is the worst way to fail — changeset publish would find every local version already on npm, publish nothing, exit 0, and the release would report success while shipping nothing. A partial version run is worse still: it ships a set of versions that were never tested together.

So scripts/check-release-ready.mjs runs before the publish, locally and in CI:

  1. No pending changesets. A leftover changeset file in .changeset means the versions in this tree do not include it.
  2. Something is actually being published. A release tag that publishes nothing is a mistake — forgotten versioning, or a duplicate tag — and must fail loudly rather than succeed emptily.
  3. Every version being published has a ## <version> changelog heading. npm versions are immutable, so an unexplained 0.3.1 is unexplained for ever. Matched at a heading, not anywhere in the file, because the version string also appears in dependency-bump bullets.
  4. No package is still at 0.0.0. The placeholder means it was never versioned.

Run npm run gate:release before tagging. Locally is where it is useful; in CI it is binding.

The release job restores no cache

actions/setup-node runs with package-manager-cache: false. The Actions cache is readable across workflows and writable by ones running on less trusted triggers, so a job that publishes to npm must not restore it — an attacker who can poison the cache could influence tarballs going out under this repository's provenance attestation, which turns the attestation from a guarantee into a misleading one.

zizmor raised this as high severity the moment the workflow became a publishing one, and it was right in a way worth recording: dropping cache: npm was not sufficient. setup-node's own action metadata says caching is enabled by default whenever package.json has a packageManager field, and this repo declares packageManager: npm@11.16.0. The cache was still being restored after the apparent fix, and the linter that looked like it was being pedantic about something already handled was reporting a real finding.

A release runs a handful of times a year, so npm ci from scratch costs nothing that matters.

Nothing publishes without a token

NPM_TOKEN absent is a notice, not a failure. Publishing to a shared npm scope is irreversible — a version number can never be reused, even after npm unpublish — so it takes a deliberate act by someone with credentials, not a side effect of a merge.

workflow_dispatch is a dry run for the same reason: it runs every gate and skips the publish, so the checks can be exercised without burning a version. Publishing on a button press would make pushing a tag bypassable, and the tag is the deliberate act.

The per-package tags have to be pushed

changeset publish prints New tag: @massing/core@0.1.0 per package, and the obvious reading — that it has created a git tag you can push — has now failed twice:

  1. 0.1.0 had no push step at all. Twenty tags were created on the runner and died with it. The workflow comment asserted changesets pushed them, which is the wrong-but-plausible kind of note that stops anyone checking.
  2. 0.1.1 added git push origin --tags, which reported "Everything up-to-date". So at the moment the push ran, the four tags changesets had just announced were not in the local repository.

The second is unexplained, and a release record that depends on behaviour nobody can explain is not a record. So scripts/tag-published.mjs does the tagging from the one unambiguous source — what each package manifest says its version is, in the tree that was just published — and pushes the refs by name, so the push either moves them or fails saying why. It is idempotent, because re-running a partly-failed release is normal.

Run it by hand with npm run tag:published if a release ever lands without its tags.

Those tags are the only thing that answers "which commit produced this published version", which is the first question a bisect over a published regression asks. npm cannot tell you.

Re-running a failed release

Safe. changeset publish is a no-op for any version already on npm, so a partially-failed release can be re-run and it will publish only what did not make it. Delete and re-push the tag, or use workflow_dispatch to check the gates first.

Verifying a publish: the registry is eventually consistent

Do not conclude a publish failed because the registry 404s. Brand-new packages under a new scope can take minutes to appear, and npm view caches negative lookups on top of that — so a freshly-published package can read as absent from two independent angles at once.

That happened on the first release, and the trap is worth naming precisely: I checked @types/node as a control, it returned real data, and I took that as proof the instrument worked and therefore that the absence was real. The instrument was working. A control that proves your tool works does not prove an observed absence is real when the system is eventually consistent. The publish had already succeeded.

The reliable check is the workflow log — changeset publish prints success packages published successfully: followed by every package it published. That is authoritative at the moment it runs. Confirm against the registry afterwards with npm cache clean --force && npm view <pkg> --prefer-online, and give it a few minutes.

If you would rather have the version PR back

Two routes, both real: