Skip to content

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

Open
mike-ainsel wants to merge 12 commits into
v4-betafrom
MILAB-7081_npm-trusted-publishing
Open

mike-ainsel wants to merge 12 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 is retiring publishing with 2FA-bypass tokens: GitHub expects bypass-2FA granular tokens to lose direct publish around January 2027 (changelog). That is how every node-simple-pnpm caller publishes today (NPMJS_TOKEN). The replacement is npm trusted publishing (OIDC), but npm accepts OIDC only from GitHub-hosted runners: "Self-hosted runners are not currently supported". 57 of the 123 callers build on hz-ubuntu-dind.

This PR splits the publish so the build stays where it is and only the registry upload moves to a GitHub-hosted runner. It is opt-in: with the new input left at its default, nothing changes for any caller.

Changes

  • node-simple-pnpm.yaml: new inputs npm-trusted-publishing (default false) and npm-token-fallback (default true). When npm-trusted-publishing is on:
    • build-test-publish (still on gha-runner-label) runs actions/node/pack-unpublished instead of changesets/action + pnpm -r publish. The tarballs go to an artifact.
    • New job npm-publish on ubuntu-latest (id-token: write, contents: write) uploads them with actions/node/publish-tarballs and creates the release tag. The tag step moves here so a tag only appears after the upload.
    • notify-slack-release also waits for npm-publish.
  • actions/node/pack-unpublished: for each non-private workspace package whose version is not in its registry, in topological order: pnpm run --if-present prepublishOnly, then pnpm pack. prepublishOnly has to run here because blocks use it for block-tools publish (S3 block registry) and software packages for pl-pkg prepublish. pnpm pack does not run it. Packages with publish/postpublish scripts fail the step, since those would have to run after the upload on another runner.
  • actions/node/publish-tarballs: Node 24 + npm ^11.5.1, npm publish <tgz> --ignore-scripts in manifest order. It passes --tag/--access from the manifest, because npm publish <tarball> ignores publishConfig.tag inside the tarball (verified locally: a next package went out as latest). Versions already in the registry are skipped, so the job is safe to re-run. registry.npmjs.org authenticates through OIDC; other registries (GitHub Packages) through GITHUB_TOKEN.
  • npm-token-fallback: also hands NPMJS_TOKEN to the upload. npm tries OIDC first and falls back to the token, so a repo can switch on before every one of its packages has a trusted publisher. Set it to false to prove a package publishes through OIDC alone.
  • scripts/npm-trust-setup.sh: discover lists every non-private package of every repo whose build.yaml calls a github-ci node workflow (today: 433 packages in 123 repos). apply <file> runs npm trust github <pkg> --repo <repo> --file build.yaml --allow-publish for each one. It skips packages that are already configured or not on the registry yet, and reports packages configured for something else instead of changing them. It needs npm >= 11.15 and an interactive npm login with 2FA; npm does not allow this with bypass tokens.

Verified

Canary repo platforma-open/npm-trusted-publishing-test: two packages, one depending on the other through workspace:*, calling this branch from hz-ubuntu-dind.

  • 0.0.1: published through the token fallback (run 38394168936). This created the packages so trusted publishers could be configured.
  • 0.0.2: npm-token-fallback: false (run 38396502404). The registry records _npmUser: "GitHub Actions" <npm-oidc-no-reply@github.com> with trustedPublisher: github, plus SLSA provenance naming .github/workflows/build.yaml and a GitHub-hosted builder. Each tarball contains prepublishOnly ran on hz-ubuntu-dind-… (self-hosted). The dependent package carries "…lib": "0.0.2", i.e. workspace:* was resolved.

To check any version yourself: npm view <pkg>@<version> _npmUser and npm audit signatures. npm prints its "tokens that bypass 2FA" notice even on OIDC publishes, so that message does not tell you which auth was used.

Things a reviewer should know

  • The trusted publisher names the caller's workflow file (build.yaml in each repo), not node-simple-pnpm.yaml. npm documents this, and the canary's provenance confirms it.
  • "Allowed actions" must include npm publish when configuring on npmjs.com. Otherwise only staged publish is allowed. The script passes --allow-publish.
  • A new trusted publisher expires if no publish goes through it within 2 days. Once the first publish succeeds, it stays. The rollout therefore configures a batch and then releases that batch.
  • New packages take a few minutes to appear on the registry after the upload ("being processed"). This only matters for brand-new packages.

Not in this PR

  • Switching callers on: npm-trusted-publishing: true per repo, in batches, together with npm-trust-setup.sh apply for those repos.
  • Publishers outside node-simple-pnpm: node-matrix*, core/platforma, pframes-rs, the desktop app.
  • The canary repo currently calls this branch; it moves to @v4-beta once this merges.

MILAB-7081

RetriggerConfidence Score: 1/5

Fix the registry token exposure, GitHub Packages permissions, and incomplete trusted-publisher checks before merging.

Fix All in Claude CodeFindings

  1. P1 GitHub Packages uploads fail ▶
  2. P1 Security Other registries receive GitHub credentials ▶
  3. P1 Setup skips incomplete permissions ▶
  4. P2 Unreadable settings look empty ▶
Fix with agent prompt
### Issue 1
.github/workflows/node-simple-pnpm.yaml:1212-1214
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
```

### Issue 2
actions/node/publish-tarballs/action.yaml:97-98
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.

### Issue 3
scripts/npm-trust-setup.sh:98-102
`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.

### Issue 4
scripts/npm-trust-setup.sh:97
`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.

Summary

Adds opt-in npm trusted publishing while keeping builds and package preparation on the caller's runner.

  • Node releases can publish with npm OIDC from a GitHub-hosted runner.
  • Operators can find packages and set up their npm trusted publishers.

Fix GitHub Packages permissions, restrict where the GitHub token is sent, and check existing publishers' allowed actions before merging. The setup script should also report unreadable JSON instead of treating it as empty.

Important touched terms:

  • npm-trusted-publishing: A boolean input selecting the new split publish path; defaults to off.
  • npm-token-fallback: A boolean input allowing NPMJS_TOKEN alongside OIDC; defaults to on.
  • pack-unpublished: A new action that runs prepublishOnly, packs packages, and writes upload metadata.
  • manifest.jsonl: The new file listing package names, versions, tarballs, registries, tags, and access settings.
  • publish-tarballs: A new action that uploads packages in manifest order without running package scripts.
  • npm-publish: The new GitHub-hosted job that uploads packages and creates the release.
  • npm-trust-setup.sh: The new script for discovering candidate packages and configuring trusted publishers.

Intentional choices acknowledged by mike-ainsel: discovery lists all non-private package files for later trimming; packages with publish or postpublish hooks fail because those hooks cannot run correctly across the split.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Caller enables trusted publishing] --> B[Build and test on caller's runner]
  B --> C[Run prepublishOnly and pack unpublished packages]
  C --> D{Packages packed?}
  D -->|Yes| E[Upload tarballs and manifest]
  E --> F[Download on ubuntu-latest]
  F --> G[Publish through OIDC or token]
  D -->|No| H[Create release if requested]
  G --> H
  H --> I[Slack waits for upload job]
Loading

Reviews (1) · Last reviewed commit: "MILAB-7081: revert canary refs back to @..." · Reviewed by Greptile

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.
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