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:
- No pending changesets. A leftover changeset file in
.changesetmeans the versions in this tree do not include it. - 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.
- Every version being published has a
## <version>changelog heading. npm versions are immutable, so an unexplained0.3.1is unexplained for ever. Matched at a heading, not anywhere in the file, because the version string also appears in dependency-bump bullets. - 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:
- 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.
- 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:
- A fine-grained PAT. The org restriction only binds
GITHUB_TOKEN. A PAT scoped to this one repository withcontents: writeandpull requests: write, passed tochangesets/actionasGITHUB_TOKEN, bypasses it — this is changesets' own documented workaround. Narrower than the org toggle, at the cost of a credential to rotate. - Enable it at the organisation level, at
https://github.com/organizations/MassingCloud/settings/actions. Simplest, and the broadest: it applies to every repository in the org, for ever.