Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 75 additions & 18 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,49 @@
name: Publish

# Tag-triggered publish to npm. A pushed v* tag starts the run, but the
# publish step is gated on maintainer sign-off through the npm-publish
# environment and authenticates through npm trusted publishing (OIDC).
# Tag-triggered publish to npm, split into a build job and a publish job so no
# credential is ever present while this repository's own code runs. The build
# job checks the release gates, installs, builds, tests and packs a tarball; it
# holds no id-token and no environment, so it runs on the tag push, before
# anyone approves, and its result is what the approver judges. The publish job
# holds the OIDC id-token alone, beside the prebuilt tarball and nothing else.
# Authentication is npm trusted publishing rather than a stored token, so
# publishing needs that publisher registered on npmjs.com against this
# repository, this workflow filename and the npm-publish environment; no
# NPM_TOKEN is involved.
on:
push:
tags: ['v*']

jobs:
publish:
build:
runs-on: ubuntu-latest
# Required-reviewer environment: a pushed tag queues the publish until a
# maintainer approves the run. The job holds no npm credential, so it
# writes no .npmrc auth entry and npm exchanges the id-token below for a
# short-lived one; that same token signs the provenance attestation.
environment: npm-publish
# No id-token and no environment on this job: a lifecycle script anywhere
# in the dependency tree runs here, so there is nothing for it to read.
permissions:
contents: read
Comment thread
robrigo marked this conversation as resolved.
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history, so the tag-on-main gate below can decide ancestry.
fetch-depth: 0

- name: Verify the tag is on main
# Fails a tag pushed on a commit that is not on main, so an approval
# can never cover a commit off the release line. The ref is spelled
# refs/remotes/origin/main because git resolves the ambiguous
# origin/main as refs/tags/origin/main first, so a tag named
# origin/main would otherwise defeat this gate.
run: |
if ! git merge-base --is-ancestor HEAD refs/remotes/origin/main; then
echo "Tag $GITHUB_REF_NAME is not on main" >&2
exit 1
fi

# No registry-url: it writes an .npmrc _authToken line, and npm stops at
# that unresolvable credential instead of falling through to OIDC.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: yarn

- name: Upgrade npm
# Trusted publishing (OIDC) needs npm >= 11.5.1; the Node 22 image
# ships npm 10.x.
run: npm install -g npm@latest

- name: Verify tag matches package version
run: |
version="$(node -p "require('./package.json').version")"
Expand All @@ -50,5 +61,51 @@ jobs:
- name: Test
run: yarn run test

- name: Pack
# prepack runs here, where no credential exists. The publish job ships
# this tarball as packed.
run: npm pack

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: package-tarball
path: '*.tgz'
if-no-files-found: error

publish:
needs: build
runs-on: ubuntu-latest
# Required-reviewer environment: the packed tarball waits here until a
# maintainer approves the run, and npm's trusted publisher pins this
# repository, this workflow file and this environment name, so no other
# workflow can mint a publish credential.
environment: npm-publish
# id-token alone, and no checkout and no dependency install in this job, so
# the credential sits beside a prebuilt tarball and nothing else.
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: package-tarball

# No registry-url: it writes an .npmrc _authToken line, and npm stops at
# that unresolvable credential instead of falling through to OIDC. No
# cache either, because this job has no checkout and so no lockfile to
# hash.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22

- name: Install npm
# Trusted publishing needs npm >= 11.5.1, and the Node 22 image ships
# an older npm. The version is pinned exact so no floating executable
# runs beside the credential.
run: npm install -g --ignore-scripts npm@12.0.2

- name: Publish
run: npm publish --access public --provenance
# The tarball came from the build job, and a tarball publish runs no
# lifecycle scripts; --ignore-scripts holds that whatever npm does
# later. No NODE_AUTH_TOKEN: npm exchanges this job's OIDC token for a
# short-lived credential, which also signs the provenance attestation.
run: npm publish ./*.tgz --access public --provenance --ignore-scripts
16 changes: 10 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ rendered GitHub Release, not at the npm publish.
git tag vX.Y.Z && git push origin vX.Y.Z
```

`.github/workflows/publish.yml` starts and waits on the `npm-publish`
`.github/workflows/publish.yml` starts; its build job runs the release
gates (tag matches version, tag on main), the install, the tests, and
packs the tarball; its publish job waits on the `npm-publish`
environment. Push the tag before creating the Release, because
`gh release create` resolves the tag rather than creating it. The tag is
the release: consumers pin or float on it, so push it only once the entry
Expand All @@ -38,9 +40,11 @@ rendered GitHub Release, not at the npm publish.
flight, create them in ascending version order, so that marker stays
monotonic.

5. Approve the `npm-publish` environment for the tag. With more than one
release waiting, approve in ascending version order, so the npm `latest`
tag stays monotonic.
5. Approve the `npm-publish` environment for the tag once the run is green
through the build gates, the tag-on-main check included, which proves
the tagged commit sits on `main`. With more than one release waiting,
approve in ascending version order, so the npm `latest` tag stays
monotonic.

6. Verify the published version and the rendered Release:

Expand All @@ -55,8 +59,8 @@ The publish job authenticates through npm trusted publishing (OIDC). It holds
no npm token and sets no registry URL on the setup step, so nothing writes an
`.npmrc` auth entry and npm 11.5.1 or later exchanges the job's OIDC identity
for a short-lived credential of its own. The `npm-publish` environment is the
gate on that identity: a pushed tag queues the run until a maintainer approves
it.
gate on that identity: the build job runs immediately on the pushed tag, and
the publish job waits until a maintainer approves it.

`publishConfig.provenance` in `package.json` makes a default local npm publish
fail, because no OIDC identity is available outside CI to satisfy it. It is
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ case "$ORIGIN" in
ssh://git@github.com/*) SLUG="${ORIGIN#ssh://git@github.com/}" ;;
https://github.com/*) SLUG="${ORIGIN#https://github.com/}" ;;
https://*@github.com/*) SLUG="${ORIGIN#https://*@github.com/}" ;;
*) die "the origin remote is not a GitHub URL: $ORIGIN" ;;
*) die "the origin remote is not a GitHub URL" ;;
esac
SLUG="${SLUG%/}"
SLUG="${SLUG%.git}"
Expand Down
Loading