From a96e4b58f81adb816a8ab5b0517b4bb448a9219e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:55:32 -0300 Subject: [PATCH] feat(release): npm staged publishing + pre-publish boot-smoke (WS1.3) (#7092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): npm staged publishing + pre-publish boot-smoke (WS1.3) v3.8.47 shipped an npm tarball that crashed on every boot and had to be deprecated — the publish path had no runtime gate and the owner's 2FA happened BEFORE any proof. Two changes to npm-publish.yml: - check:pack-boot runs right before any publish (dist/ is already assembled by build:cli in the same job) — a non-booting tarball now fails the workflow before anything reaches the registry. - npm publish becomes 'npm stage publish' (staged publishing, GA 2026-05-22, npm >= 11.15 ensured in-job): the exact bytes are parked on the registry but NOT installable until the owner runs 'npm stage approve ' with 2FA. The workflow summary prints the approve/verify/reject flow; RELEASE_CHECKLIST documents the owner flow, the one-time Trusted Publisher stage-only config, and the deprecate-first rollback playbook. publish_mode=direct (workflow_dispatch) is the emergency fallback to the legacy immediate publish. First real-registry exercise happens on the next release with the fallback one dispatch away (D2 decision, v3.8.49 plan). GitHub Packages secondary publish unchanged. YAML parse validated. * docs(release): reference upcoming verifier without file paths (docs-all strict) * fix(release): pin npm 11.15.0 in the staged-publish version guard (no @latest in the publish job) --- .github/workflows/npm-publish.yml | 64 +++++++++++++++++-- .../maintenance/npm-staged-publishing.md | 1 + docs/ops/RELEASE_CHECKLIST.md | 28 ++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 changelog.d/maintenance/npm-staged-publishing.md diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 8e74b96d46..1edd3c09e0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -22,6 +22,14 @@ on: - latest - next - historic + publish_mode: + description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)" + required: false + default: "staged" + type: choice + options: + - staged + - direct workflow_call: inputs: version: @@ -166,8 +174,34 @@ jobs: TAG: ${{ github.ref_name }} run: gh release upload "$TAG" sbom-npm.cdx.json --clobber - - name: Publish to npm + # WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must + # BOOT. build:cli already assembled dist/ above; this packs+installs+boots the + # real tarball and fails the publish before anything reaches the registry. + - name: Boot-smoke the tarball before ANY publish if: steps.resolve.outputs.skip != 'true' + run: npm run check:pack-boot + + # WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish` + # parks the exact bytes on the registry WITHOUT making them installable; the + # owner then verifies and approves with 2FA (`npm stage approve`), moving the + # human gate to AFTER the proof instead of before it. Requires npm >= 11.15 + # (staged publishing GA 2026-05-22). publish_mode=direct is the emergency + # fallback (legacy immediate publish) via workflow_dispatch. + - name: Ensure npm supports staged publishing + if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') + run: | + set -euo pipefail + CUR=$(npm --version) + if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then + # Pinned exact version (supply-chain: never float @latest in the publish + # job); bump deliberately when a newer npm is required. + echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing" + npm install -g --ignore-scripts npm@11.15.0 + fi + npm --version + + - name: Publish to npm (staged — owner approves with 2FA) + if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct') env: VERSION: ${{ steps.resolve.outputs.version }} TAG: ${{ steps.resolve.outputs.tag }} @@ -175,10 +209,32 @@ jobs: run: | set -euo pipefail # Always pass --tag explicitly. Defense in depth: even if VERSION is - # accidentally an older release, `npm publish --tag historic` will - # NOT promote it to `@latest`. + # accidentally an older release, the historic tag will NOT claim `@latest`. + npm stage publish --provenance --access public --tag "$TAG" + { + echo "## 📦 omniroute@$VERSION STAGED (not yet installable)" + echo "" + echo "The exact bytes are parked on the registry. To release them:" + echo '```' + echo "npm stage list omniroute # find the stage id" + echo "npm stage approve # owner 2FA — THE publish" + echo '```' + echo "To verify the staged bytes first: npm stage download → run" + echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)." + echo "To discard: npm stage reject ." + } >> "$GITHUB_STEP_SUMMARY" + echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'" + + - name: Publish to npm (DIRECT — emergency fallback) + if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct' + env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail npm publish --provenance --access public --tag "$TAG" - echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)" + echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" - name: Publish to GitHub Packages if: steps.resolve.outputs.skip != 'true' diff --git a/changelog.d/maintenance/npm-staged-publishing.md b/changelog.d/maintenance/npm-staged-publishing.md new file mode 100644 index 0000000000..e57f471083 --- /dev/null +++ b/changelog.d/maintenance/npm-staged-publishing.md @@ -0,0 +1 @@ +- **Release**: npm publishing is now STAGED by default — the workflow boots the packed tarball (`check:pack-boot`) and runs `npm stage publish` (bytes parked on the registry, not installable); the owner verifies the staged bytes and releases them with `npm stage approve` + 2FA, moving the human gate to after the proof (the structural fix for the #7065 broken-tarball class); `publish_mode=direct` remains as a documented emergency fallback diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index fe7bfafad4..4d298c66fd 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -37,6 +37,34 @@ npm run test:e2e # optional but recommended /capture-release-evidences-cc ``` +## npm Staged Publishing (default since v3.8.49 — WS1.3/D2) + +The npm-publish workflow no longer publishes directly: it boots the packed tarball +(`check:pack-boot`) and then runs `npm stage publish` — the exact bytes are parked on +the registry, **not installable** until the owner approves. The human 2FA gate moved +to AFTER the proof, not before it. + +**Owner flow after the workflow goes green:** + +1. `npm stage list omniroute` — find the stage id (also printed in the workflow summary). +2. Verify the staged bytes (recommended): `npm stage download `, then install the + downloaded tarball into a temp prefix and boot it (`npm run check:pack-boot` automates + the same pack→install→boot verdict in CI). +3. `npm stage approve ` — the 2FA prompt IS the publish. `npm stage reject ` discards. +4. Post-publish net: the post-publish verifier (WS1.4 of the v3.8.49 plan) installs the + published version from the public registry in a clean container and boots it. + +**Emergency fallback:** `workflow_dispatch` with `publish_mode=direct` restores the +legacy immediate `npm publish` (use only if staging itself misbehaves; record why). + +**One-time hardening (owner, npmjs.com):** configure the Trusted Publisher for +`omniroute` in stage-only mode so a leaked long-lived token cannot `npm publish` +directly from anywhere — CI can only stage; only the owner's 2FA releases. + +**Broken-artifact playbook (unchanged):** `npm deprecate omniroute@ " — use "` +as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents +window and never as the first move. Docker: never rewrite a version tag — rollback is +repointing `latest` to the last good digest. ## Hotfix Fast-Lane (label `hotfix`) A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet,