Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
23 changes: 22 additions & 1 deletion .github/workflows/capability-matrix-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions .github/workflows/format-master.yml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions scripts/quality-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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
```
38 changes: 38 additions & 0 deletions scripts/quality-gate/changed-areas.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# changed-areas.sh <base-sha> — classify the diff <base-sha>...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"
84 changes: 84 additions & 0 deletions scripts/quality-gate/fixtures-changed-areas/run.sh
Original file line number Diff line number Diff line change
@@ -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 <name> <expected> <changed file>... — 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
Loading