diff --git a/.github/required-checks.txt b/.github/required-checks.txt new file mode 100644 index 00000000..66c614c7 --- /dev/null +++ b/.github/required-checks.txt @@ -0,0 +1,39 @@ +# TIER 1 (#1264) — the checks that block a merge to main. THE source of truth: +# the "Protection" ruleset (id 17713865) is synced FROM this file after merge, +# never edited by hand. Required checks are evaluated in the MERGE QUEUE +# (merge_group), which runs the full main lane before anything lands. +# tools/ci_tier_check.sh gates this file against the workflows (each name is +# produced by exactly one job that reports on pull_request AND merge_group; +# no required path runs work only on push); `--live` diffs it against the +# ruleset. +# +# Format: one exact check-run name per line. A line starting with `#` is a +# comment (give the reason for a non-obvious entry). No trailing blanks. +# +# ci.yml +scope +# Every `container:` job runs inside this image; a failed prerequisite SKIPS the +# required jobs that need it, and GitHub counts a skipped required check as +# passing — so it is required like the jobs that need it (#1264). +build dev/ci image +werror audit ([99i], cached) +gate self-tests (section plan + audit cache key) +linux / gcc +# The clang leg: -Werror at compile time + clang codegen (#1264: tier 1 is +# "Linux gcc/clang"). Core smoke on a PR, the full suite on main. +linux / clang +macos / macos-latest +extensions (http+model+gfx suite; embed/lsp/jit-smoke) +asan + ubsan (full suite) +db extension (postgres service) +jit differential (interpreter oracle, tape-replayed) +replay differential (same-binary tape fidelity) +freestanding profile (symbol gate + smoke) +tsan (concurrency race gate) +install.sh (interpreter + eigenlsp on PATH) +bench (instruction-count regression gate) +valgrind (memcheck smoke, JIT off) +# codeql.yml (workflow "CodeQL") +Analyze C +# pages.yml (workflow "Docs site") — the aggregator over the real emcc build +playground (real emcc wasm32 build) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330207cd..42839a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: branches: [main] + # The merge queue (#1264, Rust's model): the FULL main lane runs on the + # queue's candidate (current main + the PRs queued ahead + this one) before + # anything lands, so main is green by construction. Every step below that + # is main-lane-only is gated `github.event_name != 'pull_request'`, which is + # true on push AND on merge_group — never `== 'push'`. + merge_group: + types: [checks_requested] permissions: contents: read @@ -43,15 +50,28 @@ concurrency: # probe gates (never a hand-written list). [99i] runs ONCE, in the # `werror audit` job, cached on the audit's inputs. macos-15-intel is # not on this lane. -# main lane (push to main) — the full matrix except macos-15-intel, with -# [99i] owned by the one `werror audit` job instead of being repeated ten -# times. The merge-er waits for this; contributors do not. +# main lane (merge_group AND push to main) — the full matrix except +# macos-15-intel. It runs in the MERGE QUEUE (#1264, Rust's model) on the +# candidate commit (main + the PRs queued ahead + this one) and nothing +# lands unless it is green, so main is green by construction and nobody +# rebases a PR to "update" it. Main-lane-only steps are gated +# `github.event_name != 'pull_request'` (true on push and merge_group), +# never `== 'push'`. The post-merge push run re-tests the commit the queue +# already tested: it is kept because the README badge reads it and it +# publishes the rolling `ci-main` dev image that fork PRs run in. +# TIERS (#1264): every job in this file is TIER 1 — listed in +# .github/required-checks.txt, or a worker of an aggregator listed there. +# A job that is neither must go to nightly.yml; tools/ci_tier_check.sh +# (the `gate self-tests` job) fails otherwise, and also fails on a step +# whose condition would run it on push but not in the queue. Adding a job +# here means adding its check name to that file (the ruleset is synced +# from it). # nightly (.github/workflows/nightly.yml) — macos-15-intel (ONLY here, #1264) # and the full valgrind corpus, with a tracking issue on failure. # # The risk this accepts, stated: a variant-specific regression in a -# NON-variant section reaches main before it is caught. Main still runs the -# full matrix before anything is released. +# NON-variant section passes the PR lane and is caught in the queue, before +# it lands; the contributor waits only for the fast lane. jobs: # Is this PR docs-only? A change touching nothing but *.md cannot alter C or @@ -143,13 +163,15 @@ jobs: env: OWNER: ${{ github.repository_owner }} IS_FORK: ${{ github.event.pull_request.head.repo.fork == true }} - EVENT: ${{ github.event_name }} + # The event is read from the runner's GITHUB_EVENT_NAME, not an + # expression: tools/ci_tier_check.sh reds event-derived expression + # values on a required path (#1264). Only push advances ci-main. run: | BASE="ghcr.io/${OWNER,,}/eigenscript-dev" if [ "$IS_FORK" = "true" ]; then echo "image=$BASE:ci-main" >> "$GITHUB_OUTPUT" echo "tags=" >> "$GITHUB_OUTPUT" - elif [ "$EVENT" = "push" ]; then + elif [ "$GITHUB_EVENT_NAME" = "push" ]; then echo "image=$BASE:ci-${{ github.sha }}" >> "$GITHUB_OUTPUT" echo "tags=$BASE:ci-${{ github.sha }},$BASE:ci-main" >> "$GITHUB_OUTPUT" else @@ -348,6 +370,17 @@ jobs: apt-get update && apt-get install -y --no-install-recommends python3-yaml fi python3 -c 'import yaml; print("yaml", yaml.__version__)' + # Platform tiers (#1264): every name in .github/required-checks.txt is + # produced by one job that reports on pull_request AND merge_group, no + # required path runs work only on push, and every job in THIS file is + # required or a worker of a required aggregator. ~1 s + ~10 s of planted + # faults. Needs the PyYAML installed above; a missing loader is exit 2 + # (instrument error), never a pass. + - if: needs.scope.outputs.code == 'true' + name: Platform tiers — required-checks.txt vs the workflows (#1264) + run: | + bash tools/ci_tier_check.sh + bash tools/ci_tier_check.sh --selftest - if: needs.scope.outputs.code == 'true' name: Consumer-acceptance harness self-test (~7 min) run: bash tools/consumer_acceptance.sh --self-test @@ -444,7 +477,7 @@ jobs: # only thing it no longer runs is [99i], which the `werror audit` job # owns for this run and which prints a SKIP naming that job. - name: Run test suite (full) - if: matrix.cc == 'gcc' || github.event_name == 'push' + if: matrix.cc == 'gcc' || github.event_name != 'pull_request' env: EIGS_SKIP_WERROR_AUDIT: 1 # [99zd]'s live arms. Without this the roadmap gate's milestone and @@ -457,10 +490,10 @@ jobs: # clang on a PR: the value of this leg is the BUILD (-Werror fires at # compile time, and clang's codegen differs), not a tenth execution of # the same ~263 sections the gcc leg just ran on the same commit. It - # runs the derived core-smoke plan instead. On a push to main it runs + # runs the derived core-smoke plan instead. On the main lane (queue, push) it runs # the full suite like every other leg. - name: Run test suite (derived core-smoke plan) - if: matrix.cc == 'clang' && github.event_name != 'push' + if: matrix.cc == 'clang' && github.event_name == 'pull_request' env: EIGS_SUITE_SECTIONS: core EIGS_SKIP_WERROR_AUDIT: 1 @@ -687,15 +720,15 @@ jobs: # self-test (11 min) — the single largest block of this leg's 15 min. # Its verdict cannot differ by platform anyway: the only conditional in # the Makefile is LDFLAGS, a LINK flag, and the audit reads COMPILE - # invocations. On a push to main it still runs here in full, so the + # invocations. On the main lane (queue, push) it still runs here in full, so the # gate's BSD-userland portability keeps an exercise at merge time. - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run test suite (PR lane; [99i] owned by the werror audit job) env: EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run test suite (main lane, [99i] included) run: cd tests && bash run_all_tests.sh @@ -817,14 +850,14 @@ jobs: # decide whether to skip — plus a fixed core smoke, and the job fails if # the http binary unlocks fewer probe-gated chunks than its floor (a # broken registration otherwise collapses the plan silently). - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived http+model section plan env: EIGS_SUITE_SECTIONS: http EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run full suite against http+model build env: EIGS_SKIP_WERROR_AUDIT: 1 @@ -865,14 +898,14 @@ jobs: name: Build gfx variant run: make gfx - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived gfx section plan (audio [62], [120b], [132], [133], [134]) env: EIGS_SUITE_SECTIONS: gfx EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run full suite against gfx build (audio [62], containment [132], gfx examples [97]) env: EIGS_SKIP_WERROR_AUDIT: 1 @@ -899,14 +932,14 @@ jobs: name: Build zlib variant run: make zlib - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived zlib section plan (executes DEFLATE section [124b]) env: EIGS_SUITE_SECTIONS: zlib EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run full suite against zlib build (executes DEFLATE section [124]) env: EIGS_SKIP_WERROR_AUDIT: 1 @@ -933,14 +966,14 @@ jobs: name: Build net variant run: make net - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived net section plan (executes network section [125]) env: EIGS_SUITE_SECTIONS: net EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run full suite against net build (executes network section [125]) env: EIGS_SKIP_WERROR_AUDIT: 1 @@ -986,7 +1019,7 @@ jobs: name: Build full variant (http+model+db) run: make full - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived full-variant section plan with live DATABASE_URL env: DATABASE_URL: postgres://eigs:eigs_test@db:5432/eigs_test @@ -994,7 +1027,7 @@ jobs: EIGS_SKIP_WERROR_AUDIT: 1 run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run full suite with live DATABASE_URL env: DATABASE_URL: postgres://eigs:eigs_test@db:5432/eigs_test @@ -1286,7 +1319,7 @@ jobs: # sanitizers. This job was 26 min on #1158 — the second-longest on the # board — and the core sections it shared with `asan + ubsan / core and # LSP` are already sanitized there on the same commit. - - if: needs.scope.outputs.code == 'true' && github.event_name != 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name == 'pull_request' name: Run derived asan-http section plan under sanitizers env: ASAN_OPTIONS: detect_leaks=1 @@ -1295,7 +1328,7 @@ jobs: EIGS_SKIP_WERROR_AUDIT: 1 run: make asan-http && cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' && github.event_name == 'push' + - if: needs.scope.outputs.code == 'true' && github.event_name != 'pull_request' name: Run suite under sanitizers with the HTTP+model extensions env: ASAN_OPTIONS: detect_leaks=1 @@ -1544,10 +1577,23 @@ jobs: run: ./build.sh - if: needs.scope.outputs.code == 'true' - name: Build origin/main in a worktree (same environment) + name: Build the baseline in a worktree (same environment) + # In the merge queue the candidate is main + the PRs queued AHEAD + this + # one, so the baseline is the candidate's base (merge_group.base_sha), + # not origin/main — otherwise a PR is charged for the Ir cost of the PRs + # ahead of it and falsely rejected (#1273 code review). Read from the + # event payload inside the script, never via ${{ }} (tier gate rule). run: | - git fetch --no-tags --depth=1 origin main - git worktree add /tmp/main-ref origin/main + base=origin/main + if [ "$GITHUB_EVENT_NAME" = merge_group ]; then + base=$(jq -r '.merge_group.base_sha' "$GITHUB_EVENT_PATH") + [ -n "$base" ] && [ "$base" != null ] || { echo "merge_group event without base_sha"; exit 1; } + git fetch --no-tags --depth=1 origin "$base" + else + git fetch --no-tags --depth=1 origin main + fi + echo "baseline: $base" + git worktree add /tmp/main-ref "$base" ( cd /tmp/main-ref && ./build.sh ) - if: needs.scope.outputs.code == 'true' @@ -1555,5 +1601,5 @@ jobs: run: EIGENSCRIPT="$PWD/src/eigenscript" bash bench/check_regression.sh --selftest - if: needs.scope.outputs.code == 'true' - name: Regression gate — Ir of this commit vs origin/main + name: Regression gate — Ir of this commit vs its baseline run: EIGENSCRIPT="$PWD/src/eigenscript" bash bench/check_regression.sh --vs /tmp/main-ref/src/eigenscript diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9aafd9e3..e725f8fe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + merge_group: # the merge queue (#1264): a required check must report there + types: [checks_requested] schedule: - cron: '23 7 * * 1' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e6c371c1..600731f4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -178,7 +178,7 @@ jobs: Run: $RUN_URL - These lanes moved off the PR path in #1160. \`macos-15-intel\` also runs on every push to \`main\`; the FULL valgrind corpus runs HERE ONLY (the PR lane and \`main\` both run the smoke spread), so a red \`valgrind-full\` is a finding nothing else will report. This thread exists so a nightly failure between main pushes is not silent." + These lanes moved off the PR path in #1160 and are tier 2 (#1264): they never colour \`main\`. \`macos-15-intel\` runs HERE ONLY; the FULL valgrind corpus runs HERE ONLY (the PR lane and \`main\` both run the smoke spread), so a red \`valgrind-full\` is a finding nothing else will report. This thread exists so a nightly failure between main pushes is not silent." if [ -n "$num" ]; then # Reopen first: a closed thread that starts failing again is the diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b286653b..0106c451 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -38,9 +38,10 @@ # check NAME independently of the worker's. THIS is the check # to require on main. # -# Deploy is unchanged: Configure Pages, the artifact upload and the `deploy` -# job run only on push/dispatch, never on a pull request, and deploy needs -# the aggregator. +# Deploy: Configure Pages and the artifact upload run on every non-PR event +# (push, dispatch, and the merge queue, so the queue exercises exactly what a +# push will); the `deploy` job runs only on push/dispatch — never from a PR or +# a queue candidate — and needs the aggregator. name: Docs site on: @@ -48,16 +49,19 @@ on: branches: [main] pull_request: branches: [main] + merge_group: # the merge queue (#1264): a required check must report there + types: [checks_requested] workflow_dispatch: permissions: contents: read # Deploys from main still serialize on ONE group, so a newer main push -# supersedes an older deploy. A pull request gets a group of its OWN ref: -# sharing `pages` would let every PR push cancel an in-flight main deploy. +# supersedes an older deploy. A pull request or a merge-queue candidate gets a +# group of its OWN ref: sharing `pages` would let it cancel an in-flight main +# deploy. concurrency: - group: ${{ github.event_name == 'pull_request' && format('pages-pr-{0}', github.ref) || 'pages' }} + group: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && format('pages-pr-{0}', github.ref) || 'pages' }} cancel-in-progress: true jobs: @@ -132,7 +136,9 @@ jobs: echo "OK: the real emcc build of web/build.sh passed" deploy: - if: github.event_name != 'pull_request' + # Publish only what LANDED: never from a pull request, and never from a + # merge-queue candidate that may still be rejected (#1264). + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' needs: playground runs-on: ubuntu-latest permissions: diff --git a/docs/CI.md b/docs/CI.md index af0d7a92..fdd15413 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -525,14 +525,16 @@ All three tools carry a planted-fault `--selftest` with a pinned case count and a `--contract`, and the suite runs the live pass, the contract and the selftest of each as `[99zd]`. -## Main lane (push to `main`) — the full matrix +## Main lane (the merge queue, then push to `main`) — the full matrix Everything above runs in full: macOS (`macos-latest`), every variant job on the complete suite, `linux / clang` on the complete suite. The only thing that does not run ten times is [99i], which the `werror audit` job owns. -This is the real exit gate. #1138 and #1158 both carried lanes only CI could -run. Contributors never wait on it; whoever merges does. +This is the real exit gate, and it runs **in the merge queue** (`merge_group`) +on the commit that will land, before it lands — see **Platform tiers** below. +#1138 and #1158 both carried lanes only CI could run. Contributors never wait +on it and never rebase to satisfy it; the queue does both. ## Nightly (`.github/workflows/nightly.yml`) @@ -925,8 +927,8 @@ tools/section_plan.sh --shards 3 --shard 2 # that shard's plan line EIGS_SUITE_SHARD=2/3 bash tests/run_all_tests.sh ``` -The aggregator `asan + ubsan (full suite)` — still the only ruleset-required -check, and still that name — does four things no shard can do for itself: it +The aggregator `asan + ubsan (full suite)` — a ruleset-required check (see +**Platform tiers**), and still that name — does four things no shard can do for itself: it requires every matrix leg green, re-runs `--shards 3 --check`, requires one **receipt** per shard carrying that shard's `PLAN: shard=k/3 …` line, and **sums the LeakSanitizer tallies and requires 0**. Splitting the job must not @@ -1032,85 +1034,133 @@ whose inputs really are the Makefile and the tracked scripts). A local does the suite's [99i]. `werror_cache_key.sh --selftest` reads both `ci.yml` and the audit script and fails if the split stops being used. -## Required status checks — what is actually required today - -Read off the live repo (`gh api repos/InauguralSystems/EigenScript/rulesets`, -2026-09-15), because round 1 of this change documented a list that does not -exist: - -- Classic branch protection on `main`: **not enabled** (`branches/main/protection` - returns 404, "Branch not protected"). -- Ruleset **"Protection"** (active, `~DEFAULT_BRANCH`) requires exactly **one** - status check: `asan + ubsan (full suite)`. -- Ruleset **"Main"** (active) targets `refs/heads/Main` — a branch with a - capital M that does not exist — and requires `Black`. It is inert. - -So `macos / macos-15-intel` was never in a required list, and nothing here -"must be removed" for the merge to work. What matters instead is the reverse: -**`asan + ubsan (full suite)` is the only gate the ruleset enforces**, and it -is an *aggregator* — it reports success only when both sanitizer workers -succeed (see below). That single rule keeps working unchanged under this -change. - -### The PR-lane job set, and which are aggregators - -On a pull request, `ci.yml` produces these checks: - -| Check | Kind | -|---|---| -| `scope` | gate; decides docs-only | -| `build dev/ci image` | prerequisite; every Linux leg runs inside it | -| `werror audit ([99i], cached)` | gate | -| `gate self-tests (section plan + audit cache key)` | gate | -| `linux / gcc` | the one full suite | -| `linux / clang` | build + derived core smoke | -| `macos / macos-latest` | full suite (code PRs only) | -| `extensions (http+model+gfx suite; embed/lsp/jit-smoke)` | **aggregator** over the four workers below | -| `extensions / http+model and ancillary checks` | worker | -| `extensions / gfx suite` | worker | -| `extensions / zlib suite` | worker | -| `extensions / net suite` | worker | -| `asan + ubsan (full suite)` | **aggregator** over the two workers below | -| `asan + ubsan / core and LSP` | worker | -| `asan + ubsan / HTTP and model suite` | worker | -| `db extension (postgres service)` | gate | -| `jit differential (interpreter oracle, tape-replayed)` | gate | -| `replay differential (same-binary tape fidelity)` | gate | -| `freestanding profile (symbol gate + smoke)` | gate | -| `valgrind (memcheck smoke, JIT off)` | gate (the smoke spread; the job prints its size) | -| `tsan (concurrency race gate)` | gate | -| `install.sh (interpreter + eigenlsp on PATH)` | gate | -| `bench (instruction-count regression gate)` | gate | -| `Analyze C` (workflow `CodeQL`) | gate, separate workflow | -| `playground (real emcc wasm32 build)` (workflow `Docs site`) | **aggregator** over the worker below; reports on every PR | -| `playground / build (real emcc, web/build.sh) + docs site` | worker | - -`macos / macos-15-intel` runs **only** in nightly (#1264): on the main lane it hit its -45-minute timeout on nearly every push, so main CI never finished green. - -An aggregator exists so that a *required* check name can survive the job being -split into parallel workers: it fails unless every worker succeeded, and it -treats `skipped`, `cancelled` and missing results as failure. A worker is not -separately required; it is required *through* its aggregator. - -### If the required set is ever widened - -The set worth requiring, if someone tightens the ruleset, is: `scope`, -`linux / gcc`, `extensions (…)`, `asan + ubsan (full suite)`, -`db extension (postgres service)`, `macos / macos-latest`, -`werror audit ([99i], cached)`, `gate self-tests (…)`, the two differentials, -`freestanding`, `tsan`, `install.sh`, `bench`, `valgrind` and `Analyze C`. -**Never** `macos / macos-15-intel`: it does not run on pull requests, and a -required check that never reports blocks the merge forever — the same trap the -`scope` job's comment in `ci.yml` describes. Add `playground (real emcc -wasm32 build)` — it reports on every pull request, success only when the -real emcc build of `web/build.sh` ran and passed, failure otherwise. +## Platform tiers — what blocks a merge, and what decides main's colour (#1264) + +Main CI did not finish green from 2026-09-16 to 2026-09-22 although every +required check passed on every merge: one lane that no pull request had to pass +(`macos / macos-15-intel`, main lane only) hit its timeout on nearly every +push. The README badge is the status of the whole `ci.yml` workflow on `main`, +so **any** `ci.yml` job that can fail there colours it, required or not. + +The fix copies Rust (tiers plus a merge queue), CPython and Go: + +- **Tier 1** — the checks listed in `.github/required-checks.txt`. They block + a merge, and they are evaluated **in the merge queue**. +- **The merge queue.** A PR that passed the fast PR lane joins GitHub's merge + queue. The queue builds a candidate commit (current `main` + the PRs queued + ahead of it + this PR) and runs the **full main lane** on it (the + `merge_group` event). Nothing lands unless every required check is green + there, so `main` is green by construction, and **contributors never rebase + just to update a PR** — the queue tests the combination for them. Every + workflow that produces a required check (`ci.yml`, `codeql.yml`, + `pages.yml`) triggers on `merge_group`, and every main-lane-only step is + gated `github.event_name != 'pull_request'` (true on push *and* in the + queue), never `== 'push'`. +- **The post-merge push run** re-tests the commit the queue already tested. It + stays: the README badge reads it, it publishes the rolling `ci-main` dev + image that fork PRs run in, and `pages.yml` deploys the site only on push + (never from a queue candidate that may still be rejected). +- **Tier 2** — slow and port lanes, in `.github/workflows/nightly.yml` + (today `macos-15-intel` and the full valgrind corpus). They never colour + `main`; a failure opens or appends to one tracking issue. + +### The source of truth: `.github/required-checks.txt` + +One exact check-run name per line; a line starting with `#` is a comment giving +the reason for a non-obvious entry. The ruleset **"Protection"** +(`~DEFAULT_BRANCH`) is synced *from* this file, never edited by hand. To change +tier 1: edit the file in a PR, merge, and the orchestrator syncs the ruleset; +`bash tools/ci_tier_check.sh --live` (read-only) then prints OK. Between merge +and sync it names the drift — expected, which is why CI does not run `--live`. + +### What `tools/ci_tier_check.sh` enforces + +Only what the queue cannot guarantee by itself. It runs in the `gate +self-tests` job, followed by its `--selftest` (each planted fault must go red +through its named check). A missing PyYAML is exit 2, never a pass. + +- `[unproduced]` / `[ambiguous]` — a required name is produced by no job, or by + more than one. +- `[not-on-pr]` / `[not-in-queue]` — the producing workflow does not trigger on + `pull_request` to `main` (or is path-filtered), or does not trigger on + `merge_group`. Either way the check never *reports* there, and a required + check that never reports blocks every merge until someone overrides it. +- `[event-condition]` — on a required path (a required job, the jobs it + transitively needs, and the `ci.yml` workers), a condition could run work on + push that the queue skips. A job-level `if:` may not mention the event at + all: a job **skipped** by its `if:` reports a *satisfied* required check, so a + job-level event filter lets a merge through untested. A step `if:` may + mention the event only as `github.event_name ==/!= 'pull_request'`, or via + the PR payload `github.event.pull_request.*` (empty on push and in the + queue alike). Dot and bracket syntax are both read. The same rule covers + indirection: an `env`, job `outputs`, workflow `env` or matrix value on a + required path may not read the event (outside those two forms), and an + `if:` that reads `env.*`, `needs.*.outputs` or `steps.*.outputs` is traced + to where the value is set — unresolvable is red, `vars.*` is always red. + Two reviewed step outputs are waived by a hash of their step (`scope`'s + docs-only check, the `werror audit` cache restore); a waiver that matches + nothing is red. +- `[continue-on-error]` — a job or step on a required path sets it, so its + failure would not fail the check. +- `[uncovered]` — a `ci.yml` job is neither required nor the worker of exactly + one required `if: always()` aggregator. A failing non-required job does not + stop the queue, yet it colours the badge: the `macos-15-intel` shape. + +Whether an aggregator's script really fails on every non-success worker +result is a code-review question, not this gate's. The gate's other accepted +limits (matrix `include`/`exclude`, expressions inside `run:` scripts, +`schedule:` triggers) are listed in #1278. + +### Every `ci.yml` job, classified + +| Job (check name) | Tier | Why | +|---|---|---| +| `scope` | 1 | decides docs-only; the runtime legs read its output | +| `build dev/ci image` | 1 | the image every `container:` job (the Linux legs, the extension/ASan workers, db, the audits, the differentials, freestanding) runs inside; required because required jobs `needs` it, and a failed prerequisite *skips* them — added by #1264 | +| `werror audit ([99i], cached)` | 1 | gate | +| `gate self-tests (section plan + audit cache key)` | 1 | gate; runs this checker | +| `linux / gcc` | 1 | the one full suite on a PR | +| `linux / clang` | 1 | clang `-Werror` build + core smoke on a PR, full suite on the main lane — added by #1264 (tier 1 is "Linux gcc/clang") | +| `macos / macos-latest` | 1 | the one macOS leg; full suite with [99i] on the main lane | +| `extensions (http+model+gfx suite; embed/lsp/jit-smoke)` | 1 | **aggregator** | +| `extensions / http+model and ancillary checks`, `/ gfx suite`, `/ zlib suite`, `/ net suite` | 1, via the aggregator | workers | +| `asan + ubsan (full suite)` | 1 | **aggregator**; also re-derives shard coverage and sums the leak tally | +| `asan + ubsan / core and LSP (shard k/3)`, `asan + ubsan / HTTP and model suite` | 1, via the aggregator | workers | +| `db extension (postgres service)` | 1 | gate | +| `jit differential (…)`, `replay differential (…)` | 1 | gates | +| `freestanding profile (symbol gate + smoke)` | 1 | gate | +| `valgrind (memcheck smoke, JIT off)` | 1 | the smoke spread (the full corpus is tier 2) | +| `tsan (concurrency race gate)` | 1 | gate | +| `install.sh (interpreter + eigenlsp on PATH)` | 1 | gate | +| `bench (instruction-count regression gate)` | 1 | gate (baseline: `origin/main` on a PR; the candidate's `merge_group.base_sha` in the queue, so a PR is never charged for the PRs queued ahead of it) | +| `nightly / macos-15-intel full suite` | **2** (`nightly.yml`) | port lane, slow: hit its timeout on nearly every main push (#1265) | +| `nightly / valgrind (full corpus, JIT off)` | **2** (`nightly.yml`) | slow; the PR and main lanes run the smoke spread | + +No `ci.yml` job moved to nightly in #1264 beyond `macos-15-intel` (#1265): +every other job already runs on pull requests, so each was made tier 1. + +### Checks from other workflows + +They do not affect the `ci.yml` badge. Each is required or advisory by the same +file: + +| Check (workflow) | Tier | Why | +|---|---|---| +| `Analyze C` (`codeql.yml`) | 1 | runs on every PR to `main` and in the queue, no path filter | +| `playground (real emcc wasm32 build)` (`pages.yml`) | 1 | **aggregator** over `playground / build (…)`; reports on every PR and in the queue | +| `playground / build (real emcc, web/build.sh) + docs site` (`pages.yml`) | advisory | the worker; required through the aggregator | +| `deploy` (`pages.yml`) | advisory | runs only on push and `workflow_dispatch` — it publishes, it does not test | +| `codspeed (simulation)` (`codspeed.yml`), and the CodSpeed app's `CodSpeed Performance Analysis` | advisory | path-filtered (`paths-ignore: '**.md'`), so it never reports on a docs-only PR; the instruction-count gate that blocks is `bench` | +| `build` (`docker.yml`) | advisory | runs on push to `main`, `v*` tags and `workflow_dispatch`, never on a PR | +| `Scorecard analysis` (`scorecard.yml`) | advisory | runs on push, schedule and `branch_protection_rule`, never on a PR; a posture score | +| `Analyze (python)`, `Analyze (javascript-typescript)` | advisory | CodeQL *default setup* (a GitHub app, not a workflow file) over the repo's non-C code; the C analysis that blocks is `Analyze C` | +| `issue-triage / …` (`issue-triage.yml`), `release.yml` jobs | advisory | not triggered by PRs or pushes to `main` | ## The risk this accepts -A variant-specific regression in a *non-variant* section reaches `main` before -anything catches it — for example a clang-only miscompile in a section the -core-smoke plan does not cover. Main runs the full matrix before anything is -released, so the window is between merge and the next main run, and nothing -ships through it. That trade is deliberate: it buys back roughly half the -machine-minutes and more than half the contributor wait. +A variant-specific regression in a *non-variant* section — for example a +clang-only miscompile in a section the core-smoke plan does not cover — passes +the fast PR lane. It is caught in the merge queue, which runs the full matrix +before the PR lands, so it never reaches `main`; the cost is a rejected queue +entry instead of a red PR check. That trade is deliberate: it buys back roughly +half the machine-minutes and more than half the contributor wait. diff --git a/tools/ci_tier_check.sh b/tools/ci_tier_check.sh new file mode 100755 index 00000000..7cd9fa2e --- /dev/null +++ b/tools/ci_tier_check.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# tools/ci_tier_check.sh — platform tiers (#1264), the part the merge queue +# cannot guarantee by itself. +# +# Tier 1 = the names in .github/required-checks.txt (the ruleset is synced from +# it). The merge queue runs the full main lane on merge_group and lands nothing +# that is not green there — PROVIDED the required checks report in the queue +# and test what push tests. This gate pins that proviso: +# [unproduced]/[ambiguous] each required name has exactly one producing job; +# [not-on-pr]/[not-in-queue] on pull_request (main, unfiltered) AND merge_group; +# [event-condition] on a REQUIRED PATH (required jobs, ci.yml workers, their +# needs-closure) nothing may differ between push and merge_group: a job +# `if:` may not read the event at all; a step `if:` only as +# `github.event_name ==/!= 'pull_request'` or the PR payload +# `github.event.pull_request.*` (dot or bracket syntax, any case); env / +# outputs / matrix values may not derive from the event; an `if:` that +# reads env.*, vars.*, needs.*.outputs or steps.*.outputs must resolve to +# such values (unresolvable = red; step outputs only via a pinned WAIVE); +# [continue-on-error] set on no job or step of a required path; +# [uncovered] every ci.yml job is required or the worker of ONE required +# `if: always()` job (else it colours the badge, blocking nothing). +# Aggregator scripts are code review's. Populations: loader ci.yml jobs == awk +# count > 0; names == grep count > 0. [--selftest|--live]; exit 1 violation, 2 +# instrument error. --live: read-only diff vs the ruleset (not in CI: synced post-merge). +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WF_DIR="${CI_TIER_WF_DIR:-$ROOT/.github/workflows}" +REQ_FILE="${CI_TIER_REQUIRED:-$ROOT/.github/required-checks.txt}" +SELF="$ROOT/tools/ci_tier_check.sh" + +check() { + [ -f "$WF_DIR/ci.yml" ] && [ -f "$REQ_FILE" ] \ + || { echo "ci-tier: INSTRUMENT ERROR — ci.yml or required-checks.txt missing"; return 2; } + CT_AWK=$(awk '/^jobs:/ {j=1; next} j && /^[^ #]/ {j=0} + j && /^ [A-Za-z0-9_-]+:[[:space:]]*(#.*)?$/ {n++} END {print n+0}' "$WF_DIR/ci.yml") \ + CT_GREP=$(grep -cvE '^(#|$)' "$REQ_FILE") CT_WF="$WF_DIR" CT_REQ="$REQ_FILE" python3 - <<'PY' +import hashlib, itertools, os, re, sys +from fnmatch import fnmatchcase +def instrument(m): print(f"ci-tier: INSTRUMENT ERROR — {m}; nothing was checked"); sys.exit(2) +try: + import yaml +except Exception as e: # noqa: BLE001 + instrument(f"PyYAML unavailable ({e})") +bad = [] +def V(code, msg): bad.append(code); print(f"FAIL [{code}] {msg}") + +req = [l for l in open(os.environ["CT_REQ"], encoding="utf-8").read().split("\n") if l and not l.startswith("#")] +if not req or len(req) != int(os.environ["CT_GREP"]) or len(set(req)) != len(req): + V("vacuous", f"required-checks.txt: parsed {len(req)} names, {len(set(req))} distinct, grep counts {os.environ['CT_GREP']}") +REQ = set(req) + +W = {} +for fn in sorted(os.listdir(os.environ["CT_WF"])): + if not fn.endswith((".yml", ".yaml")): continue + try: doc = yaml.safe_load(open(os.path.join(os.environ["CT_WF"], fn), encoding="utf-8")) + except Exception as e: instrument(f"{fn} does not load ({e.__class__.__name__})") # noqa: BLE001 + if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict): instrument(f"{fn} has no jobs: mapping") + on = doc.get("on", doc.get(True)) # YAML 1.1 reads bare `on` as True + on = {on: {}} if isinstance(on, str) else {str(k): {} for k in on} if isinstance(on, list) else \ + {str(k): (v if isinstance(v, dict) else {}) for k, v in (on or {}).items()} + W[fn] = (on, doc["jobs"], doc.get("env") or {}) + +def triggers(on, ev): + if ev not in on: return False + c = on[ev] + if "paths" in c or "paths-ignore" in c: return False + if "branches" in c: return any(fnmatchcase("main", str(p)) for p in c["branches"] or []) + return not any(fnmatchcase("main", str(p)) for p in c.get("branches-ignore") or []) + +def cond(x): + s = str(x).strip(); m = re.fullmatch(r"\$\{\{(.*)\}\}", s, re.S) + return (m.group(1) if m else s).strip() +def needs(j): n = j.get("needs") or []; return [n] if isinstance(n, str) else list(n) +def always(j): return cond(j.get("if", "")) == "always()" +G = r"github\s*(?:\.\s*{0}\b|\[\s*['\"]{0}['\"]\s*\])" +PR_ATOM = re.compile(G.format("event_name") + r"\s*[=!]=\s*['\"]pull_request['\"]|['\"]pull_request['\"]\s*[=!]=\s*" + G.format("event_name"), re.I) +PR_BODY = re.compile(G.format("event") + r"\s*(?:\.\s*pull_request\b|\[\s*['\"]pull_request['\"]\s*\])", re.I) +EVP = r"(?:event_name|event|ref|ref_name|ref_type|head_ref|base_ref)\b" +EV = re.compile(r"\bgithub\b(?!\s*(?:\.\s*(?!" + EVP + r")\w|\[\s*['\"](?!" + EVP + r")\w+['\"]\s*\]))", re.I) +IND = re.compile(r"\b(env|vars)\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?|\b(needs|steps)\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?" + r"\s*(?:\.\s*|\[\s*['\"])(outputs|result|outcome|conclusion)['\"]?\s*\]?(?:\s*(?:\.\s*|\[\s*['\"])([\w-]+)['\"]?\s*\]?)?", re.I) +# Step outputs an `if:` reads, reviewed as the same on push and merge_group: +# scope's `code` (true on every non-PR event) and the [99i] cache restore (keyed +# on audit inputs only). Pinned to the reviewed STEP — any edit is red until +# re-reviewed and re-pinned (sha256 of yaml.safe_dump(step, sort_keys=True)). +WAIVE = {("ci.yml", "scope", "detect"): "6611d97dc6f0b4e7", ("ci.yml", "werror-audit", "restore"): "fd12b71bc65e9a90"} +used = set() +def exprs(v): return re.findall(r"\$\{\{(.*?)\}\}", str(v), re.S) +def bad_expr(e, wf, jid, pr_ok, depth=0): + """Why expression `e` (in wf:jid) may differ between push and merge_group, or None.""" + t = PR_BODY.sub("_", PR_ATOM.sub("_", e)) if pr_ok else e + if EV.search(t): return f"`{e.strip()}` reads the event" + if re.search(r"\b(env|vars|steps|needs)\b", IND.sub("_", t), re.I): return f"`{e.strip()}` reads a context this gate cannot resolve" + for m in IND.finditer(t): + (ctx, name, kind, job, field, key), j = m.groups(), W[wf][1].get(jid, {}) + if ctx == "vars" or depth > 4: return f"`{m.group(0)}` cannot be resolved" + if ctx == "env": + vals = [str(d[name]) for d in [s.get("env") for s in j.get("steps") or [] if isinstance(s, dict)] + [j.get("env"), W[wf][2]] + if isinstance(d, dict) and name in d] + if not vals: return f"`env.{name}` is not declared in {wf}:{jid}" + why = next((w for v in vals for x in exprs(v) for w in [bad_expr(x, wf, jid, pr_ok, depth + 1)] if w), None) + elif field in ("result", "outcome", "conclusion"): continue + elif kind == "needs": + v = (W[wf][1].get(job, {}).get("outputs") or {}).get(key) + if v is None: return f"`needs.{job}.outputs.{key}` is not declared" + why = next((w for x in exprs(v) for w in [bad_expr(x, wf, job, pr_ok, depth + 1)] if w), None) + else: + src = next((yaml.safe_dump(s, sort_keys=True) for s in j.get("steps") or [] if isinstance(s, dict) and s.get("id") == job), None) + pin = WAIVE.get((wf, jid, job)) + if pin and src is not None and hashlib.sha256(src.encode()).hexdigest()[:16] == pin: used.add((wf, jid, job)); continue + return f"`steps.{job}.outputs.{key}` comes from a script (not waived, or the waived script changed)" + if why: return f"{m.group(0).strip()} -> {why}" + return None + +def names(jid, j): + m = (j.get("strategy") or {}).get("matrix") if isinstance(j.get("strategy"), dict) else None + combos = [{}] + if isinstance(m, dict): + keys = [k for k in m if k not in ("include", "exclude")] + combos = [dict(zip(keys, v)) for v in itertools.product(*[m[k] for k in keys])] if keys else [] + combos += [i for i in m.get("include") or [] if not keys] + out = [] + for c in combos: + n = re.sub(r"\$\{\{\s*matrix\.([\w-]+)\s*\}\}", lambda mo: str(c.get(mo.group(1), "?")), str(j.get("name", jid))) + if n not in out: out.append(n) + return out +NAMES = {(wf, jid): names(jid, j) for wf, (_, js, _e) in W.items() for jid, j in js.items()} +PROD = {} +for k, ns in NAMES.items(): + for n in ns: PROD.setdefault(n, []).append(k) + +def closure(wf, jid, seen): + if (wf, jid) in seen or jid not in W[wf][1]: return + seen.add((wf, jid)) + for n in needs(W[wf][1][jid]): closure(wf, n, seen) + +# (a) every required name: one producer, on pull_request AND merge_group +path = set() +for r in req: + p = PROD.get(r, []) + if len(p) != 1: + V("unproduced" if not p else "ambiguous", f"required {r!r} has {len(p)} producing jobs {p} (0 never reports and blocks every merge)"); continue + wf, jid = p[0] + if not triggers(W[wf][0], "pull_request"): V("not-on-pr", f"required {r!r}: {wf} does not run on every pull_request to main") + if not triggers(W[wf][0], "merge_group"): V("not-in-queue", f"required {r!r}: {wf} does not trigger on merge_group — it never reports in the queue") + closure(wf, jid, path) + +# (d) every ci.yml job is required or a worker of one required always() job +ci = W["ci.yml"][1] +if len(ci) != int(os.environ["CT_AWK"]) or not ci: + V("vacuous", f"ci.yml: the loader sees {len(ci)} jobs, awk sees {os.environ['CT_AWK']}") +counts = {"required": 0, "worker": 0} +for jid, j in ci.items(): + if all(n in REQ for n in NAMES[("ci.yml", jid)]): counts["required"] += 1; continue + cons = [k for k, kj in ci.items() if jid in needs(kj)] + if len(cons) == 1 and always(ci[cons[0]]) and all(n in REQ for n in NAMES[("ci.yml", cons[0])]): + counts["worker"] += 1; closure("ci.yml", jid, path); continue + V("uncovered", f"ci.yml:{jid} {NAMES[('ci.yml', jid)]} is not required and not the worker of ONE required `if: always()` job — it can colour main without blocking the queue") + +# (b) + (c) on every job of a required path +for wf, jid in sorted(path): + j = W[wf][1][jid] + why = bad_expr(cond(j.get("if", "")), wf, jid, False) + if why: V("event-condition", f"{wf}:{jid}: job-level `if:` — {why}; a job skipped in the queue or on a PR is a satisfied check") + # values an `if:` may read through indirection: event-free or PR-shaped only + vals = [(k, v) for d in (j.get("env"), j.get("outputs"), W[wf][2]) if isinstance(d, dict) for k, v in d.items()] + vals += [("strategy", yaml.safe_dump(j.get("strategy") or {}))] + [(k, v) for st in j.get("steps") or [] + if isinstance(st, dict) for k, v in (st.get("env") or {}).items()] + for k, v in vals: + for x in exprs(v): + if EV.search(PR_BODY.sub("_", PR_ATOM.sub("_", x))): + V("event-condition", f"{wf}:{jid}: `{k}: ${{{{{x}}}}}` derives a value from the event") + if j.get("continue-on-error") not in (None, False): V("continue-on-error", f"{wf}:{jid} sets continue-on-error: its failure would not fail the check") + for i, st in enumerate(j.get("steps") or []): + if not isinstance(st, dict): continue + why = bad_expr(cond(st.get("if", "")), wf, jid, True) + if why: V("event-condition", f"{wf}:{jid} step {i} ({st.get('name', st.get('uses', '?'))}): `if:` — {why}; only `github.event_name ==/!= 'pull_request'` may select the lane") + if st.get("continue-on-error") not in (None, False): V("continue-on-error", f"{wf}:{jid} step {i} ({st.get('name', '?')}) sets continue-on-error") + +if set(WAIVE) - used: V("event-condition", f"waiver(s) {sorted(set(WAIVE) - used)} matched nothing — stale; remove or re-pin") +if bad: print(f"ci-tier: FAIL — {len(bad)} violation(s): {' '.join(sorted(set(bad)))}"); sys.exit(1) +print(f"ci-tier: OK — ci.yml jobs={len(ci)} (awk={os.environ['CT_AWK']}) required={counts['required']} worker={counts['worker']}; " + f"{len(req)} required names, each produced once on pull_request+merge_group; {len(path)} jobs on required paths, no push-only condition, no continue-on-error") +PY +} + +live() { + local got want + got=$(gh api repos/InauguralSystems/EigenScript/rulesets/17713865 --jq \ + '.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context' 2>&1) && [ -n "$got" ] \ + || { echo "ci-tier --live: INSTRUMENT ERROR — cannot read the ruleset: $got"; return 2; } + want=$(grep -vE '^(#|$)' "$REQ_FILE") + local d; d=$(diff <(LC_ALL=C sort <<<"$want") <(LC_ALL=C sort <<<"$got") | sed -n 's/^< / file only: /p; s/^> / ruleset only: /p') + [ -z "$d" ] && { echo "ci-tier --live: OK — the ruleset requires exactly the $(grep -c . <<<"$want") checks in the file"; return 0; } + echo "$d"; echo "ci-tier --live: DRIFT — sync the ruleset from required-checks.txt"; return 1 +} + +selftest() { + local t pass=0 fail=0; t=$(mktemp -d "${TMPDIR:-/tmp}/ci_tier.XXXXXX") || return 2 + trap 'rm -rf "$t"' RETURN + fresh() { rm -rf "$t/w"; cp -R "$ROOT/.github/workflows" "$t/w"; cp "$ROOT/.github/required-checks.txt" "$t/req"; } + sub() { python3 -c 'import sys; p,o,n=sys.argv[1:]; s=open(p).read(); assert s.count(o)==1 and o!=n, o; open(p,"w").write(s.replace(o,n))' "$@"; } + expect() { # expect DESC CODE [LIT [LIT]] — the plant is live (sub succeeded), red via CODE (and LITERAL) + local rc=0; CI_TIER_WF_DIR="$t/w" CI_TIER_REQUIRED="$t/req" bash "$SELF" > "$t/out" 2>&1 || rc=$? + if { [ "$2" = OK ] && [ $rc -eq 0 ]; } || { [ $rc -eq 1 ] && grep -qF "FAIL [$2]" "$t/out" && grep -qF -- "${3:-FAIL}" "$t/out" && grep -qF -- "${4:-FAIL}" "$t/out"; }; then + pass=$((pass + 1)); echo " PASS $1 -> ${2}" + else fail=$((fail + 1)); echo " FAIL $1: expected [$2], rc=$rc"; tail -4 "$t/out"; fi + } + broken() { fail=$((fail + 1)); echo " FAIL $1: plant anchor missing (BROKEN, not a pass)"; } + local CI="$t/w/ci.yml" TS=" name: tsan (concurrency race gate) +" + fresh; expect "unmodified copy" OK + fresh; sub "$CI" "$TS" " name: tsan renamed +" && expect "(a) required job renamed" unproduced || broken rename + fresh; sub "$t/w/codeql.yml" " merge_group:" " workflow_call:" && expect "(a) codeql.yml loses merge_group" not-in-queue || broken queue + fresh; sub "$t/w/pages.yml" " pull_request: + branches: [main] +" "" && expect "(a) pages.yml loses pull_request" not-on-pr || broken pr + # job-level: even the lane atom is red (a PR would SKIP the job = satisfied) + fresh; sub "$CI" "$TS" "$TS if: github.event_name != 'pull_request' +" && expect "(b) required job skipped on PRs (job-level lane atom)" event-condition || broken job-push + fresh; sub "$CI" " name: Run test suite (main lane, [99i] included) + run: cd tests && bash run_all_tests.sh +" " name: Run test suite (main lane, [99i] included) + run: cd tests && bash run_all_tests.sh + - if: github.event_name == 'push' + name: planted main-only step + run: exit 1 +" && expect "(b) P1: push-only failing step in a required job" event-condition || broken p1 + fresh; sub "$CI" "&& github.event_name != 'pull_request' + name: Run test suite (main lane, [99i] included)" \ + "&& github['EVENT_NAME'] == 'push' + name: Run test suite (main lane, [99i] included)" \ + && expect "(b) bracket syntax, push-only step" event-condition || broken bracket + # round-2 critic: a push-only value reached through env (the `if:` itself must red) + fresh; sub "$CI" "$TS" "$TS env: + RUN_MAIN_CHECK: \${{ github.event_name == 'push' }} +" && sub "$CI" " run: make tsan +" " run: make tsan + - if: env.RUN_MAIN_CHECK == 'true' + run: exit 1 +" && expect "(b) env alias of a push-only value" event-condition "env.RUN_MAIN_CHECK -> " "derives a value from the event" || broken alias + fresh; sub "$CI" "Require complete sanitizer coverage +" "Require complete sanitizer coverage + continue-on-error: true +" && expect "(c) P2: aggregator step continue-on-error" continue-on-error || broken p2 + fresh; sub "$CI" " needs: [scope, sanitizers-core, sanitizers-http] + if: always() +" " needs: [scope, sanitizers-core, sanitizers-http] + if: always() + continue-on-error: true +" && expect "(c) P4: required aggregator continue-on-error" continue-on-error || broken p4 + fresh; printf '\n planted:\n runs-on: ubuntu-latest\n steps:\n - run: "true"\n' >> "$CI" + expect "new ci.yml job, neither required nor a worker" uncovered + fresh; mkdir -p "$t/ny"; echo 'raise ImportError("planted")' > "$t/ny/yaml.py" + local rc=0; PYTHONPATH="$t/ny" CI_TIER_WF_DIR="$t/w" CI_TIER_REQUIRED="$t/req" bash "$SELF" > "$t/out" 2>&1 || rc=$? + [ $rc -eq 2 ] && { pass=$((pass + 1)); echo " PASS no PyYAML -> rc 2"; } || { fail=$((fail + 1)); echo " FAIL no PyYAML: rc=$rc"; } + echo "ci_tier_check selftest: checks=$((pass + fail)) failures=$fail" + [ $fail -eq 0 ] +} + +case "${1:-}" in "") check ;; --selftest) selftest ;; --live) live ;; *) echo "usage: $0 [--selftest|--live]" >&2; exit 2 ;; esac diff --git a/tools/docs_claims_populations.txt b/tools/docs_claims_populations.txt index 708b60ee..b0fd1807 100644 --- a/tools/docs_claims_populations.txt +++ b/tools/docs_claims_populations.txt @@ -57,7 +57,11 @@ NAMES|ROADMAP.md|6 # 63 -> 60 in #1255 round 3: the scope job and its path list were deleted from # pages.yml, and the CI.md paragraph that listed that path list (and the # path-filter wording before it) went with them. +# 60 -> 63 with #1264: the "Platform tiers" section (which replaced "Required +# status checks — what is actually required today") cites +# .github/required-checks.txt, tools/ci_tier_check.sh and nightly.yml where the +# old section cited fewer paths (all resolved). NUMBERS|docs/CI.md|4 -PATHS|docs/CI.md|60 +PATHS|docs/CI.md|63 TARGETS|docs/CI.md|4 NAMES|docs/CI.md|0