From d9e72f607a5482390ffc6dcc97a8444c0aa4bec0 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:50:07 +0200 Subject: [PATCH] ci: release without anyone running a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/publish.yml | 83 +++++++++++++++++++----------- .github/workflows/token-health.yml | 83 ++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/token-health.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9628dda..999f3aa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -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 diff --git a/.github/workflows/token-health.yml b/.github/workflows/token-health.yml new file mode 100644 index 0000000..c5f4b66 --- /dev/null +++ b/.github/workflows/token-health.yml @@ -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'), + });