Skip to content

feat(ci): stage releases on npm so a version goes live only after 2FA approval - #34

Open
Bugs5382 wants to merge 1 commit into
mainfrom
feat/20-staged-publish
Open

Bugs5382 wants to merge 1 commit into
mainfrom
feat/20-staged-publish

Conversation

@Bugs5382

Copy link
Copy Markdown
Contributor

What and why

Publishing the GitHub Release used to put the version straight onto npm, with no human step between the tag and a version anyone can install. The publish step also hid a failure: JS-DevTools/npm-publish treats a version already on the registry as success. The v1.0.0 re-run on 2026-09-07 (run 34143303465) logged @the-rabbit-hole/docs-theme@1.0.0 already published. and finished green, the same pattern eslint-config showed tonight.

The publish job now runs npm stage publish instead of the action. npm uploads the tarball with provenance to its stage queue, and the version stays uninstallable until a maintainer approves it with 2FA. The action is dropped because it wraps npm publish and has no staging option.

Fails loudly. Every way of not staging turns the run red:

  • A version the registry already has makes npm exit 1 (You cannot publish over the previously published versions). Nothing passes --force, which is the only thing that skips that check (npm lib/commands/publish.js).
  • A zero exit with no staged with id <uuid> line fails the step. The id is then read back with npm stage view.
  • An npm older than 11.15.0 fails with a message saying so, instead of depending on what lts/* ships.
  • With no trusted publisher configured, npm exits ENEEDAUTH.

No token. registry-url and NODE_AUTH_TOKEN are gone. With registry-url set, setup-node writes a token line into .npmrc and exports a placeholder, which npm presents instead of doing the OIDC exchange (the fault #19 worked around). Without it, the job authenticates through trusted publishing alone.

Why the text line, not --json. The --json output of npm stage publish is shaped differently in npm 11.19 (keyed by workspace) and 12.1 (keyed by package name). The + name@version (staged with id ...) line is identical in both.

npm features used, checked against npm's docs on 2026-09-30:

  • Staged publishing needs npm CLI 11.15.0 or later and Node 22.14.0 or later. Approval requires 2FA, via the CLI or npmjs.com.
  • npm stage: publish needs no 2FA and accepts --provenance, --access and --tag. view, list and download also need no 2FA. approve and reject do.
  • Trusted publishing: "npm stage publish is always allowed" for trusted publishers. Provenance is generated automatically from GitHub Actions. Settings, Publishing access, "Require two-factor authentication and disallow tokens" shuts off token publishing.
  • GitHub changelog, 2026-05-22: a trusted publishing configuration "can be limited to stage-only, which means npm publish from that workflow will be rejected".
  • The issue's note that a brand-new package cannot be staged is out of date (npm/documentation#2047). It no longer matters here, because 1.0.0 is on the registry.

Provenance. npm's source builds the sigstore bundle into the same payload that goes to the stage route (libnpmpublish buildMetadata, then POST /-/stage/package/<name>), and --provenance is passed explicitly. npm's docs don't say whether the approved version keeps that attestation. This can only be checked against the first approved version (v1.1.0) with npm view @the-rabbit-hole/docs-theme@1.1.0 dist.attestations. That check stays open on the issue.

Closes #20

Verification

  • Lint clean (npm run lint, actionlint on every workflow, shellcheck on the step body)
  • Tests pass (npm test, 23 passed; npm run typecheck)
  • Build succeeds (npm run build)
  • Documentation updated (README gains a Release section with the approval commands)

How this was verified

  • A stub-npm harness pulls the stage step's run: body out of the workflow with yq and runs it for six cases. Version already published, staged with no id, no auth, and npm 11.14.9 all exit non-zero. A good stage passes on npm 11.19.0 and 12.1.0, writing stage-id to GITHUB_OUTPUT, calling npm stage view <id>, and putting the approve and reject commands in the job summary. Before the change, the harness failed because the workflow had no stage step.
  • Real npm 11.19.1, --dry-run (which never uploads), against the artifact file set built locally: 1.1.0 prints + @the-rabbit-hole/docs-theme@1.1.0 (staged) and exits 0. With the version set to 1.0.0 it prints You cannot publish over the previously published versions: 1.0.0. and exits 1.
  • Nothing was published or staged.

Test does not run on this PR, because its paths-ignore covers .github/** and **.md. The first real run is the v1.1.0 release.

Before the v1.1.0 release, on npmjs.com for @the-rabbit-hole/docs-theme: add a trusted publisher for the-rabbit-hole-tech/docs-theme with workflow action-deploy-npm.yaml, set to stage-only. Then set Publishing access to disallow tokens, and remove the NPM_TOKEN repo secret, which this workflow no longer reads. v1.0.0 was published under the bugs5382 user, not the OIDC identity, so there may be no trusted publisher yet. Without one, this job fails with ENEEDAUTH.

… approval

Publishing the GitHub Release used to put the version straight onto npm.
Between the tag and a version anyone can install there was no human step
and no chance to look at the tarball. The publish step also had a quieter
fault: JS-DevTools/npm-publish treats a version that is already on the
registry as success. The v1.0.0 re-run on 2026-09-07 logged "already
published" and finished green, so a release could appear to ship when
nothing had been sent.

The publish job now runs `npm stage publish` instead. npm uploads the
tarball, with provenance, to its stage queue, and the version stays
uninstallable until a maintainer approves it with 2FA (`npm stage approve
<id>` or the Staged Packages tab on npmjs.com). The action is dropped
because it wraps `npm publish` and has no staging option.

Every way of not staging now fails the job:

- npm refuses a version the registry already has, and nothing here passes
  --force, which would skip that check;
- a run that exits zero without printing a stage id is failed by the step
  itself, and the id is then read back with `npm stage view`;
- an npm older than 11.15.0, which has no `npm stage`, fails with a message
  saying so, rather than depending on what `lts/*` happens to ship.

The job no longer sets registry-url or NODE_AUTH_TOKEN. With registry-url,
setup-node writes a token line into .npmrc and exports a placeholder that
npm presents instead of doing the OIDC exchange. Without it, the job
authenticates through trusted publishing alone and holds no npm token.

The stage id is parsed from npm's "+ name@version (staged with id ...)"
line, not from --json, because the JSON shape differs between npm 11.19
(keyed by workspace) and 12.1 (keyed by package name). The text line is
the same in both.

The README gains a Release section covering the approval commands.

Closes #20
@Bugs5382 Bugs5382 added this to the v1.1.0 milestone Sep 30, 2026
@Bugs5382 Bugs5382 added the enhancement New feature or request label Sep 30, 2026
@Bugs5382 Bugs5382 self-assigned this Sep 30, 2026
@Bugs5382
Bugs5382 marked this pull request as ready for review September 30, 2026 02:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ci): publish through staged publishing so a release is approved with 2FA

1 participant