Release #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Triggered after CI succeeds on a push to master (i.e. after a release-prep PR merges). | |
| # A cheap, ungated `gate` job decides whether this push introduced a new version; only | |
| # then does the `release` job — behind a manual approval environment — tag, build, create | |
| # the GitHub Release, and publish to npm via OIDC. | |
| # | |
| # zizmor flags `workflow_run` as a dangerous trigger because it runs privileged and is | |
| # commonly misused to execute untrusted PR code. We use it safely: the gate requires a | |
| # *push* to *master* that *succeeded*, the checkout pins the triggering commit SHA (a | |
| # trusted master commit, never PR head), and the privileged publish sits behind a manual | |
| # approval environment. Hence the suppression below. | |
| on: # zizmor: ignore[dangerous-triggers] | |
| workflow_run: | |
| workflows: ["CI"] # matches name: in ci.yml | |
| types: [completed] | |
| branches: [master] # head branch of the CI run; excludes PR runs at the trigger | |
| permissions: {} | |
| # NOTE: no workflow-level `concurrency:` on purpose. A top-level group would put | |
| # every release RUN — including the ones that no-op at the gate — into one | |
| # serialized slot, and GitHub cancels the older *pending* run whenever a newer | |
| # one queues. Under a quick succession of merges A -> B -C where only A bumps | |
| # the version, B/C's no-op runs could evict A while it waits, so nothing | |
| # releases. Concurrency lives on the `release` job instead (below), where only | |
| # genuine releases land. | |
| jobs: | |
| gate: | |
| # Belt-and-suspenders: the trigger's `branches: [master]` already excludes PR runs; | |
| # this also requires the CI run to be a *push* that *succeeded*. | |
| if: >- | |
| github.event.workflow_run.event == 'push' && | |
| github.event.workflow_run.head_branch == 'master' && | |
| github.event.workflow_run.conclusion == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| # workflow_run defaults to default-branch HEAD — must pin the triggering SHA. | |
| # fetch-depth: 2 so the first parent is present for the version-introduced diff. | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| fetch-depth: 2 | |
| persist-credentials: false | |
| - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 | |
| with: | |
| cache: true | |
| - run: npm ci | |
| - id: decide | |
| run: | | |
| VERSION=$(node -p "require('./package.json').version") | |
| ./scripts/validate-version.ts "$VERSION" # format check (defence-in-depth) | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| # Release ONLY when THIS commit introduced the version (vs its first | |
| # parent), not merely whenever master happens to carry an untagged | |
| # version. This ties the release to the prepare commit and makes any | |
| # later same-version commit a no-op regardless of CI/approval | |
| # ordering. | |
| # HEAD^ = first parent, so this covers both squash and merge-commit merges. | |
| git show "HEAD^:package.json" > /tmp/package.parent.json | |
| PARENT_VERSION=$(node -p "require('/tmp/package.parent.json').version") | |
| if [ "$VERSION" != "$PARENT_VERSION" ]; then | |
| echo "should_release=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| outputs: | |
| should_release: ${{ steps.decide.outputs.should_release }} | |
| version: ${{ steps.decide.outputs.version }} | |
| sha: ${{ github.event.workflow_run.head_sha }} | |
| release: | |
| needs: gate | |
| if: needs.gate.outputs.should_release == 'true' | |
| name: Create git tag & GitHub Release, publish to npm | |
| runs-on: ubuntu-latest | |
| environment: tag-release-and-publish # <- MANUAL APPROVAL GATE (required reviewer) | |
| timeout-minutes: 15 # bound a hung job holding id-token: write | |
| # Serialize on the VERSION — the resource that actually needs mutual exclusion (it owns | |
| # the v$VERSION tag, the GitHub Release, and the npm version). Job-level concurrency can | |
| # read `needs.*` (it's evaluated after `gate`), unlike top-level concurrency. Keying by | |
| # version (not SHA) collapses two commits that target the SAME version (e.g. a bump, a | |
| # revert, then a re-bump) into one serialized slot with a single approval. | |
| concurrency: | |
| group: release-${{ needs.gate.outputs.version }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write # create the Github Release (and its tag) | |
| id-token: write # OIDC trusted publishing | |
| env: | |
| VERSION: ${{ needs.gate.outputs.version }} # from package.json, bound once | |
| SHA: ${{ needs.gate.outputs.sha }} | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| ref: ${{ needs.gate.outputs.sha }} | |
| persist-credentials: false | |
| - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 | |
| with: | |
| cache: true | |
| - run: npm ci | |
| - run: ./scripts/extract-notes.ts "$VERSION" /tmp/release-notes.md | |
| - run: npm run build | |
| - name: Create GitHub Release (which creates the tag), then publish to npm | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| TARBALL=$(npm pack | tail -n1) | |
| # Title uses the date from the CHANGELOG heading (not "today"), so it matches the | |
| # changelog even though the manual approval may land days after prepare. | |
| DATE=$(./scripts/extract-release-date.ts "$VERSION") | |
| # --target makes GitHub create the v$VERSION tag on the release commit as part of | |
| # creating the Release — tag and Release are born together from one API call, so | |
| # there's no separate git tag/push and no credential-helper dance. Release is | |
| # created before publish (publish is the least-reversible step). | |
| gh release create "v${VERSION}" --target "$SHA" --title "Tuor v${VERSION} (${DATE})" \ | |
| --notes-file /tmp/release-notes.md "$TARBALL" | |
| npm publish "$TARBALL" # OIDC: no token, provenance automatic; same artifact attached above |