Releases are fully automated: merge to main, and
.github/workflows/release.yml versions, tags, publishes and
writes the GitHub Releases. npm auth is OIDC trusted publishing, so there is no npm token to
rotate. Git auth for the one push to main is either a repo deploy key (RELEASE_DEPLOY_KEY) or an
org-owned GitHub App (RELEASE_APP_ID + RELEASE_APP_PRIVATE_KEY) — the built-in GITHUB_TOKEN
cannot push to a PR-protected branch and cannot be granted a ruleset bypass, since the app it
authenticates as is owned by github rather than by this org. See
§2 below.
version.mjs reproduces what lerna version used to do, minus lerna:
- Each package is versioned independently and tagged
@cornerstonejs/codec-foo@1.2.3. - A package's bump comes from the conventional commits touching
packages/<dir>/since its own last tag: a breaking change (feat!:or aBREAKING CHANGE:body) is a major, anyfeat:is a minor, anything else is a patch. No commits, no release. - Commits that touched only
*.md,*.yml,*.spec.jsor*.test.jsdo not count — theignoreChangeslist carried over from lerna.json. A docs pass releases nothing. - Packages that depend on something being released get their caret range rewritten and a patch bump
of their own, so
dicom-codecalways ships ranges that resolve to the codecs published alongside it. - Each bump prepends a
CHANGELOG.mdentry in the same format as the existing history.
Preview what the next release would do, at any time, from a clean checkout with tags:
pnpm release:plan # node tools/release/version.mjs --dry-run
pnpm release:preflight # what would publish, and whether npm will accept it
pnpm release:order # just the dependency order
node tools/release/version.mjs --dry-run --json # machine-readableEvery release entry point is a root package.json script, so none of them depend on a shell:
| Script | Does |
|---|---|
release:plan |
version bumps that the next release would make, mutating nothing |
release:order |
publishable packages in dependency order |
release:preflight |
the order, plus each package's registry state; publishes nothing |
release:publish |
the above, then publishes what is missing (used by the workflow) |
release:trust |
one-time trusted-publisher registration (run by a human) |
--dry-run writes nothing. Without it the script rewrites manifests and changelogs and emits
release-plan.json (gitignored); the workflow is what commits, tags and pushes.
Four jobs, in order. The split is a permission boundary, not tidiness: GitHub scopes
permissions: to a whole job, never to a step, so every right a job asks for is held by all the
third-party code that runs in it — the actions/* it calls and any dependency install scripts.
| Job | Permissions | Installs deps? |
|---|---|---|
build |
contents: read |
yes |
release |
contents: write |
yes |
publish |
id-token: write (+ contents: read) |
no |
github-releases |
contents: write |
no |
pnpm install therefore never runs in a job that can publish to npm, and the job holding the OIDC
token runs nothing but npm, the pinned actions and the scripts in this directory (node builtins
only — npm run needs no node_modules, since npm ships with node).
-
build— every package'sdist, in the emscripten container (matrix job). Read-only, but its artifacts are what reaches npm, so its actions are SHA-pinned like the rest. -
release— runs the vitest workspace against those exact dists (a failure here stops the release before anything is committed, tagged or published), thenversion.mjs, then regeneratespnpm-lock.yaml(pnpm records each importer's specifier, so rewriting dicom-codec's ranges strands the lockfile and the next--frozen-lockfileinstall fails), commitschore(release): publishand one annotated tag per released package, and pushes tomainwith a token minted per-run from the release App. The token is passed togit pushin the remote URL rather than persisted into.git/configbyactions/checkout, so it is not sitting on disk whilepnpm installruns. The job outputs the pushed commit SHA.The push is
--atomic: without it a declined branch update still publishes the tags, which is how run 32733067241 left eight version tags on a commit that never reachedmainand wedged every later release atgit tag -awith "tag already exists". -
publish— checks out that SHA, replays the dists, and runsnpm run release:publish, which publishes each package withnpm publish --ignore-scriptsin the dependency orderpublish-order.mjscomputes — dicom-codec goes out after the six siblings whose ranges it carries. Before publishing anything it resolves every package's registry state, so a release that cannot fully succeed publishes nothing (see Adding a new package).--ignore-scriptsis deliberate:prepublishOnlyre-runsbash build.sh, and this job has no emscripten toolchain — the dist being published is the artifact built in step 1 from the same commit. npm's version comes from the exactly-pinnednode-version(v24.20.0 → npm 11.19.0, past the 11.5.1 OIDC floor), so there is nonpm install --global npm@latestre-downloading an unpinned publisher every release. -
github-releases— a GitHub Release per tag, from the package listpublishuploaded. It is a separate job sogh release create'scontents: writenever coexists with the OIDC publish token, and it happens here rather than in a tag-triggered workflow because GitHub suppresses workflow runs for pushes made withGITHUB_TOKEN.
publish-order.mjs also refuses to emit a package that claims to ship dist/ but has an empty one,
which is the only thing standing between a dropped build artifact and an empty package on npm for
libjpeg-turbo-12bit (it has no vitest config, so the test gate never touches it).
release:plan and release:preflight run on every PR in pr-checks.yml, so these scripts are not
first executed mid-release. Preflight is also where a newly added package gets flagged — as a
warning on the PR, and as a hard failure in the release.
Every step is idempotent. If a run dies partway through publishing, re-run the workflow from the
Actions tab (workflow_dispatch) and it finishes the job rather than double-publishing: version.mjs
correctly finds nothing new to version (the commit and tags already landed) and the release job
falls back to reporting the triggering commit as the one to publish from, while publish and
github-releases work from "every package whose current version is not yet on npm / has no release
yet" rather than from that run's plan.
Both scripts are run by a human, once, and need credentials no CI job has.
npm login # account with publish rights on @cornerstonejs, 2FA enabled
pnpm release:trustThis registers cornerstonejs/codecs + release.yml as the trusted publisher for every package in
the workspace. The first call prompts for a 2FA one-time password. Re-running is safe: a package
that already has a config is reported and skipped.
npm trust needs npm >= 11.15.0. The way to get it is Node 24.20.0, which bundles npm 11.19.0 —
the same version release.yml pins. Do not reach for npm install --global npm@latest: on an older
Node it refuses outright, because npm 12 requires ^22.22.2 || ^24.15.0 || >=26.0.0.
npm trust also needs a web-login session, not a token. A granular or classic token in
~/.npmrc publishes fine but fails here with 401 ... Bearer token authorization is required, so
npm login is not optional even on a machine that can already publish.
The workflow's filename is part of the trust relationship. Renaming release.yml breaks every
publish until the script is re-run against the new name.
Trusted publishing also forces provenance generation, which requires each package's
repository.url to point at this repo — that is why every manifest carries a
repository block with a directory. A package whose repository.url drifts will fail to publish.
After the first green release, harden on npmjs.com: set each package's Publishing access to
"Require two-factor authentication and disallow tokens", and delete the old NPM_TOKEN from the
CircleCI project (CircleCI no longer runs anything for this repo — the project should be disabled).
A new package cannot be released by CI until a human has published it once. npm's OIDC trusted
publishing is configured per package, on the registry, so there is nothing to configure until the
package exists — and npm trust cannot create it (npm/cli#8544).
CI has no other npm credential by design, so its first npm publish fails with ENEEDAUTH.
This is not hypothetical. @cornerstonejs/codec-libjxl was merged in
#88 and every release for the next three days
failed on it, each one leaving main tagged for versions that were not on npm. Because the old
publish step was a bash loop under set -e, it died where it stood — and libjxl sits fifth in
dependency order, so little-endian, openjpeg, openjph and dicom-codec were never even
attempted. Four packages that would have published fine sat stranded behind one that could not.
So, after merging a new codec:
cd packages/<new-package>
npm publish --ignore-scripts # --ignore-scripts: prepublishOnly wants the emscripten toolchain
cd -
pnpm release:trust # registers release.yml for it; skips the restThen re-run the Release workflow. That one bootstrap version ships without a provenance
attestation — there was no trusted publisher to key it to — and every version after it has one.
Check with npm view <name>@<version> dist.attestations.
Two guards now make this loud instead of silent:
release:preflightruns on every PR and warns that the package is not on npm yet. It warns rather than fails, because on the PR that adds the codec that is simply true.release:publishresolves every package's registry state before publishing anything, and aborts the release with these instructions if one needs bootstrapping. Nothing is published, so no release half-lands.
Fail-fast is deliberate here, rather than skipping the bad package and continuing: publishing
dicom-codec while a sibling whose range it carries has just failed is precisely the window
publish-order.mjs exists to close.
Two routes. The deploy-key route needs only repo admin; the App route is better hygiene but
requires an organization owner. release.yml accepts either and prefers the App when both exist.
| Deploy key | GitHub App | |
|---|---|---|
| Who can set it up | repo admin | org owner |
| Scope | write to the whole repo | Contents: write |
| Lifetime | no expiry | token expires hourly |
| Bypass granularity | every write-enabled deploy key on the repo | that one App |
| Secrets | RELEASE_DEPLOY_KEY |
RELEASE_APP_ID + RELEASE_APP_PRIVATE_KEY |
Neither is a personal credential, which is the thing to preserve — the point of moving off CircleCI's arrangement was that releases must not depend on one person's key.
The release job needs to push the version commit to main, which requires a pull request. The
built-in GITHUB_TOKEN cannot be exempted from that: the "GitHub Actions" app it authenticates as
(id 15368) is owned by github, and a ruleset only accepts bypass actors belonging to the repo or
its owning org, so GitHub rejects it with
422 Actor GitHub Actions integration must be part of the ruleset source or owner organization
A deploy key or an org-owned App can both be listed as bypass actors. A deploy key belongs to the repository, so it satisfies "part of the ruleset source" with no ownership question — which is why it works without org access.
-
Create the key and store both halves. Full walkthrough is
STEP 1-DEPLOY-KEYin setup-branch-ruleset.sh's header. Summary:ssh-keygen -t ed25519 -N '' -C 'codecs release' -f ./codecs-release-key gh repo deploy-key add ./codecs-release-key.pub \ --repo cornerstonejs/codecs --title 'codecs release' --allow-write gh secret set RELEASE_DEPLOY_KEY --repo cornerstonejs/codecs < ./codecs-release-key rm ./codecs-release-key ./codecs-release-key.pub
-
Migrate the branch protection:
gh auth login # as a repo admin bash tools/release/setup-branch-ruleset.sh
Warning
The DeployKey bypass actor takes actor_id: null — it is a category, not a specific key.
Every write-enabled deploy key on the repo, present and future, can then push to main without
review. The script lists them and makes you acknowledge the list by name before it creates
anything. Audit before enabling, and delete any left over from retired CI — a key nobody uses stops
being merely unused and becomes one that bypasses branch protection:
gh repo deploy-key list --repo cornerstonejs/codecs
gh repo deploy-key delete <id> --repo cornerstonejs/codecsThe release key is the only write-enabled key that should appear. Anything else is a finding.
-
Create and install the App — walkthrough is
STEP 1-APPin the script's header. Summary: createcornerstonejs-releaseunder the org with Contents: read and write and nothing else, no webhook, generate a private key, install it oncodecsonly, thengh variable set RELEASE_APP_ID --repo cornerstonejs/codecs --body '<App ID>' gh secret set RELEASE_APP_PRIVATE_KEY --repo cornerstonejs/codecs < /path/to/key.pem rm /path/to/key.pem
-
Migrate the branch protection:
gh auth login # as the org owner BYPASS=app RELEASE_APP_SLUG=cornerstonejs-release bash tools/release/setup-branch-ruleset.sh
The script replaces main's classic branch protection with an equivalent ruleset listing the chosen bypass actor. Review requirements for humans are unchanged: 1 approving review, code-owner review, stale reviews dismissed on push, last-push approval, no force pushes, no branch deletion. See the script's header for why the classic rule has to go rather than sit alongside the ruleset.
One behavioural note that applies to both routes: GitHub suppresses workflow runs only for pushes
made with GITHUB_TOKEN. An App-token push and a deploy-key push are both ordinary pushes, so the
release's own version commit does retrigger the workflows on main.
What stops that being a loop is the guard on each workflow's root job:
if: >-
github.event_name != 'push'
|| !(startsWith(github.event.head_commit.message, 'chore(release): publish')
&& github.event.head_commit.author.email == '41898282+github-actions[bot]@users.noreply.github.com')(The continuation lines sit at the same indent on purpose. A >- folded scalar keeps a real newline
before any more-indented line, which would embed one in the expression.)
Both halves are written by the release job, a few lines apart — the git config user.email and the
git commit -m. Change either and you must change it in all four files, or the version commit
stops being recognised and gets a full CI run plus a bench that seeds a duplicate CodSpeed baseline.
Two things this deliberately is not:
- Not
[skip ci]. GitHub scans the entire head-commit message for that keyword, body included, and a squash merge concatenates every commit message on the branch into the body — so a PR that merely mentions[skip ci]in prose disables every workflow for its merge commit, creating no run at all to notice. That is exactly what happened to #89, whose commits explained why the release commit carried the keyword. Anchoring on the subject cannot be tripped by prose. - Not
==on the message. GitHub strips the trailing newline, so equality would match today, but it stops matching the moment the release commit grows a body — a commit template, aprepare-commit-msghook, a second-m. That failure is silent. Equality also would not buy the precision it looks like it buys: a human can type the exact subject as easily as a prefix. The author clause is what makes the match precise, because only the release job can produce that identity.
-
Verify, then re-run the failed Release workflow:
gh api repos/cornerstonejs/codecs/rulesets gh api repos/cornerstonejs/codecs/branches/main/protection # expect 404
If RELEASE_APP_ID is unset the release still runs and fails at the push, but logs a warning naming
this section rather than only protected branch hook declined.