fix(publish-npm): make silent publish failures structurally impossible - #82
Conversation
…ailures The publish-npm block currently trusts the publisher CLI's exit code, but `bunx clean-publish` (and `bunx`-wrapped commands generally) can swallow `npm publish`'s non-zero exit. The OIDC branch additionally catches its own publish failures to fall through to the token path. Together, these mean a job can finish green with nothing on npm — which is exactly what happened to spectrum-ts 1.10.0..1.11.1 (four silent-fail releases under an expired NPM_TOKEN). Add a final assertion that probes registry.npmjs.org for the just- published <name>@<version> (resolved from package.json, the source of truth both publish paths consume). Six retries spaced 5s apart absorb npm CDN propagation. Runs only on real publishes (not --dry-run). On failure, prints the most likely root causes — token revoked, scope narrowed, OIDC misconfigured, or publisher CLI swallowed an error — so operators don't have to re-derive the diagnosis each time. Co-authored-by: Cursor <cursoragent@cursor.com>
📝 WalkthroughWalkthroughThe npm publish action now includes a post-publish verification step. After publishing, the action reads the package name and version from ChangesPublish Verification
Estimated Code Review Effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
📄 README may need an updateThis PR introduces changes that might not be reflected in Reason:
|
📚 Skills documentation may need an updateThis PR introduces changes that might not be reflected in the skills documentation. Reason:
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
.github/blocks/publish-npm/action.yaml (1)
152-153: ⚡ Quick winRevisit the severity: scoped package URLs don’t need encoding for npm registry lookups
The npm registry accepts unencoded scoped package paths (e.g.,https://registry.npmjs.org/@types/node/25.9.1returns 200), so the proposed URL construction change isn’t a major correctness fix for scoped names. URL-encoding the package name/version remains a reasonable robustness improvement, but it should be treated as optional refactoring rather than a correctness issue.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/blocks/publish-npm/action.yaml around lines 152 - 153, The change treating scoped package URL-encoding as a correctness fix is overstated; revert the required encoding and keep the registry lookup using the existing URL construction (the URL variable built from NAME and VERSION: URL="https://registry.npmjs.org/$NAME/$VERSION") so scoped names like `@types/node` resolve correctly, and if you want to keep encoding as a robustness option implement it as a non-mandatory refactor (e.g., optional flag or a separate helper) rather than changing the default behavior; update the echo line/message if needed to reflect that encoding is optional.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In @.github/blocks/publish-npm/action.yaml:
- Around line 152-153: The change treating scoped package URL-encoding as a
correctness fix is overstated; revert the required encoding and keep the
registry lookup using the existing URL construction (the URL variable built from
NAME and VERSION: URL="https://registry.npmjs.org/$NAME/$VERSION") so scoped
names like `@types/node` resolve correctly, and if you want to keep encoding as a
robustness option implement it as a non-mandatory refactor (e.g., optional flag
or a separate helper) rather than changing the default behavior; update the echo
line/message if needed to reflect that encoding is optional.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b054323b-d39d-4660-b099-fd4df2074fe8
📒 Files selected for processing (1)
.github/blocks/publish-npm/action.yaml
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: copilot-pull-request-reviewer
- GitHub Check: check-readme / check-readme
- GitHub Check: check-skills / check-skills
Summary
publish-npmcurrently trusts the publisher CLI's exit code, but several common publishers — most notablybunx clean-publishandbunx-wrapped commands generally — swallownpm publish's non-zero exit. The OIDC branch also explicitly catches its own publish failures to fall through to the token path. Together, these mean a job can finish green with nothing on npm.This is exactly what happened to
spectrum-tsfromv1.10.0throughv1.11.1: an invalidatedNPM_TOKENproducednpm error code E404on every publish, but the publisher's exit code stayed at 0, so four releases went green on Actions while npmlateststayed at1.9.2. No alarms, no email, no PR comments — just silent drift.This PR makes that class of failure structurally impossible via two layers of defense.
Layer 1 — publish steps fail directly when npm errors
Both publish steps (OIDC primary, NPM token fallback) now capture publisher output via
tee, read the publisher's real exit code from${PIPESTATUS[0]}(not the pipeline's$?, which is always 0 because oftee), and treat any^npm (error|ERR!)line in the output as a hard failure even when the publisher itself exited 0. This catches the wrapper-swallowed-exit case directly at the publish step instead of relying on after-the-fact detection.The OIDC branch also intentionally refuses to fall back to
NPM_TOKENon a swallowed-exit failure: a "successful" OIDC publish that printed npm errors signals a wrapper bug the token path can't fix, and falling back would just hide it again. Real OIDC failures (non-zero exit) still fall back as before.Layer 2 — registry verification as a final backstop
Even with layer 1, a future publisher we haven't anticipated could still swallow errors in some new way. So a final step probes
https://registry.npmjs.org/<name>/<version>and asserts the bytes are actually fetchable. Six retries spaced 5s apart absorb npm CDN propagation. Only runs on real publishes (skipped on--dry-run).Why both layers
Layer 1 fails fast at the source — operators see "npm token publish exited non-zero" or "publisher reported success but npm emitted error lines" inline with the publish output, with no ambiguity about which step is wrong. Layer 2 is the canary — it's publisher-agnostic and asserts the only thing that actually matters: did the bytes land on the public registry. If layer 1 ever has a blind spot (e.g., a future publisher invents a new error format that doesn't match
^npm (error|ERR!)), layer 2 still catches it.This eliminates the "publish reports success, registry has nothing" state entirely. It cannot be reached.
Backward compatibility
<name>@<version>is live" message appear in the logdry-run: trueskips the registry verification (nothing to verify); the publish-step hardening is a no-op on dry-runs because dry-run output doesn't containnpm errorunless packaging itself is broken (which we want to surface anyway)Test plan
NPM_TOKEN: all three steps succeed, log shows✅ Published via NPM tokenand✅ <name>@<version> is live on the npm registryNPM_TOKENand a publisher that swallows exits (e.g.,bunx clean-publish): publish step fails red on layer 1 (Publisher reported success but npm emitted error lines), without ever reaching layer 2spectrum-tsrepo's first OIDC publish after this landsNotes for reviewers
There's a follow-up PR on
spectrum-ts(photon-hq/spectrum-ts#76) that opts that repo into npm OIDC Trusted Publishing. That PR is the operational fix for the immediate spectrum-ts breakage; this PR is the systemic safety net so the same class of failure can't recur silently for any caller ofpublish-npm.