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
80 changes: 53 additions & 27 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
name: Publish

# Publishing is driven by a version tag, so the released artifact is always
# traceable to a commit. `npm version` creates the tag; pushing it ships.
# Releasing is not a thing anyone does. Merge a version bump to main and the
# package ships: this workflow asks the registry whether package.json's version
# already exists, and publishes it if not.
#
# Why that check rather than a tag trigger: a tag pushed by GITHUB_TOKEN does
# not start another workflow, so "push a tag, let publish.yml notice" silently
# never runs. Asking npm what is published is also idempotent — re-running this,
# or pushing a tag by hand, cannot double-publish or fail confusingly.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
# Required for npm provenance — proves on the registry that this tarball
# was built by this workflow from this commit.
# Tagging the released commit, so a version on the registry can always be
# traced back to the tree it was built from.
contents: write
# npm provenance: proves on the registry that this tarball was built by
# this workflow from this commit.
id-token: write
steps:
- uses: actions/checkout@v4
Expand All @@ -22,30 +32,46 @@ jobs:
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- run: npm ci --ignore-scripts

# Never publish something that would not have passed CI.
- run: npm run verify

# Refuse to publish a tag whose version does not match package.json,
# rather than silently shipping the wrong number.
- name: Check tag matches package version
- name: Is this version already on the registry?
id: check
run: |
tag="${GITHUB_REF_NAME#v}"
pkg=$(node -p "require('./package.json').version")
if [ "$tag" != "$pkg" ]; then
echo "Tag v$tag does not match package.json version $pkg" >&2
exit 1
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "→ $name@$version is already published; nothing to do."
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "→ $name@$version is not on the registry; releasing it."
echo "publish=true" >> "$GITHUB_OUTPUT"
fi

# Bootstrap auth. Trusted publishing (OIDC) is the destination and needs no
# token, but it is configured on a PACKAGE — which cannot exist until it
# has been published once. So the first release authenticates with a token;
# once the package exists, the trusted publisher is configured, this env
# block is deleted, and the token is revoked.
#
# Provenance still works here: `id-token: write` above is what npm needs to
# attest the build, and that is independent of how the publish authenticates.
- run: npm publish
- if: steps.check.outputs.publish == 'true'
run: npm ci --ignore-scripts

# Never publish something that would not have passed CI.
- if: steps.check.outputs.publish == 'true'
run: npm run verify

# Bootstrap auth. Trusted publishing (OIDC) needs no token and is the
# destination; until it is configured on the package, NPM_TOKEN is what
# authenticates. The token expires — token-health.yml warns before it does,
# rather than letting a release be the thing that discovers it.
- if: steps.check.outputs.publish == 'true'
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Tag the released commit
if: steps.check.outputs.publish == 'true'
env:
TAG: v${{ steps.check.outputs.version }}
run: |
# Tag after a successful publish, so a tag never claims a release that
# did not happen. Skipped silently if it already exists.
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "→ tag $TAG already exists"
else
git tag "$TAG"
git push origin "$TAG"
fi
83 changes: 83 additions & 0 deletions .github/workflows/token-health.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Token health

# NPM_TOKEN expires. Without this, the thing that discovers that fact is a
# release failing months from now, at which point someone has to work out why —
# an npm auth error does not say "your token expired", it says 403 or ENEEDAUTH,
# which reads like a permissions problem.
#
# So the token is checked on a schedule and the failure is turned into an issue
# with the fix written in it, instead of a surprise during a release.
on:
schedule:
# Weekly, Monday 06:00 UTC.
- cron: '0 6 * * 1'
workflow_dispatch:

jobs:
check:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- name: Can the token still authenticate?
id: probe
continue-on-error: true
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# `npm whoami` is the cheapest call that proves the credential is live.
# It does not publish, and it does not need a package to exist.
if who=$(npm whoami 2>&1); then
echo "→ token is valid (authenticated as $who)"
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "→ token did NOT authenticate: $who"
echo "ok=false" >> "$GITHUB_OUTPUT"
fi

- name: Open an issue if the token is dead
if: steps.probe.outputs.ok != 'true'
uses: actions/github-script@v7
with:
script: |
const title = 'NPM_TOKEN cannot authenticate — releases are blocked';
// One open issue, not one per week.
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner, repo: context.repo.repo,
state: 'open', labels: 'release-blocked',
});
if (existing.data.some(i => i.title === title)) {
core.info('issue already open');
return;
}
await github.rest.issues.create({
owner: context.repo.owner, repo: context.repo.repo,
title,
labels: ['release-blocked'],
body: [
'`npm whoami` failed with the `NPM_TOKEN` secret, so **publishing is broken**.',
'Nothing is wrong with the package — the credential is.',
'',
'Most likely the token expired. npm tokens are created with an expiry,',
'and an expired one fails with `403`/`ENEEDAUTH`, which reads like a',
'permissions problem rather than an expiry.',
'',
'**Two ways to fix it, cheapest first:**',
'',
'1. **Configure trusted publishing and delete the token entirely.**',
' npmjs.com → this package → Settings → Trusted Publisher →',
' GitHub Actions → this org/repo → `publish.yml` → allow `npm publish`.',
' Then remove the `NODE_AUTH_TOKEN` line from `publish.yml`.',
' Tokens stop existing, so they stop expiring.',
' https://docs.npmjs.com/trusted-publishers',
'',
'2. Create a new granular token and re-set the secret:',
' `gh secret set NPM_TOKEN --repo ' + context.repo.owner + '/' + context.repo.repo + '`',
' (the secret NAME is `NPM_TOKEN`; the token itself is pasted at the prompt)',
].join('\n'),
});
Loading