8000 ci: stage npm publishes instead of publishing directly by colinhacks · Pull Request #76 · colinhacks/zshy · GitHub
[go: up one dir, main page]

Skip to content

ci: stage npm publishes instead of publishing directly - #76

Merged
colinhacks merged 2 commits into
mainfrom
staged-publishing
Sep 24, 2026
Merged

colinhacks merged 2 commits into
mainfrom
staged-publishing

Conversation

@colinhacks
@colinhacks colinhacks commented Sep 22, 2026 •
Copy link
Copy Markdown
Owner

CI can no longer publish zshy. The trusted publisher is stage-only, so the publish script runs npm stage publish and a maintainer approves each version with 2FA.

The latest workflow is workflow_dispatch only now. A dispatch needs a GitHub session or token with write access, which a stolen push credential does not give, so bumping VERSION.txt on main no longer publishes. The version still comes from that file; a first step fails the run on any ref but main.

  • Staging skips a version already live and tolerates one already staged. The restore to 0.0.0 is unchanged.
  • The latest workflow installs npm >= 11.15, drops the deleted NPM_TOKEN, waits for approval before the release steps, and skips release creation when the tag already has one.
  • The canary workflow no longer touches npm. Nobody is there to approve a canary on every push to main.

Not exercised end to end — npm is locked.

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.
Copilot AI lite review requested due to automatic review settings September 22, 2026 06:13
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts stages instead of publishes — runNpmPublish becomes runNpmStagePublish, guarded by an isLiveOnNpm pre-check and an ALREADY_STAGED regex that treats npm's re-stage refusal as success.
  • publish-latest.yml gains a human gate — installs npm@11, asserts >= 11.15, stages via OIDC, then busy-waits up to 350 minutes polling npm view before the changelog/tag/release steps run.
  • Release creation is made re-runnable — a new gh release view probe sets exists, and Create release is skipped when the tag already has one.
  • publish-canary.yml drops npm entirely — id-token: write and registry-url removed, 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus (free via Pullfrog for OSS) | 𝕏

run: pnpm build

- name: Publish to npm
- name: Stage on npm
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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+)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
echo "pnpm stage approve # or pick interactively (pnpm 12+)"
echo "pnpm stage approve # or pick interactively (pnpm 11.3+)"

Comment thread versace.ts

// 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Suggested change
const ALREADY_STAGED = /already (been )?staged|staged version|E409|EPUBLISHCONFLICT|previously published/i;
const ALREADY_STAGED = /previously staged version|already (been )?staged/i;

Comment thread versace.ts
console.log(`Running ${command}...`);

try {
process.stdout.write(execSync(command, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@colinhacks
colinhacks merged commit 8b3b212 into main Sep 24, 2026
2 of 3 checks passed
@colinhacks
colinhacks deleted the staged-publishing branch September 24, 2026 02:29
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.

2 participants

0