Skip to content

Publish through npm trusted publishing, and ask for the release approval only when there is something to release - #125

Merged
NSchatz merged 2 commits into
mainfrom
harness/108-config-trusted-publishing-approval
Oct 19, 2026
Merged

NSchatz merged 2 commits into
mainfrom
harness/108-config-trusted-publishing-approval

Conversation

@NSchatz

@NSchatz NSchatz commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Harness-Task: 108

What this changes

Two things in config's release workflow, plus the files its own gates tie to it.

1. npm trusted publishing, no npm token

rg -n NPM_TOKEN .github/workflows now prints nothing. The publish job keeps permissions: id-token: write and runs-on: ubuntu-latest, and that grant is now the npm credential.

  • The npm version. changeset publish runs pnpm publish, and pnpm 10.34.5 packs the tarball and then runs the npm CLI on PATH to upload it: runNpm(opts.npmPath, ["publish", "--ignore-scripts", path.basename(tarballPath), ...args]) in its dist/pnpm.cjs (read 2026-10-19). There is no OIDC code in pnpm 10.34.5, so pnpm does not carry trusted publishing itself, and RELEASING.md's claim that it did is retracted. Node 22.14 ships npm 10, so the publish job installs exactly npm 11.21.0 (published 2026-09-30) and fails if a different npm is first on PATH. npm's page (https://docs.npmjs.com/trusted-publishers, read 2026-10-19, last edited 2026-10-06) requires "npm CLI version 11.5.1 or later and Node version 22.14.0 or higher" and GitHub-hosted runners. npm 12 needs Node ^22.22.2, so it does not fit this Node.
  • How npm 11.21.0 uses OIDC. lib/utils/oidc.js requests the ID token through ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, exchanges it per package, and sets the result at npm's cli config layer only, so nothing is written to disk.
  • No token slot. The publish job's setup-node takes no registry-url. With one, setup-node v6 generates an npmrc with _authToken=${NODE_AUTH_TOKEN} and exports a placeholder value, which would be sent whenever the exchange fails. setup-node v7 stops exporting it, which would leave Gate 3 an unresolvable reference to refuse.
  • changesets/action at the pinned sha (v1.9.0, 2026-06-03) writes no .npmrc when NPM_TOKEN is absent and the OIDC variables are present (src/index.ts).
  • Gate 3 still passes. I ran scripts/npm-config-allow.mjs with npm 11.21.0 in the publish job's context (no generated npmrc, NPM_CONFIG_PROVENANCE=true): OK, 23 settings, registry requirement holds.

The credential declaration replaces NPM_TOKEN with GITHUB_OIDC_ID_TOKEN, using a new runner exposure route. That route counts as present only when the job's own permissions: grants the named permission (grantedBy: "id-token: write"). credential-surface.mjs now reports environment-missing for any ungated job that grants id-token: write. The publish preflight (pnpm run release) requires the two OIDC variables, so a job without the grant is refused before the build.

2. The gated job runs only when there is something to release

Before this, publish ran on every push without pending changesets, so release asked for an approval on every ordinary push. Now preflight asks the public registry, with no credential, whether each package's current version is on npm (registry-presence.mjs pending, one probe per package). The condition:

if: ${{ needs.preflight.outputs.has-changesets != 'true' && (needs.preflight.outputs.is-release == 'true' || needs.preflight.outputs.has-unpublished != 'false') }}
push has-changesets is-release has-unpublished result
carries a pending changeset true any not asked version runs, publish skipped, no deployment request
version commit false true any publish runs and asks for approval (a re-run still tags and releases)
a version npm lacks false false true publish runs and asks for approval
registry unreadable false false unknown publish runs and asks for approval
the step did not report false false (empty) publish runs and asks for approval
ordinary push, every version on npm false false false only preflight runs; no deployment request

The only answer that skips the job is "every version is already on the registry". That is the same check changeset publish makes before publishing each package, so a skipped job would have published nothing. Every other answer keeps the job in.

Shown by test/release-arm-condition.test.ts. act is not available here, so the test reads both jobs' if: lines and the preflight step's if: from the committed release.yml, using the YAML reader the credential-surface gate uses. It evaluates them on every row above with GitHub's semantics: an unset output is '' and comparison ignores case. The evaluator throws on any syntax it does not implement. Against the old condition (has-changesets != 'true' alone) the suite fails exactly the ordinary-push rows. The pending subcommand has its own cases in test/registry-presence.test.ts, run against the suite's loopback registry with the real npm view. Against the live registry today, pending answers false: all 8 current versions are published and there are no pending changesets, so after this merges an ordinary push to main asks for nothing.

The owner's step

Each package can publish this way only after its trusted publisher is registered. That is the owner's step, at the first release that needs it. RELEASING.md, "npm trusted publishing: the owner's step, per package", has the npm trust github ... --repo cosyte/config --file release.yml --env release --allow-publish loop. Until a package has one, its publish fails at the registry with an authentication error and publishes nothing, and the accounting step names it. This PR releases nothing: it adds no changeset and changes nothing under packages/.

Not done here

  • The run waiting since 2026-09-25 is left to lapse at 30 days.
  • Moving config onto the organization's shared release workflow would not be simpler. The header of release.yml records the two blockers, which this change does not touch: the shared gate reads the private root manifest's version, which never changes here, and it tags v<version>, while this repository publishes eight packages tagged <pkg>@<version>.

Checks run locally

changeset-guard, release-notes prepare, credential-surface, install-hardening, readme-check, drift-check, check:no-emdash, format:check, typecheck, lint, build, attw, actionlint 1.7.7: all clean. Root suite: 1074 passed.

Two failures in packages/test-utils come from this container, not this change. The 1 MiB JSON case hit ENOSPC on the 3 GB RAM-backed tmp and passes with TMPDIR on disk. The pax-header case also fails on an untouched origin/main checkout here.

🤖 Generated with Claude Code

…val only when there is something to release

The release workflow no longer reads an npm token. The publish job's
`id-token: write` is the npm credential: npm exchanges the runner's OIDC
ID token for a short-lived publish token per package, checked against
each package's trusted publisher (cosyte/config, release.yml, release).

- pnpm 10.34.5 hands the upload to the npm CLI on PATH, and Node 22.14
  ships npm 10, so the publish job installs exactly npm 11.21.0 and reds
  if another npm is first on PATH. npm 12 needs Node 22.22.2.
- The publish job's setup-node takes no registry-url, so no npmrc with a
  token slot is generated. changesets/action v1.9.0 (the pinned sha)
  writes no .npmrc when it has no token and OIDC is available.
- The credential declaration replaces NPM_TOKEN with
  GITHUB_OIDC_ID_TOKEN, exposed through a new `runner` route that is
  satisfied by the job's permission grant. credential-surface.mjs
  refuses `id-token: write` on an ungated job, and the publish preflight
  refuses before the build when the OIDC variables are missing.

The publish job used to start on every push to main without pending
changesets, so the release environment asked for an approval on every
ordinary push. It now starts only for a version commit, or when the
registry does not report every package's current version as present
(registry-presence.mjs `pending`, asked in the ungated preflight). The
only skipping answer is the state in which `changeset publish` would
publish nothing; an unreadable registry keeps the job in.
test/release-arm-condition.test.ts evaluates both jobs' committed `if:`
lines on every path.

RELEASING.md retracts its claim that pnpm carries trusted publishing,
and gives the owner's `npm trust github` step per package, which each
package needs before its first trusted publish.

Harness-Task: 108

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread scripts/registry-presence.mjs Fixed
CodeQL flagged the stat-then-read in workspaceVersions as a file system
race. The manifest is now read directly; a missing one (ENOENT, or
ENOTDIR for a stray file under packages/) is skipped, and any other read
or parse failure refuses as before.

Harness-Task: 108

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NSchatz
NSchatz merged commit 8fc7faf into main Oct 19, 2026
7 checks passed
@NSchatz
NSchatz deleted the harness/108-config-trusted-publishing-approval branch October 19, 2026 23:23
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