diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d5fc6..34acfd6 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() @@ -402,14 +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: - if: github.ref == 'refs/heads/master' || github.ref_type == 'tag' + # 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 @@ -417,9 +424,11 @@ 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: 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 @@ -437,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 @@ -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: @@ -508,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: github.ref_type == 'tag' uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - name: Set up JDK 17 - if: github.ref_type == 'tag' uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Android SDK - if: github.ref_type == 'tag' uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Cache Gradle - if: github.ref_type == 'tag' uses: actions/cache@v6 with: path: | @@ -538,11 +572,9 @@ jobs: gradle-${{ runner.os }}- - name: Build sidecar JAR - if: github.ref_type == 'tag' run: make sidecar - name: Publish the sanderling CLI to GitHub Releases - if: github.ref_type == 'tag' uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: "~> v2" @@ -555,7 +587,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 +621,26 @@ 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-npm + - release-cli + - docs + runs-on: ubuntu-latest + steps: + - name: Check all jobs passed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1