Skip to content

Publish through npm trusted publishing instead of NPM_TOKEN - #78

Merged
NSchatz merged 3 commits into
mainfrom
harness/107-release-trusted-publishing
Oct 20, 2026
Merged

NSchatz merged 3 commits into
mainfrom
harness/107-release-trusted-publishing

Conversation

@NSchatz

@NSchatz NSchatz commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What & why

npm will stop allowing direct publishing with granular access tokens in January 2027. This moves the shared release workflow onto npm trusted publishing (OIDC). Once it merges, no long-lived npm token exists anywhere in the pipeline, and every version still gets provenance.

Merging changes every caller at once. Every caller pins release.yml@main, so from the merge on, each caller's next release authenticates by OIDC. Each package needs its trusted-publisher entry on npmjs.com before that release, or the publish step fails with npm's own refusal and nothing is published. See "The owner's steps" below.

What changed

  • .github/workflows/release.yml

    • The NPM_TOKEN secret declaration is gone.
    • The publish step no longer sets NPM_TOKEN or NODE_AUTH_TOKEN.
    • The publishing job's setup-node no longer sets registry-url.
    • rg -n NPM_TOKEN .github/workflows/release.yml prints nothing; comments were rewritten too.
    • The release job is the only job that publishes or stages, and it already granted id-token: write and ran on ubuntu-latest. The version and finalize jobs hold no id-token: write.
    • With no token in its environment, the pinned changesets/action (v1.9.0, a45c4d59) writes no ~/.npmrc and logs that it is "using npm trusted publishing" (src/index.ts:78-116, read 2026-10-19).
  • scripts/publish-floor.mjs (the floor gate) now enforces a trusted-publishing floor on both arms, before anything is packed. It prints the tool that makes the OIDC exchange and that tool's version, and adds auth-tool, auth-tool-version and trusted-publishing-floor to its outputs:

    Caller's publish tool Who makes the exchange Floor
    pnpm 11 and later pnpm, natively pnpm 11.0.0
    pnpm 10 and earlier the npm CLI, by delegation npm 11.5.1 and Node 22.14.0
    npm the npm CLI npm 11.5.1 and Node 22.14.0

    The staging floors (npm 11.15.0, pnpm 11.3.0) already clear these, and a test pins that.

  • README: the release.yml row and the caller example now say how publishing authenticates. They also say that each package's trusted-publisher entry names the package's own repository and release.yml (the caller), never cosyte/.github. A new section, "How a release authenticates to npm", lists the sources and the owner's steps.

  • Tests: test/trusted-publishing.test.mjs is new. It checks that no npm token is named or read anywhere, and that every job that publishes or stages grants id-token: write, runs on a hosted runner and runs the floor gate first. It also checks the README. Each check is run against a copy of release.yml with that one property broken and shown to fail. The existing assertions that pinned the token on the publish step now pin its absence.

Does pnpm 10.34.5 authenticate through OIDC? Yes, through the npm it runs

Every caller pins pnpm 10.34.5 or 10.0.0. pnpm 10 has no OIDC code of its own. Its pnpm publish packs the tarball and then runs npm publish --ignore-scripts <tarball>, passing its whole environment through (run-npm copies process.env). So the npm CLI performs the publish and the OIDC exchange. All sources below were read 2026-10-19:

Callers

I checked all 14 callers' .github/workflows/release.yml. Every one already grants id-token: write on its calling job and passes secrets: inherit. No caller passes NPM_TOKEN by name, so removing the declared secret breaks none of them. All 14 packages already exist on npm, so each one can get an entry now.

The owner's steps on npmjs.com, with 2FA

These are not in this PR.

  1. For each package, add a GitHub Actions trusted publisher: organization cosyte, the package's own repository, workflow filename release.yml. The environment name release is optional.
  2. Allow direct publishing on each entry. Entries made after 2026-09-03 allow only npm stage publish by default, and callers use the default direct arm.
  3. Release within 48 hours of creating an entry. An entry that no publish has used expires after 48 hours (GitHub changelog, 2026-10-02).
  4. After a package has published through its entry, delete its NPM_TOKEN secret and revoke the token.

A residual, written down rather than closed

id-token: write is granted to the whole job. ACTIONS_ID_TOKEN_REQUEST_TOKEN is therefore in every step of the release job, and npm now accepts it in exchange for a publish token. Any step can mint one while the job runs: the caller's pnpm install lifecycle scripts, its Verify ladder and its pack:docs. Before this change the npm token was handed only to the publish step, and left in ~/.npmrc until the removal step.

