|
| 1 | +name: Release |
| 2 | + |
| 3 | +# Triggered after CI succeeds on a push to master (i.e. after a release-prep PR merges). |
| 4 | +# A cheap, ungated `gate` job decides whether this push introduced a new version; only |
| 5 | +# then does the `release` job — behind a manual approval environment — tag, build, create |
| 6 | +# the GitHub Release, and publish to npm via OIDC. |
| 7 | +# |
| 8 | +# zizmor flags `workflow_run` as a dangerous trigger because it runs privileged and is |
| 9 | +# commonly misused to execute untrusted PR code. We use it safely: the gate requires a |
| 10 | +# *push* to *master* that *succeeded*, the checkout pins the triggering commit SHA (a |
| 11 | +# trusted master commit, never PR head), and the privileged publish sits behind a manual |
| 12 | +# approval environment. Hence the suppression below. |
| 13 | +on: # zizmor: ignore[dangerous-triggers] |
| 14 | + workflow_run: |
| 15 | + workflows: ["CI"] # matches name: in ci.yml |
| 16 | + types: [completed] |
| 17 | + branches: [master] # head branch of the CI run; excludes PR runs at the trigger |
| 18 | + |
| 19 | +permissions: {} |
| 20 | + |
| 21 | +# NOTE: no workflow-level `concurrency:` on purpose. A top-level group would put |
| 22 | +# every release RUN — including the ones that no-op at the gate — into one |
| 23 | +# serialized slot, and GitHub cancels the older *pending* run whenever a newer |
| 24 | +# one queues. Under a quick succession of merges A -> B -C where only A bumps |
| 25 | +# the version, B/C's no-op runs could evict A while it waits, so nothing |
| 26 | +# releases. Concurrency lives on the `release` job instead (below), where only |
| 27 | +# genuine releases land. |
| 28 | + |
| 29 | +jobs: |
| 30 | + gate: |
| 31 | + # Belt-and-suspenders: the trigger's `branches: [master]` already excludes PR runs; |
| 32 | + # this also requires the CI run to be a *push* that *succeeded*. |
| 33 | + if: >- |
| 34 | + github.event.workflow_run.event == 'push' && |
| 35 | + github.event.workflow_run.head_branch == 'master' && |
| 36 | + github.event.workflow_run.conclusion == 'success' |
| 37 | + runs-on: ubuntu-latest |
| 38 | + permissions: |
| 39 | + contents: read |
| 40 | + steps: |
| 41 | + # workflow_run defaults to default-branch HEAD — must pin the triggering SHA. |
| 42 | + # fetch-depth: 2 so the first parent is present for the version-introduced diff. |
| 43 | + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 44 | + with: |
| 45 | + ref: ${{ github.event.workflow_run.head_sha }} |
| 46 | + fetch-depth: 2 |
| 47 | + persist-credentials: false |
| 48 | + - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 |
| 49 | + with: |
| 50 | + cache: true |
| 51 | + - run: npm ci |
| 52 | + - id: decide |
| 53 | + run: | |
| 54 | + VERSION=$(node -p "require('./package.json').version") |
| 55 | + ./scripts/validate-version.ts "$VERSION" # format check (defence-in-depth) |
| 56 | + echo "version=$VERSION" >> "$GITHUB_OUTPUT" |
| 57 | +
|
| 58 | + # Release ONLY when THIS commit introduced the version (vs its first |
| 59 | + # parent), not merely whenever master happens to carry an untagged |
| 60 | + # version. This ties the release to the prepare commit and makes any |
| 61 | + # later same-version commit a no-op regardless of CI/approval |
| 62 | + # ordering. |
| 63 | + # HEAD^ = first parent, so this covers both squash and merge-commit merges. |
| 64 | + git show "HEAD^:package.json" > /tmp/package.parent.json |
| 65 | + PARENT_VERSION=$(node -p "require('/tmp/package.parent.json').version") |
| 66 | +
|
| 67 | + if [ "$VERSION" != "$PARENT_VERSION" ]; then |
| 68 | + echo "should_release=true" >> "$GITHUB_OUTPUT" |
| 69 | + else |
| 70 | + echo "should_release=false" >> "$GITHUB_OUTPUT" |
| 71 | + fi |
| 72 | + outputs: |
| 73 | + should_release: ${{ steps.decide.outputs.should_release }} |
| 74 | + version: ${{ steps.decide.outputs.version }} |
| 75 | + sha: ${{ github.event.workflow_run.head_sha }} |
| 76 | + |
| 77 | + |
| 78 | + release: |
| 79 | + needs: gate |
| 80 | + if: needs.gate.outputs.should_release == 'true' |
| 81 | + name: Create git tag & GitHub Release, publish to npm |
| 82 | + runs-on: ubuntu-latest |
| 83 | + environment: tag-release-and-publish # <- MANUAL APPROVAL GATE (required reviewer) |
| 84 | + timeout-minutes: 15 # bound a hung job holding id-token: write |
| 85 | + # Serialize on the VERSION — the resource that actually needs mutual exclusion (it owns |
| 86 | + # the v$VERSION tag, the GitHub Release, and the npm version). Job-level concurrency can |
| 87 | + # read `needs.*` (it's evaluated after `gate`), unlike top-level concurrency. Keying by |
| 88 | + # version (not SHA) collapses two commits that target the SAME version (e.g. a bump, a |
| 89 | + # revert, then a re-bump) into one serialized slot with a single approval. |
| 90 | + concurrency: |
| 91 | + group: release-${{ needs.gate.outputs.version }} |
| 92 | + cancel-in-progress: false |
| 93 | + permissions: |
| 94 | + contents: write # create the Github release (and its tag) |
| 95 | + id-token: write # OIDC trusted publishing |
| 96 | + env: |
| 97 | + VERSION: ${{ needs.gate.outputs.version }} # from package.json, bound once |
| 98 | + SHA: ${{ needs.gate.outputs.sha }} |
| 99 | + steps: |
| 100 | + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 101 | + with: |
| 102 | + ref: ${{ needs.gate.outputs.sha }} |
| 103 | + persist-credentials: false |
| 104 | + - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 |
| 105 | + with: |
| 106 | + cache: true |
| 107 | + - run: npm ci |
| 108 | + - run: ./scripts/extract-notes.ts "$VERSION" /tmp/release-notes.md |
| 109 | + - run: npm run build |
| 110 | + - name: Create GitHub release (which creates the tag), then publish to npm |
| 111 | + env: |
| 112 | + GH_TOKEN: ${{ github.token }} |
| 113 | + run: | |
| 114 | + TARBALL=$(npm pack | tail -n1) |
| 115 | + # --target makes GitHub create the v$VERSION tag on the release commit as part of |
| 116 | + # creating the release — tag and release are born together from one API call, so |
| 117 | + # there's no separate git tag/push and no credential-helper dance. Release is |
| 118 | + # created before publish (publish is the least-reversible step). |
| 119 | + gh release create "v${VERSION}" --target "$SHA" --title "v${VERSION}" \ |
| 120 | + --notes-file /tmp/release-notes.md "$TARBALL" |
| 121 | + npm publish "$TARBALL" # OIDC: no token, provenance automatic; same artifact attached above |
0 commit comments