Skip to content

MILAB-7081: npm trusted publishing (OIDC) for node-simple-pnpm - #217

Merged
mike-ainsel merged 13 commits into
v4-betafrom
MILAB-7081_npm-trusted-publishing
Oct 19, 2026
Merged

mike-ainsel merged 13 commits into
v4-betafrom
MILAB-7081_npm-trusted-publishing

Conversation

@mike-ainsel

@mike-ainsel mike-ainsel commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

npm no longer accepts 2FA-bypass tokens for direct publish (GitHub changelog), so node-simple-pnpm callers can no longer publish with NPMJS_TOKEN. The replacement is npm trusted publishing (OIDC), which npm accepts only from GitHub-hosted runners ("Self-hosted runners are not currently supported"), while 57 of the 123 callers build on hz-ubuntu-dind.

This PR splits the publish: the build job keeps running on the caller's runner and does everything pnpm -r publish did except the upload; a new job on ubuntu-latest uploads the tarballs with trusted publishing. It is the only publish path now (no opt-in flag).

Changes

  • node-simple-pnpm.yaml
    • build-test-publish: the changesets/action + pnpm -r publish steps and the tag step are replaced by actions/node/pack-unpublished, which uploads tarballs and a manifest as artifact npm-tarballs-<app-name-slug>.
    • New job npm-publish on ubuntu-latest (id-token, contents, packages write): masks and passes NPMJS_TOKEN only when npm-token-fallback is on, runs actions/node/publish-tarballs, then creates the release tag. notify-slack-release waits for it.
    • Input npm-token-fallback (default true): npm tries OIDC first and uses the token only if the exchange fails.
  • actions/node/npm-split-publish (TypeScript, built into committed lib/; the two composite actions are thin wrappers):
    • pack: for every unpublished non-private package, in dependency order, runs prepublishOnly, prepublish and pnpm pack. Every package is checked before the first script runs (refuses publish/postpublish scripts). For npmjs uploads from public repos it fills a missing package.json repository into the packed copy, because npm auto-enables provenance and the registry rejects it (E422) otherwise.
    • publish: npm publish <tarball> --ignore-scripts, always with --tag (publishConfig.tag, else latest, as npm 10 did on the old path).
    • The upload job does not trust the artifact: validated manifest (ManifestEntry), tarball manifests read the way npm reads them (node-tar with pacote's options) and matched against the manifest, dangerous packed publishConfig keys refused, --flag=value arguments, npm run from an empty directory, GITHUB_TOKEN sent only to npm.pkg.github.com, https registries only.
  • scripts/npm-trust-setup.sh: discover lists the packages of caller repos; apply creates trusted publishers (npm trust github … --allow-publish) with interactive 2FA, skipping configured and unpublished packages.
  • 0-test-npm-publish-split.yaml: vitest (unit, real-pnpm integration on pnpm 9.12 and 10, real-npm dry run, tarball reads compared with npm's), lib/ freshness check, bats for the setup script.

Behaviour, trade-offs and recovery from a failed upload ("Re-run failed jobs") are in actions/node/npm-split-publish/README.md.

Verified

Canary repo platforma-open/npm-trusted-publishing-test, two packages, hz-ubuntu-dind, versions 0.0.1–0.0.9. With the token fallback off, the registry records _npmUser: "GitHub Actions" with trustedPublisher: github and SLSA provenance naming the caller's build.yaml. 0.0.5 reproduced the provenance E422 for a package without repository and published after the fix; 0.0.6 confirmed the fallback token is masked; 0.0.9 ran without the former opt-in input. Two independent reviews were addressed in this branch.

To check a published version: npm view <pkg>@<version> _npmUser and npm audit signatures.

Rollout notes

  • Every package a caller releases needs a trusted publisher naming the caller's repository and build.yaml, with "npm publish" allowed. A new trusted publisher expires unless a publish goes through it within 2 days, so configure and release in batches.
  • Not covered here: node-matrix*, java-gradle, node-simple, github-ci-internal's node-go-simple, and repos with their own publish workflows (platforma, pframes-rs, visualizations).

MILAB-7081

npm trusted publishing rejects self-hosted runners, and bypass-2FA tokens
lose direct publish around January 2027.

New opt-in input `npm-trusted-publishing` on node-simple-pnpm.yaml:
- build-test-publish (gha-runner-label) runs prepublishOnly and packs every
  unpublished package in topological order (actions/node/pack-unpublished);
- new npm-publish job on ubuntu-latest uploads the tarballs via OIDC
  (actions/node/publish-tarballs) and creates the release tag.

`npm-token-fallback` (default true) keeps NPMJS_TOKEN as a fallback while
trusted publishers are configured package by package. Default behaviour is
unchanged.
…lishers

discover lists every non-private workspace package of repos whose
build.yaml calls a github-ci node workflow; apply runs npm trust github
for each, skipping configured and unpublished packages. Needs an
interactive npm login with 2FA.
@notion-workspace

Copy link
Copy Markdown

Comment on lines +1212 to +1214
permissions:
id-token: write
contents: write

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 GitHub Packages uploads fail

When trusted publishing is enabled for a workspace that also publishes to GitHub Packages, npm-publish uses GITHUB_TOKEN without packages: write. The old build job had that permission. GitHub Packages uploads will be rejected, stopping the release. Add packages: write to the new job.

Suggested change
permissions:
id-token: write
contents: write
permissions:
id-token: write
contents: write
packages: write
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/node-simple-pnpm.yaml
Line: 1212-1214

Comment:
**GitHub Packages uploads fail**

When trusted publishing is enabled for a workspace that also publishes to GitHub Packages, `npm-publish` uses `GITHUB_TOKEN` without `packages: write`. The old build job had that permission. GitHub Packages uploads will be rejected, stopping the release. Add `packages: write` to the new job.

```suggestion
    permissions:
      id-token: write
      contents: write
      packages: write
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines +97 to +98
else
echo "//${host}/:_authToken=\${NPM_AUTH_TOKEN}" >> "${NPM_CONFIG_USERCONFIG}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Other registries receive GitHub credentials

Every registry other than registry.npmjs.org receives NPM_AUTH_TOKEN, which this workflow sets to a GitHub token with contents: write. If a package targets another private registry, the uploader sends that registry a repository write token, even when that host was never configured to receive it. The old create-npmrc path attached tokens only to configured hosts.

Restrict the GitHub token to GitHub Packages and require separate credentials for other registries.

How this was verified: The manifest takes the package's registry URL, and the uploader binds the repository write token to that URL before calling npm.

Prompt To Fix With AI
This is a comment left during a code review.
Path: actions/node/publish-tarballs/action.yaml
Line: 97-98

Comment:
**Other registries receive GitHub credentials**

Every registry other than `registry.npmjs.org` receives `NPM_AUTH_TOKEN`, which this workflow sets to a GitHub token with `contents: write`. If a package targets another private registry, the uploader sends that registry a repository write token, even when that host was never configured to receive it. The old `create-npmrc` path attached tokens only to configured hosts.

Restrict the GitHub token to GitHub Packages and require separate credentials for other registries.

**How this was verified:** The manifest takes the package's registry URL, and the uploader binds the repository write token to that URL before calling npm.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines +98 to +102
if jq -e --arg r "${repo}" --arg f "${file}" \
'any(.[]; .type == "github" and .repository == $r and .file == $f)' <<<"${existing}" >/dev/null; then
echo "OK ${pkg}: already trusts ${repo} ${file}"
skipped=$((skipped + 1))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Setup skips incomplete permissions

apply checks the repository and workflow but not whether direct publishing is allowed. If an existing trusted publisher allows only staged publishing, the script reports OK and skips it. This workflow uses npm publish, so releases fail when token fallback is disabled.

Check the allowed actions too. Report a conflict when direct publishing is disabled rather than claiming the package is ready.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/npm-trust-setup.sh
Line: 98-102

Comment:
**Setup skips incomplete permissions**

`apply` checks the repository and workflow but not whether direct publishing is allowed. If an existing trusted publisher allows only staged publishing, the script reports `OK` and skips it. This workflow uses `npm publish`, so releases fail when token fallback is disabled.

Check the allowed actions too. Report a conflict when direct publishing is disabled rather than claiming the package is ready.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment thread scripts/npm-trust-setup.sh Outdated
exit 1
fi
fi
existing="$(jq -s -c '.' <<<"${raw}" 2>/dev/null)" || existing='[]'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unreadable settings look empty

apply merges stderr into the JSON from npm trust list, then treats a parse failure as an empty configuration. If npm prints a warning or an unexpected response, the script attempts creation instead of reporting that it could not read the existing settings.

Keep stderr separate and stop with a clear error when the JSON cannot be read.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/npm-trust-setup.sh
Line: 97

Comment:
**Unreadable settings look empty**

`apply` merges stderr into the JSON from `npm trust list`, then treats a parse failure as an empty configuration. If npm prints a warning or an unexpected response, the script attempts creation instead of reporting that it could not read the existing settings.

Keep stderr separate and stop with a clear error when the JSON cannot be read.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@DenKoren DenKoren Oct 13, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you create separate action for this operation, make it runnable and testable locally.
You can easily extract shell scripts you run here into two files, make them executable and write tests for them.
Having an inlined script, that generates another script and runs it in a loop is crazy. It is hard to digest and very easy to fail.

nit: I'd preferred writing TS-based actions instead of shell for such complex things, but bash is OK too. I just feel TS is easier to test and harder to fail in an unpredictable/unexpected way.

Like problem found by greptile above: when apply merges stderr into the JSON from npm trust list and JQ parsing error then looks like 'no config'

@DenKoren DenKoren Oct 13, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same comment as for above: this script rely on contract with pack-unpublished, that is visible here (as both actions are in same PR) but is hard to understand when you look only at this action. I'd extract this script into a separate sh file, add test manifest.json and write tests that use this file to check everything works. We can use bats for that.

(nit: that is why I'd prefer TS for such things: it provides a standard way to bring all tools you need)

- pack-unpublished.sh / publish-tarballs.sh replace the inline scripts; the
  pack script calls itself per package via pnpm exec instead of generating
  a script. The manifest format is documented in pack-unpublished.sh.
- bats suites for both scripts and for npm-trust-setup.sh apply, with
  fixtures and npm stubs; 0-test-npm-publish-split.yaml runs them with
  pnpm 9.12 and the repo's packageManager.
- npm-publish job: add packages: write for GitHub Packages uploads.
- publish-tarballs: GITHUB_TOKEN goes to npm.pkg.github.com only; any other
  non-npmjs registry fails before the first upload.
- npm-trust-setup apply: require the createPackage (npm publish) permission,
  keep npm stderr out of the JSON, stop on unreadable output, and read the
  mapping from fd 3 so npm's 2FA prompt keeps stdin.
npm attaches a token by host regardless of scheme, so an http:// registry in
the manifest would receive GITHUB_TOKEN or the npmjs token in clear text.
Review follow-up: the two composite actions now run one TS package,
actions/node/npm-split-publish, instead of shell scripts.

- ManifestEntry (src/manifest.ts) is the typed, validated contract between
  pack and publish.
- pack orders packages itself (all dependency fields, as pnpm does; cycles
  are broken and reported) and checks every package before the first
  prepublishOnly runs, so a bad package fails without side effects.
- publish parses registry URLs with URL: https only, no credentials or port
  in the URL, GITHUB_TOKEN for npm.pkg.github.com only.
- vitest: unit tests with a fake command runner, integration tests with real
  pnpm on the fixture workspace (CI: pnpm 9.12 and the repo's pnpm).
- lib/ is committed like the other TS actions; the test workflow fails when
  it does not match a fresh build.

npm-trust-setup.sh stays bash with its bats suite.
…d job

The manifest and tarballs reach the GitHub-hosted job as an artifact.
- Validate every value (package name, semver version, .tgz basename, https
  registry, dist-tag, access) so none can be read by npm as an option; the
  pack side validates on write, so bad values fail in the build job.
- Pass values as --flag=value.
- Run npm from an empty temp directory: npm reads a project .npmrc from its
  working directory, and the artifact directory could carry one.
- Provenance: under trusted publishing npm turns provenance on for public
  packages and the registry rejects (E422) a package.json whose
  repository.url does not name the building repository; most of ours have
  none. pack fills a missing `repository` into the packed copy and fails
  early on a different repository. Reproduced on the canary (0.0.5).
- Dist-tag: always pass --tag (publishConfig.tag, else latest). npm 11
  refuses prereleases and versions below the highest one without it; npm 10
  on the old path tagged them latest. Covered by a real-npm dry-run test.
- Private packages without a version no longer fail pack.
- The upload job checks each tarball's package.json against its manifest
  entry before the first upload.
- The upload job no longer loads all caller secrets (actions/env); it gets
  NPMJS_TOKEN only, and only with npm-token-fallback.
- Artifact named per call (app-name-slug); npm pinned to 11.21.0; pack step
  gets GITHUB_TOKEN as the old publish step did.
- README: provenance, tag policy, artifact trust, partial-release recovery.
- Fallback token: extracted from the caller's env secret in its own step and
  masked (::add-mask::) before any step gets it as an input; GitHub masks the
  JSON secret as a whole, not values taken out of it.
- Run `prepublish` after `prepublishOnly`, as pnpm publish does.
- Checks (unsupported scripts, repository) run only on packages this release
  uploads; repository handling applies only to npmjs targets from public
  repositories, and the packed tarball is checked to carry it.
- Tarball check: reject entries outside package/ and duplicate
  package.json (npm strips the top directory; last one wins), handle pax and
  long-name headers.
- Refuse packed publishConfig keys that npm would apply for auth, proxy or
  scope registries, and a registry other than the entry's; correct the
  manifest comment that said npm ignores the tarball's publishConfig.
- Re-run of a restricted package without a token: treat npm's duplicate
  refusal as already published.
- Test workflow also runs on node-simple-pnpm.yaml changes; README documents
  the latest-tag trade-off.
…d unusual headers

npm's unpacker normalizes paths (package/./package.json is
package/package.json), so a second manifest spelled differently could pass
the identity check. Paths are normalized before comparison; '..', links and
other special entries, pax size records and non-octal sizes are refused.
Replace the hand-written tar reader with node-tar 7.5.22 extraction using
pacote's options (strip 1, no links), as npm 11.21 reads a tarball before
publishing. A custom parser can always disagree with npm on some edge case;
tests now compare our result with npm's own (npm pack --dry-run --json) for
duplicate manifests, odd path spellings, '..', links and missing manifests.
npm no longer accepts 2FA-bypass tokens for direct publish, so the
token-based path (changesets/action + pnpm -r publish on the build runner)
cannot publish anymore and an opt-in flag has no use. Remove the
`npm-trusted-publishing` input and the old publish and tag steps: every
node-simple-pnpm caller now packs on its runner and uploads from the
GitHub-hosted `npm-publish` job with trusted publishing.

`npm-token-fallback` stays (default true): npm tries OIDC first, so the
token only matters while it can still publish.
@mike-ainsel mike-ainsel changed the title MILAB-7081: npm trusted publishing (OIDC) for node-simple-pnpm, opt-in MILAB-7081: npm trusted publishing (OIDC) for node-simple-pnpm Oct 19, 2026
@mike-ainsel
mike-ainsel merged commit ae981ad into v4-beta Oct 19, 2026
1 check passed
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