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,