ci: publish npm packages over OIDC instead of a static token - #38188
Draft
jasonhernandez wants to merge 2 commits into
Draft
ci: publish npm packages over OIDC instead of a static token#38188jasonhernandez wants to merge 2 commits into
jasonhernandez wants to merge 2 commits into
Conversation
npm's trusted publishing accepts OIDC tokens from GitHub Actions, GitLab,
and CircleCI, but not from Buildkite. Rather than move the release out of
Buildkite, the deploy-npm step now packs the tarballs, stages them in S3,
and hands off to a small GitHub Actions workflow that performs the publish
with a short-lived OIDC credential. The step waits on the workflow and
fails if it fails, so the pipeline still reports the state of the release.
This removes the only consumer of NPM_TOKEN, which is currently injected
into the environment of every build on the high-risk agent queues.
Provenance is explicitly disabled: it is generated by default when
publishing over OIDC, but it would attest that the workflow built tarballs
that Buildkite actually built.
Before this can be enabled:
* configure a trusted publisher for each of @materializeinc/sql-lexer,
sql-parser, and sql-pretty, naming this repository, the workflow file
publish-npm.yml, and the npm-publish environment
* create the npm-publish environment and set the NPM_PUBLISH_ROLE_ARN
repository variable to the GithubActionsNpmPublish role
* decide how the latest-X.Y dist-tags get applied, since OIDC covers
npm publish but not npm dist-tag
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TXb9w2VhQmDQz3F5YDfcx
Two fixes toward needing no npm token at all: setup-node's registry-url writes an `_authToken` line into .npmrc, and an empty token there convinces npm it is already authenticated, so it never performs the OIDC exchange and the publish fails with ENEEDAUTH. Drop registry-url -- the default registry is the one we publish to anyway -- and fail loudly if anything else configures an auth token. `npm dist-tag` never performs the OIDC exchange itself, only `npm publish` and `npm stage publish` do, so the `latest-X.Y` tags would have needed a long-lived token. Perform the exchange by hand instead and pass the resulting short-lived, package-scoped token to the CLI. The publish step now skips versions that are already on npm, which makes the workflow re-runnable from the same staging URI. That is the remedy if the dist-tag step fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TXb9w2VhQmDQz3F5YDfcx
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.
Motivation
NPM_TOKENis a long-lived npm write token injected into the environment of every build on the high-risk Buildkite agent queues. Since npm capped granular access tokens at 90 days, it also has to be rotated on a timer, which is the cost we actually want to eliminate.npm's trusted publishing replaces tokens with short-lived OIDC credentials, but it accepts them from GitHub Actions, GitLab, and CircleCI only — not from Buildkite.
Companion PR in MaterializeInc/i2 provisions the staging bucket and the IAM role.
Description
Rather than move the release off Buildkite, this keeps Buildkite as the pipeline and moves only the
npm publishcall:deploy-npmbuilds the wasm packages as before, thennpm packs them and writes amanifest.jsondescribing what to publish and with which dist-tags.repository_dispatch..github/workflows/publish-npm.ymlassumes an AWS role via GitHub OIDC, downloads the tarballs, and publishes each one with an npm OIDC credential.No npm token anywhere in the path — including for the
latest-X.Ydist-tags, which is the part that took some doing. The npm CLI only performs the OIDC exchange fromnpm publishandnpm stage publish;npm dist-tagnever does, so it would ordinarily need a token. The workflow therefore performs the exchange by hand —POST /-/npm/v1/oidc/token/exchange/package/<pkg>with the Actions ID token, exactly as the CLI does internally — and passes the resulting short-lived, package-scoped registry token tonpm dist-tag add.This is the one thing that needs an empirical check. npm documents the exchanged token as covering publish, and the trusted-publisher UI's "allowed actions" list offers only
npm publishandnpm stage publish, so the registry may reject a dist-tag write with it. The CLI does use the same token for a non-publish endpoint (libaccess.getVisibility), which is why this is worth trying. Oneworkflow_dispatchrun against adevversion settles it. If the registry says no, the fallback that still gets us to zero tokens is to retire thelatest-X.Ytags in favor of~X.Y.0ranges in consumers —~26.37.0resolves to the newest26.37.x, which is what the tag means.Other non-obvious decisions:
registry-urlonsetup-node. It writes an_authTokenline into.npmrc, and an empty token there convinces npm it is already authenticated so it never attempts OIDC, failing withENEEDAUTH(actions/setup-node#1551). A guard step fails the job if anything else configures one.repository_dispatch, not a tag trigger, because thedevchannel publishes a prerelease on every main build. Dispatched workflows always run the definition from the default branch, so the payload can't run workflow code from another ref.Worth being explicit about the security boundary: this retires the long-lived token, but the published bytes still come off a Buildkite agent, and S3-write plus dispatch from that environment are jointly equivalent to publish rights. The hardening win of building where you publish is not part of this change.
Required setup before merging
@materializeinc/sql-lexer,sql-parser, andsql-pretty: this repository, workflow filenamepublish-npm.yml, environmentnpm-publish. npm does not validate any of this at save time — mistakes surface asENEEDAUTHat publish.npm-publishenvironment and set theNPM_PUBLISH_ROLE_ARNrepository variable to theGithubActionsNpmPublishrole ARN from the i2 PR.NPM_STAGING_BUCKETreaches the agents.Once a release has gone out through this path: set each package's publishing access to "require 2FA and disallow tokens", revoke the automation token, and drop
npm_tokenfrom i2. At that point there is no npm credential left to rotate.Verification
Not yet verified end to end — the handoff can't run until the trusted publishers and the AWS role exist, which is why this is a draft. Locally:
bin/pyactivate -m ci.deploy.npm --no-releasestill exercises the build path unchanged, and the publish path is guarded byversion_exists_in_npm, so a retried step re-publishes nothing.The first real test should be a
workflow_dispatchrun against adevbuild, which exercises the publish path but not the dist-tag path (dev versions carry nolatest-X.Y). A release build is what settles the dist-tag question above.