Skip to content

Latest commit

 

History

History
315 lines (248 loc) · 17.1 KB

File metadata and controls

315 lines (248 loc) · 17.1 KB

Releasing

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.

How a release is decided

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 a BREAKING CHANGE: body) is a major, any feat: is a minor, anything else is a patch. No commits, no release.
  • Commits that touched only *.md, *.yml, *.spec.js or *.test.js do not count — the ignoreChanges list 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-codec always ships ranges that resolve to the codecs published alongside it.
  • Each bump prepends a CHANGELOG.md entry 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-readable

Every 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.

What the workflow does

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).

  1. build — every package's dist, in the emscripten container (matrix job). Read-only, but its artifacts are what reaches npm, so its actions are SHA-pinned like the rest.

  2. release — runs the vitest workspace against those exact dists (a failure here stops the release before anything is committed, tagged or published), then version.mjs, then regenerates pnpm-lock.yaml (pnpm records each importer's specifier, so rewriting dicom-codec's ranges strands the lockfile and the next --frozen-lockfile install fails), commits chore(release): publish and one annotated tag per released package, and pushes to main with a token minted per-run from the release App. The token is passed to git push in the remote URL rather than persisted into .git/config by actions/checkout, so it is not sitting on disk while pnpm install runs. 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 reached main and wedged every later release at git tag -a with "tag already exists".

  3. publish — checks out that SHA, replays the dists, and runs npm run release:publish, which publishes each package with npm publish --ignore-scripts in the dependency order publish-order.mjs computes — 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-scripts is deliberate: prepublishOnly re-runs bash 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-pinned node-version (v24.20.0 → npm 11.19.0, past the 11.5.1 OIDC floor), so there is no npm install --global npm@latest re-downloading an unpinned publisher every release.

  4. github-releases — a GitHub Release per tag, from the package list publish uploaded. It is a separate job so gh release create's contents: write never 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 with GITHUB_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.

One-time setup

Both scripts are run by a human, once, and need credentials no CI job has.

1. npm trusted publishing

npm login                  # account with publish rights on @cornerstonejs, 2FA enabled
pnpm release:trust

This 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).

Adding a new package

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 rest

Then 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:preflight runs 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:publish resolves 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.

2. A push credential for main + the branch ruleset

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.

Route A — deploy key (repo admin)

  1. Create the key and store both halves. Full walkthrough is STEP 1-DEPLOY-KEY in 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
  2. 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/codecs

The release key is the only write-enabled key that should appear. Anything else is a finding.

Route B — GitHub App (org owner)

  1. Create and install the App — walkthrough is STEP 1-APP in the script's header. Summary: create cornerstonejs-release under the org with Contents: read and write and nothing else, no webhook, generate a private key, install it on codecs only, then

    gh 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
  2. 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

Either route

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, a prepare-commit-msg hook, 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.
  1. 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.