Skip to content

Release

Release #4

Workflow file for this run

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