Skip to content

Commit d9e72f6

Browse files
catomeanclaude
andcommitted
ci: release without anyone running a release
Mirrors ai-forms#16 so both libraries release identically. Cutting a release meant `npm version && git push --tags` by hand. Now merging a version bump to main ships the package: the workflow asks the registry whether package.json's version already exists and publishes it if not. That check rather than a tag trigger, because a tag pushed by GITHUB_TOKEN does not start another workflow — "push a tag, let publish.yml notice" silently never runs — and because asking npm what is published is idempotent, so a re-run or a hand-pushed tag cannot double-publish. The tag is created after a successful publish, so it never claims a release that did not happen. NPM_TOKEN expires 2026-11-14. token-health.yml probes `npm whoami` weekly and opens a single issue when it fails, with the fix in it — rather than letting a release three months from now be the thing that discovers the expiry, reported as a 403 that reads like a permissions problem. Verified: both files parse, and the registry check answers correctly against the real registry for threadkit@0.1.0 (already published → skip). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent af08370 commit d9e72f6

2 files changed

Lines changed: 136 additions & 30 deletions

File tree

.github/workflows/publish.yml

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
name: Publish
22

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

917
jobs:
1018
publish:
1119
runs-on: ubuntu-latest
1220
permissions:
13-
contents: read
14-
# Required for npm provenance — proves on the registry that this tarball
15-
# was built by this workflow from this commit.
21+
# Tagging the released commit, so a version on the registry can always be
22+
# traced back to the tree it was built from.
23+
contents: write
24+
# npm provenance: proves on the registry that this tarball was built by
25+
# this workflow from this commit.
1626
id-token: write
1727
steps:
1828
- uses: actions/checkout@v4
@@ -22,33 +32,46 @@ jobs:
2232
node-version: '24'
2333
registry-url: 'https://registry.npmjs.org'
2434

25-
- run: npm ci --ignore-scripts
26-
27-
# Never publish something that would not have passed CI.
28-
- run: npm run verify
29-
30-
# Refuse to publish a tag whose version does not match package.json,
31-
# rather than silently shipping the wrong number.
32-
- name: Check tag matches package version
35+
- name: Is this version already on the registry?
36+
id: check
3337
run: |
34-
tag="${GITHUB_REF_NAME#v}"
35-
pkg=$(node -p "require('./package.json').version")
36-
if [ "$tag" != "$pkg" ]; then
37-
echo "Tag v$tag does not match package.json version $pkg" >&2
38-
exit 1
38+
name=$(node -p "require('./package.json').name")
39+
version=$(node -p "require('./package.json').version")
40+
echo "version=$version" >> "$GITHUB_OUTPUT"
41+
if npm view "$name@$version" version >/dev/null 2>&1; then
42+
echo "→ $name@$version is already published; nothing to do."
43+
echo "publish=false" >> "$GITHUB_OUTPUT"
44+
else
45+
echo "→ $name@$version is not on the registry; releasing it."
46+
echo "publish=true" >> "$GITHUB_OUTPUT"
3947
fi
4048
41-
# OIDC (id-token above) is the preferred credential and needs no secret.
42-
# But trusted publishing is configured per package on npmjs.com, and a
43-
# package that has never been published cannot have it configured — so
44-
# OIDC alone cannot do the FIRST publish. Confirmed on ai-forms: the
45-
# provenance statement was signed and logged to sigstore, then the PUT
46-
# returned E404 "could not be found or you do not have permission",
47-
# which reads like a missing package rather than a missing credential.
48-
#
49-
# NPM_TOKEN covers only that first publish. Once this package exists and
50-
# a trusted publisher is configured, npm prefers OIDC and the secret can
51-
# be deleted.
52-
- run: npm publish
49+
- if: steps.check.outputs.publish == 'true'
50+
run: npm ci --ignore-scripts
51+
52+
# Never publish something that would not have passed CI.
53+
- if: steps.check.outputs.publish == 'true'
54+
run: npm run verify
55+
56+
# Bootstrap auth. Trusted publishing (OIDC) needs no token and is the
57+
# destination; until it is configured on the package, NPM_TOKEN is what
58+
# authenticates. The token expires — token-health.yml warns before it does,
59+
# rather than letting a release be the thing that discovers it.
60+
- if: steps.check.outputs.publish == 'true'
61+
run: npm publish
5362
env:
5463
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
64+
65+
- name: Tag the released commit
66+
if: steps.check.outputs.publish == 'true'
67+
env:
68+
TAG: v${{ steps.check.outputs.version }}
69+
run: |
70+
# Tag after a successful publish, so a tag never claims a release that
71+
# did not happen. Skipped silently if it already exists.
72+
if git rev-parse "$TAG" >/dev/null 2>&1; then
73+
echo "→ tag $TAG already exists"
74+
else
75+
git tag "$TAG"
76+
git push origin "$TAG"
77+
fi

