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
102 changes: 77 additions & 25 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, authenticated by npm's trusted publisher rather
# than a stored token. It runs only on a pushed v* tag, and only once a
# maintainer approves the npm-publish environment. Publishing therefore needs
# the trusted publisher registered on npmjs.com against this repository, this
# workflow filename and that environment; no NPM_TOKEN is involved.
# 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's trusted publisher 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, and npm's trusted publisher config pins to
# this environment name so no other workflow can mint a publish token.
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
id-token: write
steps:
Comment thread
robrigo marked this conversation as resolved.
- uses: actions/checkout@v4
- 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

- uses: actions/setup-node@v4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
registry-url: https://registry.npmjs.org
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,10 +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
# No NODE_AUTH_TOKEN: npm mints a short-lived credential from the OIDC
# token this job requests, against the trusted publisher npmjs.com pins
# to this repo, workflow and environment. Supplying a token instead
# takes the classic auth path, which demands an interactive OTP and so
# can never complete unattended.
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
19 changes: 17 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ How a version of this package reaches npm and GitHub. A release ends at a render
git tag vX.Y.Z && git push origin vX.Y.Z
```

`publish.yml` starts and waits on the `npm-publish` environment. Push the tag before you create the Release, because `--target <short sha>` fails.
`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 you create the Release, because `--target <short sha>` fails.

4. Compose the body, read it, then create the Release:

Expand All @@ -25,7 +25,7 @@ How a version of this package reaches npm and GitHub. A release ends at a render

With more than one release in flight, create them in ascending version order.

5. Approve the `npm-publish` environment for the tag. With more than one release waiting, approve in ascending version order, so npm `latest` 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 npm `latest` stays monotonic.

6. Verify the published version and the rendered Release:

Expand All @@ -34,6 +34,21 @@ How a version of this package reaches npm and GitHub. A release ends at a render
gh release view vX.Y.Z
```

## Publish auth

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: 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
data inside the manifest being published, not an access control. The durable
control is the npm-side package setting that requires trusted publishing,
which closes the classic-token path that no workflow change can reach.

## Body template

The Release title is the tag name verbatim. The body is an optional one-sentence summary, then the sections that have items, then the commit list, then the compare link as the last line. Nothing follows the link, and a section with no items is left out.
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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