Skip to content

Release

Release #38

Workflow file for this run

# Runs on a v* tag push, or via workflow_dispatch ON A TAG REF (bump.yml uses the
# dispatch path because pushes made with the default GITHUB_TOKEN never trigger
# other workflows). Flow: test -> assert tag == package.json version -> publish to
# public npm (with provenance) -> cut a GitHub Release.
#
# npm publish is SKIPPED with a warning — not failed — when the NPM_TOKEN secret is
# missing, so the GitHub Release is still created. Add an npm "Automation" token as
# the NPM_TOKEN repo secret to enable publishing. See docs/RELEASING.md.
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch: # dispatched by bump.yml with --ref vX.Y.Z; must target a tag
# A tag can arrive twice for the same version — the push (tags: v*) AND bump.yml's
# explicit `gh workflow run release.yml --ref vX.Y.Z` dispatch. Serialize per-ref so
# the two never race a double publish; the loser queues (never cancels a mid-publish).
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
# Publish only after the FULL quality gate passes — not just `npm test` (P0-11).
gate:
uses: ./.github/workflows/reusable-quality-gate.yml
release:
needs: gate
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub Release
id-token: write # npm provenance (supply-chain attestation)
env:
# secrets are not directly usable in step `if:`, so surface presence here.
HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }}
steps:
- name: Assert this run targets a v* tag
if: github.ref_type != 'tag'
run: |
echo "::error::Release must run on a v* tag ref (got '${{ github.ref }}'). Use the 'Bump version' workflow to cut one."
exit 1
- uses: actions/checkout@v7
with:
fetch-depth: 0 # full history so release notes can diff from the last tag
- uses: actions/setup-node@v6.4.0
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
cache: npm
- run: npm ci
- name: Assert tag matches package.json version
env:
TAG: ${{ github.ref_name }}
run: |
PKG="v$(node -p "require('./package.json').version")"
if [ "$TAG" != "$PKG" ]; then
echo "::error::Tag $TAG does not match package.json version $PKG. Re-run the 'Bump version' workflow instead of tagging by hand."
exit 1
fi
- name: Publish to npm (with provenance)
if: env.HAS_NPM_TOKEN == 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Idempotent: skip when the version is already on the registry, so re-running a
# wedged release (or the serialized second tag event) never dies on "cannot
# publish over the previously published version".
run: |
PKG=$(node -p "require('./package.json').name")
VER=$(node -p "require('./package.json').version")
if npm view "$PKG@$VER" version >/dev/null 2>&1; then
echo "::notice::$PKG@$VER is already on npm — skipping publish (idempotent re-run)."
else
npm publish --provenance --access public
fi
- name: Skip npm publish (NPM_TOKEN not set)
if: env.HAS_NPM_TOKEN != 'true'
run: |
echo "::warning::NPM_TOKEN secret is not set — SKIPPING npm publish. The GitHub Release is still created. To publish, add an npm Automation token as the NPM_TOKEN repo secret (Settings -> Secrets and variables -> Actions) and re-run this workflow. See docs/RELEASING.md."
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
# --generate-notes builds notes from merged PRs/commits since the previous tag,
# so it works with lightweight OR annotated tags (unlike --notes-from-tag).
# Idempotent: skip if the Release already exists (re-run recovery).
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::notice::GitHub Release $TAG already exists — skipping creation."
else
gh release create "$TAG" --title "$TAG" --generate-notes --verify-tag
fi
- name: Verify the release loop closed (fail loudly on a wedge)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
# H1 closed-loop check: a tag must produce BOTH a GitHub Release and (when
# publishing is enabled) an npm version. Historically release.yml could wedge
# after the tag landed and nothing alerted — orphan tags v0.22.2/v0.23.2/v0.24.0.
# This step turns that silent wedge into a red, notified job failure; re-running
# the workflow (steps above are idempotent) then completes whatever is missing.
run: |
fail=0
if gh release view "$TAG" >/dev/null 2>&1; then
echo "GitHub Release $TAG: present."
else
echo "::error::No GitHub Release exists for $TAG after the release job."
fail=1
fi
if [ "$HAS_NPM_TOKEN" = "true" ]; then
PKG=$(node -p "require('./package.json').name")
VER=$(node -p "require('./package.json').version")
ok=0
for _ in 1 2 3 4 5 6; do
if npm view "$PKG@$VER" version >/dev/null 2>&1; then ok=1; break; fi
sleep 10 # let the registry reflect a just-published version
done
if [ "$ok" = "1" ]; then
echo "npm $PKG@$VER: present."
else
echo "::error::npm does not serve $PKG@$VER after publish (registry wedge?)."
fail=1
fi
else
echo "::warning::NPM_TOKEN not set — verified the GitHub Release only, not npm."
fi
if [ "$fail" != "0" ]; then
echo "::error::Release loop did NOT close for $TAG — do not treat it as shipped. Re-run this workflow to complete the missing artifact."
exit 1
fi
echo "Release loop verified for $TAG."