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
83 changes: 53 additions & 30 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,33 +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

# OIDC (id-token above) is the preferred credential and needs no secret.
# But trusted publishing is configured per package on npmjs.com, and a
# package that has never been published cannot have it configured — so
# OIDC alone cannot do the FIRST publish. Confirmed on ai-forms: the
# provenance statement was signed and logged to sigstore, then the PUT
# returned E404 "could not be found or you do not have permission",
# which reads like a missing package rather than a missing credential.
#
# NPM_TOKEN covers only that first publish. Once this package exists and
# a trusted publisher is configured, npm prefers OIDC and the secret can
# be deleted.
- 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