Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -166,19 +174,67 @@ 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 }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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 <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$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'
Expand Down
1 change: 1 addition & 0 deletions changelog.d/maintenance/npm-staged-publishing.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions docs/ops/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`, 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 <id>` — the 2FA prompt IS the publish. `npm stage reject <id>` 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@<bad> "<reason> — use <fixed>"`
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,
Expand Down
Loading