.github/workflows/token-health.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Token health
2+
3+
# NPM_TOKEN expires. Without this, the thing that discovers that fact is a
4+
# release failing months from now, at which point someone has to work out why —
5+
# an npm auth error does not say "your token expired", it says 403 or ENEEDAUTH,
6+
# which reads like a permissions problem.
7+
#
8+
# So the token is checked on a schedule and the failure is turned into an issue
9+
# with the fix written in it, instead of a surprise during a release.
10+
on:
11+
schedule:
12+
# Weekly, Monday 06:00 UTC.
13+
- cron: '0 6 * * 1'
14+
workflow_dispatch:
15+
16+
jobs:
17+
check:
18+
runs-on: ubuntu-latest
19+
permissions:
20+
issues: write
21+
steps:
22+
- uses: actions/setup-node@v4
23+
with:
24+
node-version: '24'
25+
registry-url: 'https://registry.npmjs.org'
26+
27+
- name: Can the token still authenticate?
28+
id: probe
29+
continue-on-error: true
30+
env:
31+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
32+
run: |
33+
# `npm whoami` is the cheapest call that proves the credential is live.
34+
# It does not publish, and it does not need a package to exist.
35+
if who=$(npm whoami 2>&1); then
36+
echo "→ token is valid (authenticated as $who)"
37+
echo "ok=true" >> "$GITHUB_OUTPUT"
38+
else
39+
echo "→ token did NOT authenticate: $who"
40+
echo "ok=false" >> "$GITHUB_OUTPUT"
41+
fi
42+
43+
- name: Open an issue if the token is dead
44+
if: steps.probe.outputs.ok != 'true'
45+
uses: actions/github-script@v7
46+
with:
47+
script: |
48+
const title = 'NPM_TOKEN cannot authenticate — releases are blocked';
49+
// One open issue, not one per week.
50+
const existing = await github.rest.issues.listForRepo({
51+
owner: context.repo.owner, repo: context.repo.repo,
52+
state: 'open', labels: 'release-blocked',
53+
});
54+
if (existing.data.some(i => i.title === title)) {
55+
core.info('issue already open');
56+
return;
57+
}
58+
await github.rest.issues.create({
59+
owner: context.repo.owner, repo: context.repo.repo,
60+
title,
61+
labels: ['release-blocked'],
62+
body: [
63+
'`npm whoami` failed with the `NPM_TOKEN` secret, so **publishing is broken**.',
64+
'Nothing is wrong with the package — the credential is.',
65+
'',
66+
'Most likely the token expired. npm tokens are created with an expiry,',
67+
'and an expired one fails with `403`/`ENEEDAUTH`, which reads like a',
68+
'permissions problem rather than an expiry.',
69+
'',
70+
'**Two ways to fix it, cheapest first:**',
71+
'',
72+
'1. **Configure trusted publishing and delete the token entirely.**',
73+
' npmjs.com → this package → Settings → Trusted Publisher →',
74+
' GitHub Actions → this org/repo → `publish.yml` → allow `npm publish`.',
75+
' Then remove the `NODE_AUTH_TOKEN` line from `publish.yml`.',
76+
' Tokens stop existing, so they stop expiring.',
77+
' https://docs.npmjs.com/trusted-publishers',
78+
'',
79+
'2. Create a new granular token and re-set the secret:',
80+
' `gh secret set NPM_TOKEN --repo ' + context.repo.owner + '/' + context.repo.repo + '`',
81+
' (the secret NAME is `NPM_TOKEN`; the token itself is pasted at the prompt)',
82+
].join('\n'),
83+
});

0 commit comments

Comments
 (0)