diff --git a/.github/workflows/ci.yml b/.github/workflows/checks.yml similarity index 81% rename from .github/workflows/ci.yml rename to .github/workflows/checks.yml index 1f93233..010caed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/checks.yml @@ -1,9 +1,10 @@ -name: CI +name: Checks +# Reusable suite of gating checks, called by both pull-request.yml (PRs) and master.yml +# (pushes to master). Kept as a `workflow_call` workflow — rather than a composite action — +# so the three checks stay separate parallel jobs with their own minimal permissions. on: - push: - branches: [master] - pull_request: + workflow_call: {} permissions: {} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 0000000..07d5970 --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,49 @@ +name: Master + +# Master pipeline: run the shared checks on every push to master, and — only once they're +# green — decide whether this commit bumped the package.json version and, if so, dispatch +# release.yml. The version-changed decision lives here (not in release.yml) so the Release +# workflow only ever runs for an actual release instead of firing on every commit. +on: + push: + branches: [master] + +permissions: {} + +jobs: + checks: + uses: ./.github/workflows/checks.yml + permissions: # ceiling for the called jobs (union of what they each need) + contents: read + actions: read + security-events: write + + trigger-release: + needs: checks # only on green master + runs-on: ubuntu-latest + permissions: + contents: read # read package.json and its first parent + actions: write # dispatch release.yml (workflow_dispatch) + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 # need HEAD^ to diff package.json against the first parent + persist-credentials: false + - name: Dispatch release.yml when the version changed + env: + GH_TOKEN: ${{ github.token }} + run: | + # Release ONLY when THIS commit introduced the version (vs its first + # parent), not whenever master merely carries an untagged version. + # HEAD^ = first parent, so this covers both squash and merge-commit + # merges of the prepare PR. release.yml reads the version itself; we + # only hand it the commit to release. + VERSION=$(jq -r .version package.json) + git show "HEAD^:package.json" > /tmp/package.parent.json + PARENT_VERSION=$(jq -r .version /tmp/package.parent.json) + if [ "$VERSION" = "$PARENT_VERSION" ]; then + echo "Version unchanged ($VERSION) — no release." + exit 0 + fi + echo "Version bumped $PARENT_VERSION -> $VERSION — dispatching release for $GITHUB_SHA." + gh workflow run release.yml --ref master -f sha="$GITHUB_SHA" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..01d9063 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,16 @@ +name: Pull request + +# PR pipeline: run the shared checks on every pull request. Nothing release-related lives +# here — that belongs to master.yml. +on: + pull_request: + +permissions: {} + +jobs: + checks: + uses: ./.github/workflows/checks.yml + permissions: # ceiling for the called jobs (union of what they each need) + contents: read + actions: read + security-events: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c92ada8..7d0f85d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,110 +1,93 @@ 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. +# 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). # -# 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 +# 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: {} -# 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' + # 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: - # 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 + ref: master + fetch-depth: 0 # full history: need master's first-parent chain + the release commit's blob persist-credentials: false - - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 - with: - cache: true - - run: npm ci - - id: decide + - id: resolve + env: + SHA: ${{ inputs.sha || github.sha }} 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") + # 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 [ "$VERSION" != "$PARENT_VERSION" ]; then - echo "should_release=true" >> "$GITHUB_OUTPUT" - else - echo "should_release=false" >> "$GITHUB_OUTPUT" + 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 - outputs: - should_release: ${{ steps.decide.outputs.should_release }} - version: ${{ steps.decide.outputs.version }} - sha: ${{ github.event.workflow_run.head_sha }} - + 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: gate - if: needs.gate.outputs.should_release == 'true' + 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 - # 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 }} + # 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) + 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 }} + VERSION: ${{ needs.verify.outputs.version }} + SHA: ${{ needs.verify.outputs.sha }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.gate.outputs.sha }} + 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 @@ -112,13 +95,17 @@ jobs: 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. + + # 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 credential-helper dance. Release is - # created before publish (publish is the least-reversible step). + + # --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; same artifact attached above + npm publish "$TARBALL" # OIDC: no token, provenance automatic