Skip to content

2.7: Publish releases directly by default, keep staging behind an input - #72

Merged
NSchatz merged 1 commit into
mainfrom
build/2.7-direct-npm-publishing
Oct 8, 2026
Merged

NSchatz merged 1 commit into
mainfrom
build/2.7-direct-npm-publishing

Conversation

@NSchatz

@NSchatz NSchatz commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

What changed

The shared release workflow now publishes directly by default, so a release is live when its run ends and nobody has to promote it. Staging stays available behind a new optional input.

  • release.yml: a new workflow_call input, publish-mode (string, default "direct"; the other accepted value is "staged"). It reaches publish-floor.mjs as the PUBLISH_MODE environment variable, so the value is never shell text. The publish step's publish: expression is unchanged. It still runs pnpm run release on every mode except staged, and publish-floor.mjs now reports mode: direct by default, for a package that is already on npm too. Every step that reads steps.publish-floor.outputs.mode (stage report, promotion wait, promoted-arm release, direct-arm release, install gate) still reads that one value, so on the default arm the staged steps are skipped and the direct-arm release and install gate run.
  • scripts/publish-floor.mjs:
    • direct: still identifies the publish tool and reads its version, and refuses if it cannot. It owes no staging floor and does not query the registry, because neither changes the arm. The annotation is a notice, not a warning.
    • staged: behaves exactly as main does today.
    • Any other value, including the empty string, is refused before any tool is spawned or the registry is queried.
    • With neither --mode nor PUBLISH_MODE, it uses the default, direct.
  • Tests:
    • test/publish-floor.test.mjs: the existing staged-arm cases now ask for staged explicitly. New cases cover the default, the direct arm (no floor owed, registry never queried, tool still read) and the refusal of unknown values, both as unit tests and end to end through the real script. The input's declared default is pinned to the script's DEFAULT_PUBLISH_MODE. The input reaches only the floor gate, and only as an env var.
    • test/environment-gate.test.mjs: AC11 now admits publish-mode as an optional input with default "direct".
  • README: "Staged publishing" is now "Direct publishing, and staging on request". It says what a direct publish needs from NPM_TOKEN, that npm caps write tokens at 90 days, that npm has said it will remove direct publishing with granular tokens in January 2027, and how a caller opts back into staging. Each claim cites its source and the date we read it.

Callers. All 14 library callers pin release.yml@main and pass only package-name (read from each caller's .github/workflows/release.yml on main, 2026-10-08). They get the direct default with no change on their side. scripts/reference-publish.mjs classifies the new input as input-added with act: false: "the input publish-mode was added, defaulting to "direct". A caller that passes nothing is unaffected."

How it was checked

$ node --test "test/*.test.mjs"
ℹ tests 1031
ℹ pass 1031
ℹ fail 0
$ actionlint            # rhysd/actionlint 1.7.12, all workflows
(exit 0)
$ node scripts/check-no-emdash.mjs
[no-emdash] OK: 89 tracked file(s) scanned ...

Before this change the suite had 1019 tests, all passing. With the code change and no test edits, 15 of them failed, all of them cases that expected the staged arm by default.

We did not run any release, publish or workflow_dispatch.

Research (read 2026-10-08)

  • Direct npm publish from CI with a granular token. "When the Bypass 2FA option is set to true, this setting takes precedence over account-level and package-level 2FA settings for package and automation actions such as publishing." Bypass 2FA "is set to false by default at token creation". A "Read and write (stage only)" token "cannot publish new package versions directly". https://docs.npmjs.com/about-access-tokens (last edited 2026-09-10). Without Bypass 2FA, npm answers a CI publish with E403 or EOTP.
  • No OTP flag and no --provenance flag needed. Provenance is optional to npm and is enabled by NPM_CONFIG_PROVENANCE, which the publish step already sets for public callers, together with id-token: write. https://docs.npmjs.com/generating-provenance-statements (last edited 2026-05-04). We checked this on the registry: @cosyte/hl7@0.0.10, which this workflow published directly on 2026-08-07, carries an SLSA provenance attestation (npm view @cosyte/hl7@0.0.10 dist.attestations).
  • Staging is chosen per publish command. "Instead of publishing directly with npm publish, you can submit packages to a staging area with npm stage publish." Promotion needs 2FA: "You will be prompted for 2FA verification whether you approve the package in the CLI or on npmjs.com." https://docs.npmjs.com/staged-publishing (last edited 2026-09-29).
  • Write tokens are capped. "All npm existing granular tokens with write permissions are now capped at 90-day maximum lifetime." https://github.blog/changelog/2025-11-05-npm-security-update-classic-token-creation-disabled-and-granular-token-changes/
  • End date for direct publishing with tokens. "The ability to publish new package versions directly with a granular access token will be removed in January 2027" (about-access-tokens, above). "We are targeting January 2027 for this update. You should move automated publishing to trusted publishing (OIDC) or staged publishing." https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/. The stage-only token release "doesn't change existing tokens or their direct-publish capabilities." https://github.blog/changelog/2026-09-18-stage-only-npm-tokens-for-safer-automation/

Scope notes

  • The publish expression is untouched. The arm is decided in one place, publish-floor.mjs, so the publish command and the steps that depend on it cannot disagree.
  • On the direct arm, the floor gate no longer queries the registry. Before this change a 503 from the registry refused every release of an existing package. On the direct arm the answer cannot change what happens, so a registry outage now fails at the publish itself instead of before packing.
  • staged-publish.mjs, promotion.mjs and the finalize job are unchanged. finalize also covers a direct publish whose release step never finished, which was already true before this change.
  • The first direct publish of an existing package depends on the org NPM_TOKEN being allowed to publish without 2FA. If it fails with E403 or EOTP, the token needs replacing (granular, "Read and write (publish and stage)", Bypass 2FA). That is a step for the owner on npmjs.com, and this PR does not address it.

https://claude.ai/code/session_01U97ssbeo9C37pqPiWMQ1BU

The release job now publishes every release directly unless its caller
asks for staging, so a version is live when its run ends and nobody has
to promote it. A new optional workflow_call input, publish-mode, picks
the arm: "direct" (the default) or "staged". Every caller pins @main and
passes nothing, so every caller gets direct with no change on its side.

publish-floor.mjs reads the input (PUBLISH_MODE, or --mode by hand) and
stays the one place the arm is decided; every step condition still reads
its mode output, and the publish command is byte-identical. On the
direct arm it still identifies the publish tool and reads its version,
but owes no staging floor and does not ask the registry, because neither
changes the arm. On "staged" its behaviour is unchanged. Any other value,
the empty string included, is refused before anything is packed.

The README section is now "Direct publishing, and staging on request",
with what a direct publish needs from NPM_TOKEN (a granular token,
"Read and write (publish and stage)", with Bypass 2FA, or npm answers
E403/EOTP), the 90-day cap on write tokens, and npm's stated January
2027 end for direct publishing with granular tokens, each with its
source and the date read.

Claude-Session: https://claude.ai/code/session_01U97ssbeo9C37pqPiWMQ1BU
@NSchatz
NSchatz merged commit d422872 into main Oct 8, 2026
8 checks passed
@NSchatz
NSchatz deleted the build/2.7-direct-npm-publishing branch October 8, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant