Skip to content

Stage releases on npm from a workflow that cannot publish - #18

Merged
nedtwigg merged 1 commit into
mainfrom
release-workflow
Sep 25, 2026
Merged

nedtwigg merged 1 commit into
mainfrom
release-workflow

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Adds .github/workflows/release.yml, which stages the packages on npm instead of publishing them, plus a Releasing section in PACKAGES.md.

How it works

  • plan job (on push to main; permissions: contents: read, checks: read): compares each package's version with npm and fails if the three versions differ. If any is missing, it waits for security-audit to pass on that commit. Any successful run counts, because a superseded run may be cancelled.
  • stage job (runs only if plan found something to stage): the only job with id-token: write, and the only one in the publish environment (main only, admin bypass off). It packs with pnpm packages:pack, so the staged archives are the ones check.yml verifies. It carries dist/provenance.json naming the audited commit. It stages core first, records each result in the job summary, and exits non-zero if any package failed.
  • A dispatch from a branch other than main does nothing.

Staged packages are not public. A maintainer approves each one with 2FA (npm stage approve, or the Staged Packages tab on npmjs.com). Each package's trusted publisher on npm must name this repo, release.yml and the publish environment, and must leave "can also publish directly with npm publish" unchecked. That way a stolen workflow can stage a version but cannot make it public.

Tested

  • actionlint (with shellcheck) is clean.
  • I ran each script block against the live registry and GitHub API:
    • all three versions on npm: nothing to stage
    • a new version: stages pgstencil auth stripe, in that order
    • mismatched versions: fails
    • audit gate: passes on e79cc4d, fails on a2bc185 (whose audit failed), passes when a cancelled run sits beside a successful one, and keeps waiting when no run exists yet
    • staging loop with a stubbed npm: continues past a failure, records each outcome, exits non-zero
  • The real npm stage publish <tarball> --dry-run accepts the archive path and refuses to overwrite the already-published 0.2.0.

Not tested yet

The OIDC path can only run in Actions, so the first real release is the test. Every failure I can foresee (npm too old, OIDC rejected, stage refused) stops before anything is staged. Three things to check on that first release:

  • the runner's npm version; the workflow installs a pinned 11.19.1 when it is older than 11.15
  • whether npm's provenance attestation survives approval (npm view <pkg>@<version> dist.attestations)
  • that staging works with a tarball under trusted publishing

Merging this changes nothing: all three packages are already on npm at 0.2.0, so the workflow finds nothing to stage.

🤖 Generated with Claude Code

release.yml finds a version npm does not have, waits for security-audit
to pass on that commit, and runs npm stage publish from the publish
environment, which admits only main. A staged package is not public: a
maintainer approves each one with 2FA. Its npm trusted publisher must
leave direct publishing unchecked, so a stolen workflow can stage a
version but never make it public. PACKAGES.md documents the release and
approval steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit effbb6e into main Sep 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant