Repository navigation
Publish through npm trusted publishing, and ask for the release approval only when there is something to release - #125
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/workflowsnow prints nothing. Thepublishjob keepspermissions: id-token: writeandruns-on: ubuntu-latest, and that grant is now the npm credential.changeset publishrunspnpm publish, and pnpm 10.34.5 packs the tarball and then runs thenpmCLI on PATH to upload it:runNpm(opts.npmPath, ["publish", "--ignore-scripts", path.basename(tarballPath), ...args])in itsdist/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.lib/utils/oidc.jsrequests the ID token throughACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKEN, exchanges it per package, and sets the result at npm'scliconfig layer only, so nothing is written to disk.setup-nodetakes noregistry-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..npmrcwhenNPM_TOKENis absent and the OIDC variables are present (src/index.ts).scripts/npm-config-allow.mjswith 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_TOKENwithGITHUB_OIDC_ID_TOKEN, using a newrunnerexposure route. That route counts as present only when the job's ownpermissions:grants the named permission (grantedBy: "id-token: write").credential-surface.mjsnow reportsenvironment-missingfor any ungated job that grantsid-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,
publishran on every push without pending changesets, soreleaseasked 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:versionruns,publishskipped, no deployment requestpublishruns and asks for approval (a re-run still tags and releases)publishruns and asks for approvalpublishruns and asks for approvalpublishruns and asks for approvalpreflightruns; no deployment requestThe only answer that skips the job is "every version is already on the registry". That is the same check
changeset publishmakes 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.actis not available here, so the test reads both jobs'if:lines and the preflight step'sif:from the committedrelease.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. Thependingsubcommand has its own cases intest/registry-presence.test.ts, run against the suite's loopback registry with the realnpm view. Against the live registry today,pendinganswersfalse: 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-publishloop. 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 underpackages/.Not done here
release.ymlrecords 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 tagsv<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,actionlint1.7.7: all clean. Root suite: 1074 passed.Two failures in
packages/test-utilscome 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 untouchedorigin/maincheckout here.🤖 Generated with Claude Code