From a667a7c7d330200b0fc56a2a000ed7cded7cbf85 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 30 Oct 2026 14:10:10 +0100 Subject: [PATCH] ci: skip the jobs a diff cannot affect Bookkeeping PRs (capability sync, baseline refresh) and their merges ran the full gate, mutation and annotation validation although they touch no file those checks read. changed-areas.sh classifies the diff (code / csharp) and each workflow skips what it cannot affect, decided from the files alone, never from the PR's author, title, branch or commit message. - PRs from this repository's own branches are format-blocked; only fork PRs keep --bypass-format, so format PRs after merge become rare. - format-master and capability-matrix-sync drop their commit-message skips, which let any change titled chore(...) skip formatting or sync. --- .github/workflows/build-and-test.yml | 57 ++++++++++--- .github/workflows/capability-matrix-sync.yml | 23 ++++- .github/workflows/format-master.yml | 32 +++++-- .github/workflows/mutation.yml | 19 +++++ scripts/quality-gate/README.md | 19 +++++ scripts/quality-gate/changed-areas.sh | 38 +++++++++ .../fixtures-changed-areas/run.sh | 84 +++++++++++++++++++ 7 files changed, 252 insertions(+), 20 deletions(-) create mode 100755 scripts/quality-gate/changed-areas.sh create mode 100755 scripts/quality-gate/fixtures-changed-areas/run.sh diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c4119509..beb3e131 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,7 +11,27 @@ concurrency: cancel-in-progress: true jobs: + # Skips what the diff cannot affect, decided from its files alone + # (scripts/quality-gate/changed-areas.sh). A skipped job reports as passing. + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + code: ${{ steps.areas.outputs.code }} + csharp: ${{ steps.areas.outputs.csharp }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: areas + run: ./scripts/quality-gate/changed-areas.sh "$BASE" | tee -a "$GITHUB_OUTPUT" + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + build-and-test: + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest permissions: contents: write # push the baseline branch for the post-merge PR below @@ -44,18 +64,31 @@ jobs: # the dependency-vulnerability scan (a vulnerable package may have no available # fix, which shouldn't wedge every PR) are signals — surfaced but non-blocking. # - # The flag depends on the event (this workflow only runs on pull_request and - # push:master, so the two branches below are exactly those two cases): - # PR → --bypass-format: a fork contributor can't always fix a format - # violation themselves the way an agent can, and blocking the merge on - # it would just be the friction this exists to remove. format-master.yml - # auto-formats master after merge regardless. The gate stays read-only - # (no --overwrite-baseline), so a PR run never rewrites a baseline. - # push → --overwrite-baseline: The gate writes the improved baseline to the - # runner's tree; the next step commits it. Format stays blocking here - # (no --bypass-format). + # The flag depends on where the change comes from: + # push → --overwrite-baseline: the gate writes the improved baseline + # to the runner's tree; the next step commits it. Format + # stays blocking. + # PR from a fork → --bypass-format: a fork contributor can't always fix a + # format violation themselves the way an agent can, and + # format-master.yml auto-formats master after merge regardless. + # PR from a branch of this repository → no flag: format blocks, so the + # violation is fixed before merge instead of in a format PR + # after it. + # A PR run never passes --overwrite-baseline, so it never rewrites a baseline. - name: Quality gate - run: ./scripts/quality-gate/gate.sh packages ${{ github.event_name == 'pull_request' && '--bypass-format' || '--overwrite-baseline' }} + run: | + if [[ "$EVENT" == "push" ]]; then + mode="--overwrite-baseline" + elif [[ "$HEAD_REPOSITORY" != "$REPOSITORY" ]]; then + mode="--bypass-format" + else + mode="" + fi + ./scripts/quality-gate/gate.sh packages $mode + env: + EVENT: ${{ github.event_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} - name: Refresh baseline on master if: always() && github.event_name == 'push' @@ -75,6 +108,8 @@ jobs: # catches a mistyped feature key or a feature reported with conflicting statuses. capability-matrix: name: Validate capability annotations + needs: changes + if: needs.changes.outputs.csharp == 'true' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/capability-matrix-sync.yml b/.github/workflows/capability-matrix-sync.yml index d1506fd5..b399bf72 100644 --- a/.github/workflows/capability-matrix-sync.yml +++ b/.github/workflows/capability-matrix-sync.yml @@ -2,17 +2,38 @@ name: Sync capability matrix # Runs only on push to master. The in-code capability annotations are the source # of truth; sdk-compliance.yaml is a generated artifact the matrix site aggregates. +# +# Annotations live only in .cs files, so a push without one skips the sync +# (scripts/quality-gate/changed-areas.sh). A change to the canonical spec in +# supabase/sdk does not push here: dispatch this workflow to pick one up. on: push: branches: [master] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + csharp: ${{ steps.areas.outputs.csharp }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: areas + run: ./scripts/quality-gate/changed-areas.sh "$BASE" | tee -a "$GITHUB_OUTPUT" + env: + BASE: ${{ github.event.before }} + sync: - if: ${{ !startsWith(github.event.head_commit.message, 'chore(') }} + needs: changes + if: needs.changes.outputs.csharp == 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/format-master.yml b/.github/workflows/format-master.yml index 79a56aaa..d99a4dd2 100644 --- a/.github/workflows/format-master.yml +++ b/.github/workflows/format-master.yml @@ -1,10 +1,10 @@ name: Auto-format master -# Runs only on push to master. It exists because build-and-test.yml's -# PR-time gate runs with --bypass-format (a fork contributor can't always fix a -# format violation themselves the way an agent can), so master can land a merge -# with a format violation still in it. This closes that gap: format whatever -# the merge actually changed, and commit the fix directly. +# Runs only on push to master. It exists because build-and-test.yml's PR-time +# gate runs with --bypass-format for PRs from forks (a fork contributor can't +# always fix a format violation themselves the way an agent can), so master can +# land a merge with a format violation still in it. This closes that gap: format +# whatever the merge actually changed, and commit the fix directly. on: push: branches: [master] @@ -14,10 +14,26 @@ concurrency: cancel-in-progress: true jobs: + # Formats only the .cs files this push changed, so a push without one has + # nothing to format (scripts/quality-gate/changed-areas.sh). + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + csharp: ${{ steps.areas.outputs.csharp }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: areas + run: ./scripts/quality-gate/changed-areas.sh "$BASE" | tee -a "$GITHUB_OUTPUT" + env: + BASE: ${{ github.event.before }} + format-master: - if: >- - !startsWith(github.event.head_commit.message, 'chore(format)') && - !startsWith(github.event.head_commit.message, 'chore(quality-gate)') + needs: changes + if: needs.changes.outputs.csharp == 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index d1d0da83..c0578525 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -10,7 +10,26 @@ concurrency: cancel-in-progress: true jobs: + # Stryker only mutates changed .cs files, so a diff without one has nothing to + # mutate (scripts/quality-gate/changed-areas.sh). + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + csharp: ${{ steps.areas.outputs.csharp }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: areas + run: ./scripts/quality-gate/changed-areas.sh "$BASE" | tee -a "$GITHUB_OUTPUT" + env: + BASE: ${{ github.event.pull_request.base.sha }} + mutation: + needs: changes + if: needs.changes.outputs.csharp == 'true' runs-on: ubuntu-latest # Ceiling, not the norm: Stryker.NET mutates everything if a non-C# file changed # (stryker-net#3013), so the timeout bounds that worst case. diff --git a/scripts/quality-gate/README.md b/scripts/quality-gate/README.md index b78fb457..39ddaaa4 100644 --- a/scripts/quality-gate/README.md +++ b/scripts/quality-gate/README.md @@ -143,6 +143,23 @@ FIXTURE_KEEP=1 ./scripts/quality-gate/fixtures/run-fixtures.sh # keep work dir Change the gate's behavior → add or update the fixture that pins it. A behavior with no fixture is untested. +## Which CI jobs a change needs + +`changed-areas.sh` classifies a diff, and each workflow skips the jobs it cannot affect: + +| Output | True when the diff touches | Gates | +|----------|---------------------------------------------------------------------------|------------------------------------------------------| +| `code` | anything but `sdk-compliance.yaml`, `CHANGELOG.md`, `.gate-baseline.json` | the full gate | +| `csharp` | a `.cs` file | mutation, annotation validation, format, matrix sync | + +The decision comes from the files alone, never from the PR's author, title, branch or +commit message: those are chosen by whoever opens the PR, so skipping on them would let +any change skip the gate. + +```sh +./scripts/quality-gate/fixtures-changed-areas/run.sh # its cases +``` + ## Layout ``` @@ -153,5 +170,7 @@ lib/warnings.sh analyzer warnings + per-package baseline I/O lib/coverage.sh line coverage + per-package baseline I/O lib/stages.sh one definition of each stage, used for both scopes format-suggest.sh prints the format diff for a changed file (dev helper) +changed-areas.sh classifies a diff into the areas CI jobs care about fixtures/ the gate's own test suite +fixtures-changed-areas/ cases for changed-areas.sh ``` diff --git a/scripts/quality-gate/changed-areas.sh b/scripts/quality-gate/changed-areas.sh new file mode 100755 index 00000000..1b8c9e89 --- /dev/null +++ b/scripts/quality-gate/changed-areas.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# changed-areas.sh — classify the diff ...HEAD into the areas +# CI jobs care about, as GitHub Actions outputs (append to $GITHUB_OUTPUT): +# +# code=true|false any file other than sdk-compliance.yaml, CHANGELOG.md and +# packages/*/.gate-baseline.json. No build, test or analyzer +# reads those, so a diff made only of them skips the gate. +# csharp=true|false any .cs file. Mutation, annotation validation, formatting +# and the capability sync only ever look at .cs files. +# +# The decision is made from the files alone, never from the PR's author, title, +# branch or commit message: those are chosen by whoever opens the PR, so skipping +# on them would let any change skip the gate. +# +# A missing, all-zero or unknown base (a manual dispatch, a first push, a history +# rewrite) and an empty diff count as everything changed. +# +set -euo pipefail + +INERT='^(sdk-compliance\.yaml|CHANGELOG\.md|packages/[^/]+/\.gate-baseline\.json)$' +CSHARP='\.cs$' + +base="${1:-}" +if [[ -z "$base" || "$base" =~ ^0+$ ]] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + files="" +else + files="$(git diff --name-only "$base"...HEAD)" +fi + +if [[ -z "$files" ]]; then + echo "code=true" + echo "csharp=true" + exit 0 +fi + +grep -qvE "$INERT" <<<"$files" && echo "code=true" || echo "code=false" +grep -qE "$CSHARP" <<<"$files" && echo "csharp=true" || echo "csharp=false" diff --git a/scripts/quality-gate/fixtures-changed-areas/run.sh b/scripts/quality-gate/fixtures-changed-areas/run.sh new file mode 100755 index 00000000..58c3f7e2 --- /dev/null +++ b/scripts/quality-gate/fixtures-changed-areas/run.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# run.sh — the test for changed-areas.sh. +# +# Each case commits a set of files on top of a base commit in a throwaway repo and +# asserts the areas the script reports. A wrong answer here skips a check a change +# needed, so every rule in the script's header has a case. +# +# run.sh run every case +# +# Exit 0 if every case passed, 1 otherwise. + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AREAS="$HERE/../changed-areas.sh" +RUNROOT="${FIXTURE_RUNROOT:-${TMPDIR:-/tmp}/changed-areas-fixtures.$$}" + +if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + RED=$'\033[31m'; GRN=$'\033[32m'; DIM=$'\033[2m'; BLD=$'\033[1m'; OFF=$'\033[0m' +else RED=""; GRN=""; DIM=""; BLD=""; OFF=""; fi + +mkdir -p "$RUNROOT" +trap '[[ -n "${FIXTURE_KEEP:-}" ]] || rm -rf "$RUNROOT"' EXIT + +PASS=0; FAIL=0 + +report() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + printf " ${GRN}PASS${OFF} %s\n" "$name"; PASS=$((PASS+1)) + else + printf " ${RED}FAIL${OFF} %s\n ${DIM}expected: %s${OFF}\n ${DIM}actual: %s${OFF}\n" \ + "$name" "$expected" "$actual" + FAIL=$((FAIL+1)) + fi +} + +commit_all() { + git -c user.email=fixture@gate -c user.name=fixture add -A + git -c user.email=fixture@gate -c user.name=fixture commit -q --allow-empty -m "$1" +} + +# areas_case ... — commits the files on a fresh base +areas_case() { + local name="$1" expected="$2"; shift 2 + local repo="$RUNROOT/${name// /-}" + mkdir -p "$repo" + ( + cd "$repo" + git init -q -b main + commit_all base + for file in "$@"; do mkdir -p "$(dirname "$file")"; echo changed >"$file"; done + commit_all change + ) + report "$name" "$expected" "$(cd "$repo" && bash "$AREAS" "$(git rev-parse HEAD~1)" | paste -sd' ' -)" +} + +areas_case "capability sync" "code=false csharp=false" sdk-compliance.yaml +areas_case "baseline refresh" "code=false csharp=false" packages/Gotrue/.gate-baseline.json +areas_case "bookkeeping together" "code=false csharp=false" sdk-compliance.yaml CHANGELOG.md packages/Core/.gate-baseline.json +areas_case "release" "code=true csharp=false" CHANGELOG.md .release-please-manifest.json packages/Core/Core/Core.csproj +areas_case "mark API shipped" "code=true csharp=false" packages/Gotrue/Gotrue/PublicAPI.Shipped.txt +areas_case "feature" "code=true csharp=true" packages/Gotrue/Gotrue/Client.cs +areas_case "feature with bookkeeping" "code=true csharp=true" packages/Gotrue/Gotrue/Client.cs sdk-compliance.yaml +areas_case "workflow" "code=true csharp=false" .github/workflows/build-and-test.yml +areas_case "nested compliance file" "code=true csharp=false" docs/sdk-compliance.yaml +areas_case "nested changelog" "code=true csharp=false" packages/Core/CHANGELOG.md +areas_case "baseline outside a package" "code=true csharp=false" .gate-baseline.json +areas_case "empty diff" "code=true csharp=true" + +report "no base" "code=true csharp=true" \ + "$(cd "$RUNROOT/feature" && bash "$AREAS" "" | paste -sd' ' -)" +report "all-zero base" "code=true csharp=true" \ + "$(cd "$RUNROOT/capability-sync" && bash "$AREAS" 0000000000000000000000000000000000000000 | paste -sd' ' -)" +report "unknown base" "code=true csharp=true" \ + "$(cd "$RUNROOT/capability-sync" && bash "$AREAS" 1234567890abcdef1234567890abcdef12345678 | paste -sd' ' -)" + +echo +if [[ $FAIL -eq 0 ]]; then + echo "${GRN}${BLD}cases: $PASS/$((PASS+FAIL)) passed${OFF}"; exit 0 +else + echo "${RED}${BLD}cases: $FAIL/$((PASS+FAIL)) failed${OFF}"; exit 1 +fi