diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d719ec..033fcf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -833,9 +833,16 @@ jobs: # nexus-flow-76u.15), while staging always runs to rehearse the packaging — registry-free, see the # publish step for why that distinction is the whole point (6j6v.xk2t). Auth is OIDC trusted # publishing (no long-lived NXF_NPM_TOKEN — nexus-flow-76u.15): once the var is enabled, a prod - # publish only succeeds when the trusted publisher is configured on the @nexus-flow/mcp package + # release only reaches npm when the trusted publisher is configured on the @nexus-flow/mcp package # (npmjs.com → package → Trusted Publisher → GitHub Actions → this repo + release.yml). Absent - # that config npm rejects the publish — fail-closed by default. Runbook: docs/specs/release-management.md. + # that config npm rejects it — fail-closed by default. Runbook: docs/specs/release-management.md. + # + # STAGED, NOT PUBLISHED (6j6v.c7dd). The job runs `npm stage publish`: the version lands on npm + # UNAVAILABLE to the public, and only a maintainer's approval WITH 2FA (npmjs.com, or + # `npm stage approve `) makes it live. This is npm's default for trusted publishers since + # 2026-09-03 (a new one is created stage-only) and the owner's choice for this repo: no CI run — + # not even a compromised one — can put a package in front of `npx` users on its own. The job goes + # green when the version is STAGED; the release is not complete on npm until it is approved. publish-npm: needs: [meta, publish] # Run for staging (packaging dry-run) always; for a prod release only once npm publishing is @@ -855,16 +862,17 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: - # Node 24 for a modern bundled npm; the upgrade step below then pins npm >= 11.5.1, the - # floor for OIDC trusted publishing (npm docs). registry-url points publish at npmjs.org. + # Node 24 for a modern bundled npm; the upgrade step below then pins npm >= 11.15.0, the + # floor for staged publishing (npm docs). registry-url points publish at npmjs.org. node-version: 24 registry-url: https://registry.npmjs.org - # Trusted publishing (OIDC) requires npm CLI >= 11.5.1; pin the range instead of `@latest` so a - # future npm major can't silently change publish behaviour in this credential-minting job. - # Zero-dep shim, so this touches nothing the package ships. - - name: Ensure npm supports trusted publishing - run: npm install -g "npm@>=11.5.1 <12" + # Staged publishing (`npm stage publish`) requires npm CLI >= 11.15.0 (trusted publishing alone + # needed 11.5.1); pin the range instead of `@latest` so a future npm major can't silently change + # publish behaviour in this credential-minting job. Zero-dep shim, so this touches nothing the + # package ships. + - name: Ensure npm supports staged trusted publishing + run: npm install -g "npm@>=11.15.0 <12" # Stamp the release version into the package (lockstep). The published file list is unaffected. - name: Stamp the package version @@ -948,6 +956,11 @@ jobs: # succeeds once the trusted publisher is configured on the package (else npm rejects it — # fail-closed by default; see the runbook in docs/specs/release-management.md). # Idempotent: a re-run of the same tag must not fail on an already-published version. + # A version that is STAGED but not yet approved is invisible to `npm view`, and a re-run + # then fails on npm's own uniqueness check. That is deliberate: the short-lived OIDC token + # may only stage or publish, not list the queue, so this job cannot tell "already staged" + # from any other refusal, and guessing from error text would turn a real refusal green. + # Approve (or reject) the staged version instead of re-running. if npm view "@nexus-flow/mcp@${VERSION}" version >/dev/null 2>&1; then echo "@nexus-flow/mcp@${VERSION} already published — skipping" exit 0 @@ -968,8 +981,24 @@ jobs: else echo "repo not public (private=${REPO_PRIVATE:-unknown}) → provenance omitted (npm cannot attest a non-public repo)" fi - npm publish --access public "${PROVENANCE[@]}" - echo "published @nexus-flow/mcp@${VERSION}" + npm stage publish --access public "${PROVENANCE[@]}" 2>&1 | tee "$RUNNER_TEMP/stage.log" + echo "staged @nexus-flow/mcp@${VERSION} — NOT live until a maintainer approves it" + { + echo "## npm: @nexus-flow/mcp@${VERSION} is STAGED, not live" + echo + echo "A maintainer approves it with 2FA — on npmjs.com (package → Staged versions), or:" + echo + echo '```' + echo "npm stage list @nexus-flow/mcp" + echo "npm stage approve " + echo '```' + echo + echo "npm's own output:" + echo + echo '```' + cat "$RUNNER_TEMP/stage.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" # Refresh the STAGING federated content after a real beta release so the staging landing's # /open-source changelog reflects the new beta (nexus-flow-s8a, re-homed to the content provider diff --git a/docs/specs/release-management.md b/docs/specs/release-management.md index a7d7122..f11a6d5 100644 --- a/docs/specs/release-management.md +++ b/docs/specs/release-management.md @@ -853,10 +853,14 @@ new installs/checks; already-affected users are healed by a higher fix release. 8. Rehearsal: `release.yml` via dispatch against staging (with `NXF_MACOS_SIGNING_ENABLED=true` the run verifies codesign + notarization + `spctl --assess`); then the first real tag release. -9. **npm publishing of `@nexus-flow/mcp` via trusted publishing.** The `publish-npm` +9. **npm publishing of `@nexus-flow/mcp` via trusted, STAGED publishing.** The `publish-npm` job authenticates by **OIDC trusted publishing — no long-lived npm token** (validated against the - current npm docs; this supersedes the earlier `NXF_NPM_TOKEN` plan). It is fail-closed: a prod publish - only succeeds once the trusted publisher is configured on the package, so nothing ships prematurely. + current npm docs; this supersedes the earlier `NXF_NPM_TOKEN` plan). It is fail-closed: a prod release + only reaches npm once the trusted publisher is configured on the package, so nothing ships prematurely. + **Since 6j6v.c7dd it STAGES rather than publishes** (`npm stage publish`): the version sits on npm + unavailable to the public until a maintainer approves it with 2FA. That is npm's default for trusted + publishers created since 2026-09-03, and the owner's choice: no CI run can put a package in front of + `npx` users on its own. **Every release therefore ends with one human step** — see g. One-time owner steps (require the npm account that will own the org): a. **Own the scope.** Create the **`@nexus-flow`** org on npmjs.com (this reserves the `@nexus-flow/*` scope) and confirm the **`@nexus-flow/mcp`** name is free. @@ -868,9 +872,13 @@ new installs/checks; already-affected users are healed by a higher fix release. use that and skip the manual publish.) c. **Configure the trusted publisher.** npmjs.com → `@nexus-flow/mcp` → *Settings* → *Trusted Publisher* → **GitHub Actions**, with: organization/owner **`nxsflow`**, repository **`nexus-flow`**, workflow - filename **`release.yml`** (filename only). Leave *Environment* blank (the job uses none); if the - form offers an allowed-actions choice, select **npm publish**. These must match the `publish-npm` - job exactly (`id-token: write`, `release.yml`). + filename **`release.yml`** (filename only). Leave *Environment* blank (the job uses none). Under + *Allowed actions* keep **stage publish only** (npm's default); do NOT tick direct publish — the job + never uses it, and leaving it off is what makes the approval step binding. A trusted publisher that + allows only staging answers a direct `npm publish` with `403 … OIDC permission denied for this + action`. These must match the `publish-npm` job exactly (`id-token: write`, `release.yml`). The entry + is bound to the repository, not only its name: after the 2026-09 cutover to the history-free repo + the old entry answered `404` on the PUT until it was removed and re-added. d. **Lock it down.** Once a trusted publish works, set the package to *Require two-factor authentication and disallow tokens* (npm recommendation) — OIDC still works, and stray tokens can no longer publish. *Optional defense-in-depth (yk2c / review #160.4).* The trust today is "any `release.yml` run with @@ -896,8 +904,16 @@ new installs/checks; already-affected users are healed by a higher fix release. a machine **without** `nxs`: `npx -y @nexus-flow/mcp -- --workspace ` fetches the signed `nxs` from the live CDN, passes **sha256 + minisign**, caches, and serves. Confirm a **tampered/wrong-key** artifact still **aborts** against the live pubkey (the fail-closed guarantee — already unit-proven by - the shim's `node --test` suite, re-asserted against the real CDN here). npm CLI ≥ 11.5.1 + Node ≥ 22.14 - are required for OIDC; the job pins them (Node 24 + `npm install -g npm@latest`). + the shim's `node --test` suite, re-asserted against the real CDN here). npm CLI ≥ 11.15.0 (staged + publishing; OIDC alone needed 11.5.1) + Node ≥ 22.14 are required; the job pins them (Node 24 + + `npm install -g "npm@>=11.15.0 <12"`). + g. **Approve every release (the human step).** The `publish-npm` job goes green when the version is + STAGED, and writes the approval instructions into its job summary. Approve on npmjs.com + (`@nexus-flow/mcp` → staged versions) or with `npm stage list @nexus-flow/mcp` then + `npm stage approve `; both ask for 2FA. `npm stage reject ` discards a bad one. + Until then `npx -y @nexus-flow/mcp` keeps resolving the previous version. Do not re-run the job for + a version that is staged but unapproved: npm refuses a second copy of the same version, and the + job cannot see the queue (its OIDC token may only stage or publish), so it fails rather than guess. ## 13. Implementation phases