Repository navigation
ci: stage npm publishes instead of publishing directly - #76
Conversation
The trusted publisher on zshy is stage-only, so CI can put a version in npm's staged queue and nothing else. A maintainer approves each staged version with 2FA before it becomes installable. versace.ts runs `npm stage publish`, skips a version already live on the registry, and treats npm's refusal of an already-staged version as success so a re-run is idempotent. publish-latest installs an npm that can stage, drops NODE_AUTH_TOKEN (the secret is deleted; OIDC is the only path to npm now), and waits for the registry to serve the version before building the changelog and creating the GitHub release. Release creation is skipped when the tag already carries one, so a re-run after a late approval completes. publish-canary no longer reaches npm. It runs on every push to main, so there is no human to approve a staged canary; its npm step has been commented out since v0.2.5 anyway.
There was a problem hiding this comment.
Caution
publish-latest.yml still passes registry-url to setup-node while this PR deletes the NODE_AUTH_TOKEN it existed to inject. That combination writes a placeholder _authToken into the runner's .npmrc, which makes npm skip the OIDC exchange entirely — so npm stage publish cannot authenticate at all. Details inline.
Reviewed changes — the full diff across three files, plus the surrounding workflow tail (Configure changelog → Create release) that the new wait step now gates.
versace.tsstages instead of publishes —runNpmPublishbecomesrunNpmStagePublish, guarded by anisLiveOnNpmpre-check and anALREADY_STAGEDregex that treats npm's re-stage refusal as success.publish-latest.ymlgains a human gate — installsnpm@11, asserts>= 11.15, stages via OIDC, then busy-waits up to 350 minutes pollingnpm viewbefore the changelog/tag/release steps run.- Release creation is made re-runnable — a new
gh release viewprobe setsexists, andCreate releaseis skipped when the tag already has one. publish-canary.ymldrops npm entirely —id-token: writeandregistry-urlremoved, leaving checkout/install/test/build.
I verified the npm contract against primary sources since the PR is explicitly untested end to end. npm stage publish with --tag and --provenance is real and correctly spelled (npm-stage docs), stage-only trusted publishers exist (trusted-publishers docs), and the registry's real refusal is 409 … Cannot publish over previously staged version "x.y.z" (npm/cli#9889) — which the regex does match. The version-floor check at line 41 is arithmetically correct for <, =, and >.
⚠️ A six-hour wait can put the git tag on a commit npm never saw
Create release uses commitish: ${{ github.ref }} — refs/heads/main, a moving ref resolved by the API at call time, not github.sha. That was a seconds-wide window before; this PR widens it to six hours. Bump to vA, the job parks in the wait, main advances while a maintainer sleeps, approval lands at T+4h, and the tag vA gets created on whatever main points at then — not on the tree whose tarball npm actually serves. toTag: ${{ github.ref }} on the changelog step drifts the same way.
The workflow also has no concurrency: key. Two VERSION.txt bumps inside the same six-hour window run two overlapping jobs that both stage, both wait, and then race the existing_release → Create release pair: both can read exists=false and the loser takes a 422 after npm has already shipped.
Technical details
# Release identity drifts during the new approval wait
## Affected sites
- `.github/workflows/publish-latest.yml:135` — `commitish: ${{ github.ref }}` resolves at API-call time, hours after checkout pinned `github.sha`
- `.github/workflows/publish-latest.yml:102` — `toTag: ${{ github.ref }}` on the changelog builder, same drift
- `.github/workflows/publish-latest.yml` (job level) — no `concurrency:` group, so overlapping six-hour jobs race
- `.github/workflows/publish-latest.yml:111-127` — the `existing_release` probe and `Create release` are two separate steps, so the check is TOCTOU under concurrency
## Required outcome
- The tag created for version `X` must point at the commit that produced the tarball npm serves as `X`, regardless of how long approval takes.
- Two version bumps landing within one approval window must not interleave into each other's changelog or release.
## Suggested approach
- `commitish: ${{ github.sha }}` and `toTag: ${{ github.sha }}`.
- `concurrency: { group: publish-latest, cancel-in-progress: false }` at job level.ℹ️ publish-canary.yml is now a second copy of test.yml
With the npm step gone, the canary workflow is checkout → install → pnpm test → pnpm build on every push to main. test.yml already runs exactly that on push to main. So every merge now gets two identical CI runs, one of them named Publish --canary and publishing nothing — which is more confusing than the commented-out block it replaces. Deleting the file says the same thing the new header comment says, and git history keeps the recipe for whoever wants canaries back. If it stays, workflow_dispatch-only would at least stop it shadowing test.yml.
Claude Opus (free via Pullfrog for OSS) | 𝕏
| run: pnpm build | ||
|
|
||
| - name: Publish to npm | ||
| - name: Stage on npm |
There was a problem hiding this comment.
This step cannot authenticate: line 33 still passes registry-url: 'https://registry.npmjs.org' to setup-node, but this PR deleted the NODE_AUTH_TOKEN that made it useful. setup-node unconditionally writes //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} into a generated .npmrc and points NPM_CONFIG_USERCONFIG at it, so npm sees auth as already configured and never starts the OIDC exchange — you get ENEEDAUTH/403 instead of a staged version. Worth noting the PR removes registry-url from publish-canary.yml, where it was harmless, and keeps it here, where it is fatal.
Technical details
# `registry-url` suppresses the OIDC token exchange
## Affected sites
- `.github/workflows/publish-latest.yml:32-33` — `with: registry-url: 'https://registry.npmjs.org'` on the `install-dependencies` composite action, forwarded to `actions/setup-node`
- `.github/workflows/publish-latest.yml:51-56` — the `Stage on npm` step that depends on OIDC succeeding
- diff line 105-106 — `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` removed, leaving `registry-url` with nothing to inject
## Required outcome
- No `_authToken` entry for `registry.npmjs.org` exists in the npm config when `npm stage publish` runs, so the CLI falls through to the trusted-publisher OIDC flow.
## Suggested approach
- Drop the `with: registry-url: ...` block. `registry.npmjs.org` is npm's default registry, so nothing else changes.
- If it must stay for some other reason, strip the line before staging: `sed -i '/_authToken/d' "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}"`.
## References
- https://github.com/actions/setup-node/issues/1551
- https://github.com/npm/documentation/issues/1960| echo | ||
| echo "This job waits up to 350 minutes. If the approval comes later, re-run it." | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| deadline=$(( $(date +%s) + 350 * 60 )) |
There was a problem hiding this comment. 8000 p>
The 350-minute deadline starts when this step starts, but timeout-minutes: 360 is measured from job start — checkout, pnpm install, npm install -g npm@11, pnpm test, and pnpm build all run first. That leaves under ten minutes of slack, so an approval arriving late in the window can go live on npm and then have the job hard-killed before Create release runs, which is exactly the tag-without-release state the gate exists to prevent (and the friendly ::error:: on line 80 never prints either). Anchoring the deadline to job start, or just budgeting ~300 minutes, restores the headroom.
| deadline=$(( $(date +%s) + 350 * 60 )) | |
| deadline=$(( $(date +%s) + 300 * 60 )) |
| echo '```' | ||
| echo "npm stage list zshy # find the stage id" | ||
| echo "npm stage approve <id> # approve it" | ||
| echo "pnpm stage approve # or pick interactively (pnpm 12+)" |
There was a problem hiding this comment.
pnpm stage was added in pnpm v11.3.0, not 12 — the docs page carries Added in: v11.3.0. Since this line is instructions a maintainer reads mid-release to actually perform the approval, the wrong floor sends them on an unnecessary upgrade.
| echo "pnpm stage approve # or pick interactively (pnpm 12+)" | |
| echo "pnpm stage approve # or pick interactively (pnpm 11.3+)" |
|
|
||
| // npm's refusal when the version is already sitting in the staged queue. The exact wording is | ||
| // unverified against a real refusal, so the match is loose; anything it misses stays fatal. | ||
| const ALREADY_STAGED = /already (been )?staged|staged version|E409|EPUBLISHCONFLICT|previously published/i; |
There was a problem hiding this comment.
The wording is verifiable now: npm returns 409 … Cannot publish over previously staged version "x.y.z" (npm/cli#9889), which the staged version branch already catches — so the comment above can lose its hedge. The loose branches are the ones worth dropping: E409 matches any HTTP 409 (policy rejection, trusted-publisher misconfiguration), and a false match returns success with nothing staged, sending the job into a 350-minute wait that ends by telling the maintainer to approve a stage that does not exist.
Technical details
# `ALREADY_STAGED` matches more than an already-staged refusal
## Affected sites
- `versace.ts:100` — `E409` and `previously published` are broad enough to swallow unrelated npm failures
- `versace.ts:127-129` — a match returns `void` from `runNpmStagePublish`, so the workflow step exits 0
- `.github/workflows/publish-latest.yml:78-85` — downstream consequence: a 350-minute poll for a version that was never staged, ending in a misleading error
## Required outcome
- Only npm's genuine "this version is already staged" refusal is treated as success; every other failure stays fatal and surfaces at the staging step rather than six hours later.
## Suggested approach
- Narrow to the documented wording. `previously published` is redundant with the `isLiveOnNpm` pre-check on line 110 — if you keep it as a backstop for a stale `npm view`, it is worth a comment saying so, because it reports "already staged" for a version that is in fact already published.| const ALREADY_STAGED = /already (been )?staged|staged version|E409|EPUBLISHCONFLICT|previously published/i; | |
| const ALREADY_STAGED = /previously staged version|already (been )?staged/i; |
| console.log(`Running ${command}...`); | ||
|
|
||
| try { | ||
| process.stdout.write(execSync(command, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] })); |
There was a problem hiding this comment.
The workflow passes --verbose specifically to get npm's diagnostics, but npm writes its verbose log to stderr and execSync only returns stdout — so on the success path that output is captured into the pipe and discarded. It only ever surfaces via failure.stderr when the command fails. Since the pipes are needed to classify the failure, spawnSync (which returns both streams) would let the success path echo stderr too; this also drops the live streaming the old stdio: "inherit" gave you for a step that can run for minutes.

CI can no longer publish zshy. The trusted publisher is stage-only, so the publish script runs
npm stage publishand a maintainer approves each version with 2FA.The latest workflow is
workflow_dispatchonly now. A dispatch needs a GitHub session or token with write access, which a stolen push credential does not give, so bumpingVERSION.txton main no longer publishes. The version still comes from that file; a first step fails the run on any ref butmain.Not exercised end to end — npm is locked.