Four things bound this. The minted token covers one package and is short-lived. The job starts only after the release environment approval. A stage-only entry would keep a 2FA promotion between a minted token and the live registry, but the default direct arm gives that up. Every publish carries provenance.

Two ways to narrow it are recorded in the README and not done here:

  • Blank the variable with a step-level env: on the steps that need neither it nor its URL. I didn't measure this on a run, so I didn't ship it.
  • Pack in a job without the permission and publish from one with it.

A known gap in the floor gate

The gate reads the npm on the step's PATH before pnpm install. pnpm 10 runs opts.npmPath if one is set. Under pnpm run, the caller's node_modules/.bin also comes first on PATH. So a caller with an npm-path setting or an npm devDependency would publish with an npm the gate never checked. No caller sampled has either.

Checklist

  • The full pipeline passes locally. This repo's gate is node --test "test/*.test.mjs" (1044 pass, 0 fail) plus actionlint 1.7.12 with .github/actionlint.yaml (clean). The pnpm line does not apply here.
  • Tests cover the change.
  • The change is recorded the way this repo records changes. There is no .changeset/ and no CHANGELOG.md, so there is nothing to record.
  • No PHI anywhere in the diff.
  • Public API changes are documented. The NPM_TOKEN secret is removed from the workflow_call contract, and the README and the workflow header say so.
  • One logical change per PR.

Harness-Task: 107

🤖 Generated with Claude Code

release.yml no longer declares, reads or hands on an npm token. The
publishing job already held id-token: write; the publish step now runs
with no NPM_TOKEN or NODE_AUTH_TOKEN, so changesets/action writes no
~/.npmrc and the publishing tool exchanges the job's OIDC token at npm.
setup-node in that job drops registry-url, since nothing authenticates
with a file any more.

publish-floor.mjs now owes the trusted-publishing floor on every arm,
before anything is packed, and prints the tool that makes the exchange
with its version: npm 11.5.1 and Node 22.14.0 when npm makes it (pnpm
10 hands its tarball to npm publish), pnpm 11.0.0 when pnpm publishes
natively. The staging floors already clear these.

The README says how publishing authenticates, what each package's
trusted-publisher entry must name (its own repository and release.yml,
never cosyte/.github), the owner's one-time steps on npmjs.com, and the
residual: id-token: write is job-wide, so any step of the release job
can mint a publish token while it runs.

test/trusted-publishing.test.mjs pins the workflow half and is shown to
fail on a copy of release.yml with each property broken.

Harness-Task: 107

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread test/publish-floor.test.mjs Fixed
NSchatz and others added 2 commits October 19, 2026 22:46
CodeQL flagged js/incomplete-sanitization on the new direct-arm floor
test: it built a RegExp from a version string and escaped only dots.
A plain substring check says the same thing with nothing to escape.

Harness-Task: 107

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t long

Harness-Task: 107

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NSchatz
NSchatz merged commit d9861c3 into main Oct 20, 2026
9 of 10 checks passed
@NSchatz
NSchatz deleted the harness/107-release-trusted-publishing branch October 20, 2026 15:10
NSchatz added a commit to cosyte/config that referenced this pull request Nov 7, 2026
…OKEN note (#131)

npm runs `prepare` during `npm pack`, including the `npm pack --dry-run
--json` whose stdout the shared attw gate parses. simple-git-hooks
prints its `[INFO]` lines on stdout, so every repo scaffolded from the
template was born with a `pnpm attw` that refused ("did not print
JSON"). The template's `prepare` now runs `simple-git-hooks 1>&2`, as
cosyte/hl7 and cosyte/pathways already do.

test/attw-scaffold.test.ts runs the real scaffolder, plants a stand-in
hook where `pnpm install` puts simple-git-hooks, and runs the gate's
own `npm pack --dry-run --json` with `prepare` firing. It asserts the
hook ran, its line is on stderr and not stdout, and stdout is one JSON
document naming the package. A counterfactual case puts the old
`prepare` back and shows the line in stdout and the parse failing.
Seen red on the unchanged template first, then green, on npm 10, 11
and 12.

The template's release.yml comment and the scaffolder's closing message
both still said to set NPM_TOKEN. Publishing moved to npm trusted
publishing in cosyte/.github#78, so both now name the trusted-publisher
entry and the two secrets the shared workflow reads: RELEASE_PR_TOKEN
and DOCS_REPO_DISPATCH_TOKEN.

No package ships the template or the scaffolder, so no changeset.
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.

2 participants