From 908fd3741c734ddc63736c86a2e004a9d5fe9061 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 03:44:40 +0530 Subject: [PATCH 1/2] ci: name every job Category (variant), and gate the lot on one check Follows the convention in antithesishq/bombadil: the display name is what groups a run in the Actions UI, so Check (tests), Check (browser), Check (workflows), Folio (android), Folio (ios), Folio (web), Replay UI, Release and Docs. Every job carries a name, so none of them falls back to its kebab-case id. All checks passed needs all nine and runs with if: always(), so branch protection has one check to point at and a skipped job cannot read as a pass. Release and docs now gate on startsWith(github.ref, 'refs/tags/v') alongside master, which is the form the trigger filter already uses. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- .github/workflows/ci.yml | 68 +++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d5fc6..7abc417 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,8 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: + check-tests: + name: Check (tests) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -109,7 +110,8 @@ jobs: - name: Run folio's unit tests run: make test-folio - browser: + check-browser: + name: Check (browser) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -126,9 +128,8 @@ jobs: - name: Drive web fixtures through headless Chrome run: make test-browser - # The folio jobs and the release job never run on a pull request, so a bad - # expression or a missing action in them would land before anything caught it. - workflows: + check-workflows: + name: Check (workflows) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -136,18 +137,20 @@ jobs: # Pinned so a new actionlint release cannot change what CI enforces, # for the same reason the buf version above is spelled out. shellcheck # runs over every run: block by default. - - name: Lint the workflows + - name: Lint the workflow uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 with: version: 1.7.12 # actionlint reads a local action's inputs but never checks that its path # exists: `uses: ./.github/actions/typo` lints clean and fails only when - # the job runs. + # the job runs, and the folio jobs and the release job never run on a + # pull request. - name: Check that the workflow references resolve run: .github/scripts/workflow-refs.sh folio-android: + name: Folio (android) if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 90 @@ -196,6 +199,7 @@ jobs: retention-days: 14 folio-ios: + name: Folio (ios) if: github.event_name != 'pull_request' runs-on: macos-15 timeout-minutes: 90 @@ -252,6 +256,7 @@ jobs: retention-days: 14 folio-web: + name: Folio (web) if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 60 @@ -296,6 +301,7 @@ jobs: retention-days: 14 replay-ui: + name: Replay UI runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -387,8 +393,8 @@ jobs: # property was ever evaluated against real content: they all decline to # judge when the elements they read are absent, so a run that never # rendered the step page is green and worthless. This step is what tells - # the two apart, and it fails the job when nothing was judged. folio's - # jobs make the same call inside folio-run.sh, where the exit code it is + # the two apart, and it fails the job when nothing was judged. folio + # makes the same call inside folio-run.sh, where the exit code it is # judging is in scope. - name: Classify the run if: always() @@ -406,7 +412,8 @@ jobs: # GitHub Releases. On master it publishes @sanderling/spec only, and only when # pkg/spec/package.json carries a version npm does not have yet. release: - if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' + name: Release + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write @@ -419,7 +426,7 @@ jobs: # which is what stops the value below forging a second $GITHUB_OUTPUT key. - name: Validate the tag id: tag - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') run: | pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' if [[ ! "$TAG" =~ $pattern ]]; then @@ -456,7 +463,7 @@ jobs: run: npm ci - name: Stamp version - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') working-directory: pkg/spec run: npm version "$VERSION" --no-git-tag-version --allow-same-version env: @@ -509,25 +516,25 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Set up Go - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - name: Set up JDK 17 - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Android SDK - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Cache Gradle - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') uses: actions/cache@v6 with: path: | @@ -538,11 +545,11 @@ jobs: gradle-${{ runner.os }}- - name: Build sidecar JAR - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') run: make sidecar - name: Publish the sanderling CLI to GitHub Releases - if: github.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/v') uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: "~> v2" @@ -555,7 +562,8 @@ jobs: # on every merge instead: it is pandoc over a few pages, and a deploy of bytes # that did not change is a no-op. docs: - if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' + name: Docs + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: read @@ -588,3 +596,25 @@ jobs: - uses: actions/deploy-pages@v5 id: deployment + + # The one status check to point branch protection at. Without `if: always()` + # this would be skipped along with anything that skipped, and a skipped + # required check reads as a pass. + all-checks-passed: + name: All checks passed + if: always() + needs: + - check-tests + - check-browser + - check-workflows + - folio-android + - folio-ios + - folio-web + - replay-ui + - release + - docs + runs-on: ubuntu-latest + steps: + - name: Check all jobs passed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 From af90b0f2990a41a0a33b54af7843107a99e82571 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 03:48:22 +0530 Subject: [PATCH 2/2] ci: split the release job back in two Collapsing them left the npm publish steps in a job holding contents: write, because GoReleaser needs it, so npm ci ran its dependency lifecycle scripts with a write-capable GITHUB_TOKEN in reach of the same job as a live NPM_TOKEN. Release (npm) is back on contents: read and Release (cli) keeps contents: write, which is what they each had before. Each validates the tag from its own copy of the pattern rather than waiting on a job that exists only to pass a string. Release (cli) is tags only: there is no CLI to cut on a merge. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7abc417..34acfd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,15 +408,15 @@ jobs: path: runs/ retention-days: 14 - # On a tag this publishes @sanderling/spec at the tag's version and the CLI to - # GitHub Releases. On master it publishes @sanderling/spec only, and only when - # pkg/spec/package.json carries a version npm does not have yet. - release: - name: Release + # On master this publishes @sanderling/spec, and only when + # pkg/spec/package.json carries a version npm does not have yet. On a tag it + # publishes the version the tag names. + release-npm: + name: Release (npm) if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: # A refname is attacker-controlled text and git permits backtick, `$`, # `(`, `;`, `&` and `|` in it, so it goes through env: a `${{ }}` is @@ -424,6 +424,8 @@ jobs: # outputs rather than the refname, and nothing reaches a shell before it # has matched the pattern. The pattern is anchored and admits no newline, # which is what stops the value below forging a second $GITHUB_OUTPUT key. + # Release (cli) validates the same way, from its own copy: the two jobs + # hold different permissions and neither should wait on the other. - name: Validate the tag id: tag if: startsWith(github.ref, 'refs/tags/v') @@ -444,8 +446,6 @@ jobs: # Empty on master, where the commit that triggered the run is the one # to publish and master may have moved on since. ref: ${{ steps.tag.outputs.tag || github.sha }} - # GoReleaser reads the tag history for its changelog. - fetch-depth: 0 # `npm ci` below runs dependency lifecycle scripts, and no step in # this job needs the git credential afterwards. persist-credentials: false @@ -515,26 +515,53 @@ jobs: VERSION: ${{ steps.version.outputs.version }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Tags only: there is no CLI to cut on a merge. This is the job that holds + # contents: write, and it holds no publish credential of its own. + release-cli: + name: Release (cli) + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # The same validation Release (npm) runs, on the same pattern, for the + # same reason. Both copies must stay identical. + - name: Validate the tag + id: tag + run: | + pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' + if [[ ! "$TAG" =~ $pattern ]]; then + echo "release: refusing to publish from '$TAG'" >&2 + echo "release: a release tag is vMAJOR.MINOR.PATCH with an optional -prerelease, e.g. v0.1.0 or v0.0.1-rc1" >&2 + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + env: + TAG: ${{ github.ref_name }} + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.tag.outputs.tag }} + # GoReleaser reads the tag history for its changelog. + fetch-depth: 0 + - name: Set up Go - if: startsWith(github.ref, 'refs/tags/v') uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - name: Set up JDK 17 - if: startsWith(github.ref, 'refs/tags/v') uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Android SDK - if: startsWith(github.ref, 'refs/tags/v') uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Cache Gradle - if: startsWith(github.ref, 'refs/tags/v') uses: actions/cache@v6 with: path: | @@ -545,11 +572,9 @@ jobs: gradle-${{ runner.os }}- - name: Build sidecar JAR - if: startsWith(github.ref, 'refs/tags/v') run: make sidecar - name: Publish the sanderling CLI to GitHub Releases - if: startsWith(github.ref, 'refs/tags/v') uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: "~> v2" @@ -611,7 +636,8 @@ jobs: - folio-ios - folio-web - replay-ui - - release + - release-npm + - release-cli - docs runs-on: ubuntu-latest steps: