Release #4
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 | |
| # Dispatched by master.yml (`gh workflow run`) when a push to master bumps the | |
| # package.json version, so this workflow only runs for an actual release. Also | |
| # runnable BY HAND from the Actions tab as an escape hatch: optionally pass a | |
| # commit SHA to release (defaults to the branch HEAD). | |
| # | |
| # The version number is read from package.json AT that commit — never supplied | |
| # by the caller — and the commit is verified to be on master before anything | |
| # irreversible happens. The tag/Release/publish themselves sit behind the | |
| # `tag-release-and-publish` manual-approval environment. | |
| # | |
| # Note: workflow_dispatch always runs THIS file from the master HEAD version, | |
| # not from the released SHA (the dispatch API only accepts a branch/tag ref). | |
| # This could in theory lead to a mismatch between pipeline code (build & | |
| # packaging instructions) and code-to-build, though in most cases the released | |
| # SHA will coincide with HEAD or be very close. Either way, there is no way | |
| # around this, unfortunately, without resorting to PAT/GitHub App shenanigans. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| sha: | |
| description: "Commit SHA to release; defaults to the branch HEAD if left blank" | |
| required: false | |
| permissions: {} | |
| jobs: | |
| # Pre-approval integrity checks: resolve the commit + its package.json version, and refuse | |
| # anything not on master's first-parent history (guards the manual escape hatch against | |
| # releasing an unmerged/arbitrary/feature-branch commit). Runs before the approval gate, | |
| # so a bad request never even prompts for approval. | |
| verify: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| sha: ${{ steps.resolve.outputs.sha }} | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| ref: master | |
| fetch-depth: 0 # full history: need master's first-parent chain + the release commit's blob | |
| persist-credentials: false | |
| - id: resolve | |
| env: | |
| SHA: ${{ inputs.sha || github.sha }} | |
| run: | | |
| # Refuse anything that isn't on master's FIRST-PARENT chain — i.e. a commit that | |
| # actually landed on master (a direct push, or the merge/squash/rebase commit of a | |
| # merged PR), not a feature-branch commit that is merely *reachable* from master | |
| # through a merge. Plain git only — no gh CLI / GH_TOKEN, and no auth needed to read | |
| # a public repo's history. | |
| if ! git rev-list --first-parent master | grep -qxF "$(git rev-parse --verify "$SHA^{commit}")"; then | |
| echo "::error::Commit ${SHA} is not on master's first-parent history. Refusing to release." | |
| exit 1 | |
| fi | |
| echo "sha=${SHA}" >> "$GITHUB_OUTPUT" | |
| # Read the version from the RELEASE commit (not the checked-out master tip). | |
| echo "version=$(git show "${SHA}:package.json" | jq -r .version)" >> "$GITHUB_OUTPUT" | |
| release: | |
| needs: verify | |
| 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 | |
| concurrency: | |
| # Block multiple releases pipelines (for the same $VERSION) from running | |
| # at the same time. | |
| group: release-${{ needs.verify.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.verify.outputs.version }} | |
| SHA: ${{ needs.verify.outputs.sha }} | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| ref: ${{ needs.verify.outputs.sha }} | |
| persist-credentials: false | |
| - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 | |
| with: | |
| cache: true | |
| - run: npm ci | |
| - run: ./scripts/validate-version.ts "$VERSION" # format check | |
| - 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 some | |
| # time 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 git | |
| # 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 |