refactor(a2a): the front door's tap and gate become one screening ste… #1056
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # TWO TIERS, AND WHY. | |
| # | |
| # This workflow used to trigger on `push` to main/dev/qa ONLY, plus `pull_request`. That left every | |
| # feature branch COMPLETELY UNGATED until someone opened a PR. During 1.5.4 development two work | |
| # items were reported green from local runs and had never been CI-verified at all; the gap was only | |
| # discovered when the PR was opened, days of work later. "It passed locally" is not a CI result, and | |
| # a branch nobody has opened a PR for is exactly where that mistake is cheapest to make and most | |
| # expensive to discover. | |
| # | |
| # So: `branches: ['**']`, and EVERY branch push now runs CI. But the full set is 12 jobs including a | |
| # Windows runner, a release-mode timing gate, a loom model and a full-history migration corpus, and | |
| # paying all of that on every intermediate commit of a long-lived feature branch is real money for | |
| # very little marginal signal. So pushes to NON-PROMOTION branches run the FAST TIER: | |
| # | |
| # FAST TIER (every branch push) structure lint, fmt/clippy/build/test, config-stability, | |
| # public-hygiene. This answers the only questions that matter | |
| # mid-feature: does it build, does it lint, do the tests pass, is | |
| # the layout/config-grammar/public-prose still clean. | |
| # FULL TIER (dev, qa, main, and everything above PLUS openapi-schema, migration-corpus, | |
| # EVERY pull request, and executable-config, no-default-features, no-plugins-gate, | |
| # workflow_dispatch) txn-guards, timing, windows. | |
| # | |
| # THE TRADEOFF, STATED PLAINLY: a Windows-only or featureless-build-only regression is now caught at | |
| # PR time rather than at push time. That is exactly where it was caught before this change, so the | |
| # fast tier costs nothing anyone had; it is pure addition. The cost paid is that a branch with an | |
| # open PR runs the fast tier (push) alongside the full tier (pull_request) for the same commit. The | |
| # two events land in DIFFERENT concurrency groups (`github.ref` is `refs/heads/<branch>` for push and | |
| # `refs/pull/N/merge` for pull_request) and they are deliberately NOT unified: a shared group would | |
| # let the cheap push run cancel the full PR run that the required checks depend on. Four duplicated | |
| # jobs is the accepted price of never again shipping an unverified branch. | |
| # | |
| # ESCAPE HATCH: `workflow_dispatch` forces the FULL tier on any ref, so a feature branch can buy the | |
| # whole gate before opening a PR without waiting for one: | |
| # gh workflow run ci.yml -R GetBusbar/busbar --ref feat/my-branch | |
| # | |
| # `branches:` never matches tag pushes, so the release tag (v1.5.3 et al) still does not run CI here. | |
| on: | |
| push: | |
| branches: ['**'] | |
| pull_request: | |
| workflow_dispatch: | |
| # Without this, every push queues a fully independent run that competes for the same runners | |
| # instead of cancelling the one it superseded -- qa-gate.yml already has this; ci.yml never did, | |
| # so a burst of rapid pushes (e.g. iterating on a CI fix) pays for N full runs instead of 1. | |
| # Doubly load-bearing now that every branch push triggers a run. | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUSTFLAGS: "-D warnings" | |
| jobs: | |
| # WHICH TIER RAN, SAID OUT LOUD. A green check mark is read as "the gate passed", and after this | |
| # change a green check on a feature branch means something WEAKER than a green check on a PR. | |
| # An operator who cannot see the difference will assume the strong one, which is the precise | |
| # mistake this workflow's two tiers otherwise re-introduce in a new place. So every run states its | |
| # own tier in the job summary and names the jobs it did NOT run. Costs one echo; buys the run's | |
| # verdict being self-describing instead of needing someone to remember the rule. | |
| gate-tier: | |
| name: gate tier (what this run actually proved) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Declare the tier | |
| # Ref and event come in as ENV, not as `${{ }}` spliced into the script text. A branch name | |
| # is attacker-influenced data (anyone who can open a PR picks it) and GitHub allows plenty of | |
| # shell metacharacters in one; splicing it into a `[ ... ]` test is a script-injection seam | |
| # that happens to be quiet until someone names a branch to exploit it. | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| REF: ${{ github.ref }} | |
| REF_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT" != "push" ] || \ | |
| [ "$REF" = "refs/heads/main" ] || \ | |
| [ "$REF" = "refs/heads/dev" ] || \ | |
| [ "$REF" = "refs/heads/qa" ]; then | |
| { | |
| echo "### CI tier: FULL" | |
| echo "" | |
| echo "Every CI job ran. Event \`$EVENT\`, ref \`$REF\`." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| { | |
| echo "### CI tier: FAST" | |
| echo "" | |
| echo "Push to a non-promotion branch (\`$REF\`), so this run proved:" | |
| echo "structure lint, fmt/clippy/build/test, config-stability, public-hygiene." | |
| echo "" | |
| echo "It did NOT run: openapi-schema, migration-corpus, executable-config," | |
| echo "no-default-features, no-plugins-gate, txn-guards, timing, windows." | |
| echo "" | |
| echo "Those run on every pull request, on dev/qa/main, and on demand via" | |
| echo "\`gh workflow run ci.yml -R GetBusbar/busbar --ref $REF_NAME\`." | |
| echo "" | |
| echo "A green check here is NOT the full gate. Do not promote on it." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| cat "$GITHUB_STEP_SUMMARY" | |
| # LAYOUT GATE — its OWN job, deliberately. It used to be the first step of `check`, ahead of | |
| # clippy/build/test, which made it a MASK: any layout violation aborted the job before a single | |
| # test ran, and the `Test` step is the only place BUSBAR_TEST_POSTGRES_URL / VALKEY_URL are set | |
| # against the service containers, so the two 1.5.0 store backends' live-DB coverage — the coverage | |
| # the comment on `services:` calls out as load-bearing, hardened to HARD-FAIL rather than skip — | |
| # silently stopped executing on every push and PR. A layout debt and a broken test are independent | |
| # facts about a commit; ordering them in one job means the second is unobservable until the first | |
| # is paid off. Both jobs are required checks, and neither can now hide the other. | |
| structure-lint: | |
| name: structure lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The lint's `#[cfg(test)]` scope scanner decides which lines are EXEMPT from every | |
| # choke-point bypass rule, so a scanner that can be lied to reports "no bypass" while the | |
| # bypass sits in production. Its self-test runs FIRST: never trust the lint's verdict before | |
| # proving the lint still works. | |
| - name: Structure lint self-test | |
| run: scripts/structure-lint.sh --selftest | |
| - name: Structure lint | |
| run: scripts/structure-lint.sh | |
| # RELEASE-SCRIPT lint — durable guard against the 1.5.2 gate's 2h31m hang: a backgrounded | |
| # server (serve_forever) whose stdout was NOT redirected, captured via `$(...)`, held the | |
| # substitution's pipe open until the job timeout. Self-test runs FIRST (never trust the lint's | |
| # verdict before proving the scanner still catches the real antipattern), then it scans | |
| # scripts/release-check*.sh and verifies the 1.5.2 watchdog is intact. | |
| - name: Release-script lint self-test | |
| run: scripts/release-script-lint.sh --selftest | |
| - name: Release-script lint | |
| run: scripts/release-script-lint.sh | |
| # RELEASE-ORDER lint: nothing may be tagged until it has been verified from the consumer side, | |
| # and nothing may be cut from a red commit. Both of those live in the SHAPE of the workflow | |
| # graph (which job depends on which), which is edited by people in a hurry during an incident | |
| # and has no other place to be asserted. Self-test runs FIRST, and it earns its place: it | |
| # caught this lint's own `--draft` rule passing against a release that had had `--draft` | |
| # deleted, because `--draft=false` elsewhere in the file contained the substring. | |
| - name: Release-order lint self-test | |
| run: python3 scripts/release-order-lint.py --selftest | |
| - name: Release-order lint | |
| run: python3 scripts/release-order-lint.py --root . | |
| # NO-SELF-FILED-ISSUES lint: this repository must not open issues against itself. It used to — | |
| # verify-deploy.yml filed #51 when a published release was broken — and that was wrong three | |
| # ways: a robot's issue is indistinguishable from a user's in the queue a human triages, the | |
| # filing step ended on an `echo` so the run stayed GREEN while the release was broken, and a | |
| # long-lived mutable issue needed a whole second job to close it again. The replacement is a | |
| # red check plus a run summary. This lint stops the filing coming back. Self-test runs FIRST, | |
| # and it includes a comment-only fixture because the workflows now EXPLAIN the ban in prose — | |
| # a lint its own explanation fails is a lint people learn to skip. | |
| - name: No-self-filed-issues lint self-test | |
| run: scripts/no-self-filed-issues-lint.sh --selftest | |
| - name: No-self-filed-issues lint | |
| run: scripts/no-self-filed-issues-lint.sh | |
| # AND WATCH IT FAIL. `--prove` fails each job of the release graph in turn and asserts that no | |
| # name-minting job runs afterwards. It is the executable form of "a failure leaves nothing | |
| # public", so that property is re-proven on every push rather than argued once in a comment. | |
| - name: Release-order proof (a failure leaves nothing public) | |
| run: python3 scripts/release-order-lint.py --prove | |
| # CHANGELOG lint: the newest entry carries a version AND a date, always. This has shipped | |
| # wrong more than once and been reported more than once, which is what makes it a gate rather | |
| # than a reminder. The specific fault was a headless blob at the top of the published page: | |
| # the site's `## [Unreleased]` strip was terminated by `\Z`, which JavaScript has no anchor | |
| # for — it matched the literal letter `z` under `/i`, cut the section mid-word, and left the | |
| # remainder on the page with no heading, so no version and no date. That half is fixed in the | |
| # site repo; this guards the source shape the site can only render what it is given. Self-test | |
| # runs FIRST: never trust the lint's verdict before proving the lint still fires. | |
| - name: Changelog lint self-test (every rule proven RED) | |
| run: python3 scripts/changelog-lint.py --selftest | |
| - name: Changelog lint | |
| run: python3 scripts/changelog-lint.py --root . | |
| # WORKSPACE-DEPS lint: one version requirement per external dependency, written once in | |
| # [workspace.dependencies]. The table makes that POSSIBLE; only this lint makes it TRUE — | |
| # nothing in Cargo stops a member re-stating a version, and before the table `hex`, `sha2` and | |
| # `tracing` were each declared two different ways and agreed purely by luck. It sits in the | |
| # FAST tier deliberately: a manifest can drift on any branch push, and this costs one python | |
| # process. Self-test runs FIRST and covers dev-dependencies and | |
| # `[target.'cfg(...)'.dependencies]` explicitly, because a rule enforced only on | |
| # `[dependencies]` is a rule scoped to where the bug was first seen — and the jemalloc pins, | |
| # the ones that differ per shipped target, live in a target table. | |
| - name: Workspace-deps lint self-test | |
| run: python3 scripts/workspace-deps-lint.py --selftest | |
| - name: Workspace-deps lint | |
| run: python3 scripts/workspace-deps-lint.py --root . | |
| # QA-GATE DISPATCHER DRIFT. `workflow_run` ALWAYS loads the workflow file from the DEFAULT | |
| # branch, so the gate that actually fires after a push to `qa` is `main`'s copy, never the | |
| # promoted commit's. That has already cost a silent green: measured on qa c736177 the | |
| # auto-fired gate ran ONE job while the segmentation umbrella sat unused, and the run passed | |
| # having done far less than anyone believed. Gate LOGIC now rides the commit (the dispatcher | |
| # checks out the triggering SHA and runs scripts/qa-gate-run.sh from there), but everything | |
| # GitHub reads BEFORE a checkout exists — `on:`, concurrency, permissions, the needs/if graph, | |
| # the matrix expression — still comes from `main` and cannot. | |
| # | |
| # This compares the PARSED structure, not the bytes, so comments drift freely while the run | |
| # graph may not. That split is what stops the check deadlocking: `main` only moves at a | |
| # release, so a byte-identical rule would make every prose edit red until the very release it | |
| # is meant to gate. Self-test first, as everywhere else here. | |
| - name: qa-gate dispatcher drift self-test | |
| run: python3 scripts/qa-gate-dispatch-lint.py --selftest | |
| # `fetch-depth: 0` is not set on this job's checkout, so origin/main may be absent; fetch it | |
| # explicitly rather than letting the lint fail closed for a reason that is not drift. | |
| - name: Fetch the default branch (the dispatcher that will actually fire) | |
| run: git fetch --no-tags --depth=1 origin main | |
| - name: qa-gate dispatcher drift | |
| run: python3 scripts/qa-gate-dispatch-lint.py | |
| # ARTIFACT CONTRACT wholeness. The contract is data (.github/artifact-contract.json) and the | |
| # verifier implements it, so the two can drift: a row declared with no implementation is a | |
| # property everybody believes is checked and nobody checks, and a check outside the contract is | |
| # invisible to anyone reading it. The guard asserts SET EQUALITY both ways plus a row floor, | |
| # and this self-test proves that guard still discriminates rather than merely returning the | |
| # rows unchanged. Its eight cases each construct one malformation and require it be refused. | |
| - name: Artifact-contract wholeness self-test | |
| run: python3 scripts/verify-artifact.py --selftest | |
| # RELEASE-CHECK VERDICT ACCOUNTING. release-check.sh used to end with an unconditional | |
| # "RELEASE GATE PASSED" banner even when phases had not run at all, so a green gate did not | |
| # prove those phases executed. This selftest proves the two are now distinguishable: a | |
| # coverage gap changes the banner, names the phase, and is fatal under --require-siblings, | |
| # while a by-design segment skip stays a clean pass. Offline, no gate run, seconds. | |
| - name: Release-check verdict self-test | |
| run: scripts/release-check.sh --selftest | |
| # RESPONSE-HEADER lint: every busbar-INJECTED response header | |
| # (`Server-Timing: busbar;dur=`, `x-busbar-route-policy`/`-target`) must be emitted from its ONE | |
| # sanctioned, config-gated site — never a hand-rolled second emission that bypasses the | |
| # `advanced.response_headers` opt-in. Self-test runs FIRST (never trust the lint's verdict before | |
| # proving the scanner still catches a real bypass), then it scans crates/busbar-core/src. | |
| - name: Response-header lint self-test | |
| run: scripts/response-header-lint.sh --selftest | |
| - name: Response-header lint | |
| run: scripts/response-header-lint.sh | |
| # TRACING-SEAM lint: every `#[tracing::instrument]` must carry an explicit | |
| # `level =` so a hot-path span can never again silently default to INFO (always-on). Self-test | |
| # runs FIRST (never trust the lint's verdict before proving the scanner still catches a real | |
| # bypass), then it scans crates/**/*.rs. | |
| - name: Tracing lint self-test | |
| run: scripts/tracing-lint.sh --selftest | |
| - name: Tracing lint | |
| run: scripts/tracing-lint.sh | |
| # SETTINGS-LEAK lint: an admin-facing projection may serve an opaque `settings:` bag's KEY | |
| # NAMES (`settings_keys` / `service::redact_settings_bags`) but NEVER its values — that bag is | |
| # where an operator's credentials legitimately live and the reads are READ-ONLY scope. Added | |
| # after the SAME defect was found in FOUR independent projections (see the script header). | |
| # Self-test runs FIRST (never trust the lint's verdict before proving the scanner still catches | |
| # a real leak), then it scans crates/busbar-core/src/admin. | |
| - name: Settings-leak lint self-test | |
| run: scripts/settings-leak-lint.sh --selftest | |
| - name: Settings-leak lint | |
| run: scripts/settings-leak-lint.sh | |
| # BLOCKING-FFI lint (1.5.3): a synchronous call into a dlopened PLUGIN (`transport_call`, and | |
| # the `dlopen` + constructor before it) must never run inline in an `async fn` — one such call | |
| # parks a Tokio worker for the plugin's full network timeout, and N concurrent callers stop the | |
| # runtime polling anything, `/healthz` included. Added after the SAME defect was found in five | |
| # independent places, the last of them on the ANONYMOUSLY-reachable `/auth/token` (see the | |
| # script header). Self-test runs FIRST (never trust the lint's verdict before proving the | |
| # scanner still catches a real inline call), then it scans crates/busbar-core/src. | |
| - name: Blocking-FFI lint self-test | |
| run: scripts/blocking-ffi-lint.sh --selftest | |
| - name: Blocking-FFI lint | |
| run: scripts/blocking-ffi-lint.sh | |
| # THE REGISTRY GATE (plugins.yaml is the single source of truth for first-party plugins): | |
| # red when a registered plugin lacks coverage anywhere — no qa-gate checkout, no | |
| # release-check phase, no published release (or a phantom release with zero assets) — or | |
| # when a plugin-shaped org repo exists unregistered. Found necessary during the 1.5.0 ship: | |
| # the plugin list was duplicated across 5+ places and the "full plugin gate" silently ran | |
| # 6 of 8. Adding a plugin = one plugins.yaml entry; this gate stays red until every | |
| # consumer actually covers it. | |
| - name: Plugin registry gate | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: scripts/plugin-registry-check.sh | |
| # QA-GATE SEGMENTATION self-test. qa/segments.toml is the umbrella's single | |
| # source of truth (registry-driven fan-out, mirroring plugins.yaml). This self-test proves the | |
| # manifest's SHAPE before any segment is trusted to run: it lists both active and reserved | |
| # entries, the preserved core-data-plane/plugins coverage is present (union ⊇ today's gate), | |
| # every reserved slot is defined-but-inert (PASS/SKIP), and every segment names a run command. | |
| # Same "prove the gate before you trust its verdict" discipline as the lints above. | |
| - name: qa-gate segmentation self-test | |
| run: scripts/qa-segments.sh --selftest | |
| check: | |
| name: fmt · clippy · build · test | |
| runs-on: ubuntu-latest | |
| # Live-DB service containers for the store-postgres / store-valkey roundtrip tests. Without these | |
| # the roundtrip tests skip (their URLs are unset) and the two 1.5.0 store backends ship with ZERO | |
| # CI coverage of the delete_key cascade + credential cleanup. The tests read BUSBAR_TEST_POSTGRES_URL | |
| # / VALKEY_URL (set on the Test step below) and, because `CI` is set in Actions, HARD-FAIL rather | |
| # than silently skip if a service is misconfigured - so this coverage cannot vanish unnoticed. | |
| services: | |
| # PINNED BY DIGEST for the same reason as release.yml's gate: `postgres:16` and | |
| # `valkey/valkey:8` are moving tags, and these containers are load-bearing (without them the | |
| # store roundtrip tests hard-fail rather than skip). What they resolve to must be a fact about | |
| # the commit, not about the day. Re-pin with `docker buildx imagetools inspect <tag>`; the | |
| # monthly-refresh PR is the natural place. | |
| # | |
| # NO `credentials:` HERE, DELIBERATELY, AND IT LEAVES HALF THE HOLE OPEN. Authenticating would | |
| # raise Docker Hub's anonymous per-IP rate limit, which is the half a digest pin cannot fix -- | |
| # but this workflow runs on `pull_request`, where secrets are withheld from fork runs, so an | |
| # unconditional `credentials:` block would hand every fork PR empty credentials. release.yml's | |
| # gate (push to main only) IS authenticated. Closing this half properly means either | |
| # restricting the credentials to non-fork events, which GitHub does not allow on a service | |
| # container, or mirroring both images into GHCR and pulling from there. Written down rather | |
| # than quietly left, because `branch-green` requires this workflow green: a throttled pull | |
| # here stops a release. | |
| postgres: | |
| image: postgres:16@sha256:95206741a5b214807675e14165369d05b93a9cf692223b616d07cca227e74b0b | |
| env: | |
| POSTGRES_USER: busbar | |
| POSTGRES_PASSWORD: busbar | |
| POSTGRES_DB: busbar_test | |
| ports: | |
| - 5432:5432 | |
| # Wait until the DB accepts connections before the job's steps run. | |
| options: >- | |
| --health-cmd "pg_isready -U busbar" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| valkey: | |
| image: valkey/valkey:8@sha256:495e4fecdc98ee48a20b207726caa5ab6451e0fac3642a9be10d9e70b3068df6 | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "valkey-cli ping" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| # NOTE: there used to be a `vault:` service here for busbar-secret-vault / | |
| # busbar-secret-vault-plugin's live-Vault tests. That coverage moved with the crates to | |
| # GetBusbar/hashicorp-vault (its own ci.yml boots the same real hashicorp/vault dev-mode | |
| # container) — see docs/plugins.md and scripts/release-check.sh's Vault phase for how the | |
| # monorepo now gates on that repo's own test suite via a sibling checkout instead. | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| - name: Format check | |
| run: cargo fmt --all -- --check | |
| - name: Clippy | |
| run: cargo clippy --workspace --all-targets --locked -- -D warnings | |
| - name: Build | |
| run: cargo build --workspace --locked --verbose | |
| - name: Test | |
| # Point the store-postgres / store-valkey roundtrip tests at the service containers above so | |
| # they RUN (not skip) in CI. The services publish on localhost via the mapped ports. | |
| env: | |
| BUSBAR_TEST_POSTGRES_URL: postgres://busbar:busbar@localhost:5432/busbar_test | |
| VALKEY_URL: redis://localhost:6379 | |
| # Same two-number floor as the windows job. This is the PRIMARY unix suite; with the | |
| # service containers wired it runs a SUPERSET of the windows workspace (it adds the | |
| # cfg(unix) tests and the postgres/valkey roundtrip tests windows can't run), so it must | |
| # never fall BELOW the windows high-water of 4755. MIN carries slack; EXPECTED warns. | |
| UNIX_MIN_TESTS: "4700" | |
| UNIX_EXPECTED_TESTS: "4783" | |
| run: | | |
| set -uo pipefail | |
| # PIPESTATUS[0] is cargo's own exit; `tee` must not launder a red test run into green. | |
| cargo test --workspace --locked --verbose 2>&1 | tee "$RUNNER_TEMP/unix-test.log" | |
| rc=${PIPESTATUS[0]} | |
| # SUM across every test binary — `test result: ok. N passed` prints once per binary, so a | |
| # single-line grep would floor one binary and miss a whole suite (a cfg gate, a dropped | |
| # target) compiling to nothing while still printing `ok` and exiting 0. | |
| total=$(grep -oE 'test result: (ok|FAILED)\. [0-9]+ passed' "$RUNNER_TEMP/unix-test.log" \ | |
| | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' \ | |
| | awk '{s+=$1} END {print s+0}') | |
| echo "unix: ${total} tests passed across the workspace (floor: ${UNIX_MIN_TESTS}, expected: ${UNIX_EXPECTED_TESTS})" | |
| if [ "$rc" -ne 0 ]; then | |
| echo "::error::cargo test --workspace FAILED on unix (exit ${rc})." | |
| exit "$rc" | |
| fi | |
| if [ "$total" -lt "$UNIX_EXPECTED_TESTS" ]; then | |
| echo "::warning::unix ran ${total} tests, ${UNIX_EXPECTED_TESTS} were expected." | |
| echo "::warning::If tests were legitimately removed or merged, LOWER UNIX_EXPECTED_TESTS in this job." | |
| echo "::warning::If not, something stopped being collected — a cfg gate, a renamed harness, a dropped target." | |
| elif [ "$total" -gt "$UNIX_EXPECTED_TESTS" ]; then | |
| echo "::notice::unix ran ${total} tests, above the recorded ${UNIX_EXPECTED_TESTS}. Raise UNIX_EXPECTED_TESTS." | |
| fi | |
| if [ "$total" -lt "$UNIX_MIN_TESTS" ]; then | |
| echo "::error::the unix test suite ran ${total} tests, below its floor of ${UNIX_MIN_TESTS}." | |
| echo "::error::A workspace whose suites compiled to nothing still prints 'test result: ok' and exits 0." | |
| echo "::error::This is NOT a pass. Either a suite stopped being built/collected, or a cfg gate erased one." | |
| echo "::error::If the drop is intended, LOWER the UNIX_MIN_TESTS floor in this job deliberately." | |
| exit 1 | |
| fi | |
| # A test that writes into the repo is a test that can leak. The docs example harness once | |
| # wrote its patched config and its secret stand-in next to the SHIPPED file it was validating, | |
| # which put four generated artifacts in the working tree, two of them carrying the absolute | |
| # home-directory path of the machine that ran them. They were committed, and only the | |
| # public-hygiene gate downstream noticed. This makes the suite itself prove it wrote nothing: | |
| # scratch belongs in a temp dir, always. | |
| - name: Test suite left the working tree clean (no test writes into the repo) | |
| run: | | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "The test suite modified the working tree. Tests must write scratch to a temp dir." | |
| echo "Offending paths:" | |
| git status --porcelain | |
| git diff --stat | |
| exit 1 | |
| fi | |
| echo "working tree clean after the full test suite" | |
| # OpenAPI schema gate (CI-ONLY `openapi-schema` feature). `openapi_doc()` derives typed response | |
| # schemas via schemars — a dependency deliberately kept OUT of the shipped binary (the `check` job | |
| # above builds/tests WITHOUT the feature, proving that). This job compiles the feature, lints it, | |
| # and runs the openapi tests: the DRIFT GUARD (`openapi_json_matches_committed_file`) fails the PR | |
| # if the committed `openapi.json` — the file the runtime serves via `include_str!` — no longer | |
| # matches what the code generates, and the COVERAGE LOCK proves every operation has a typed body. | |
| # Regenerate a stale file with: | |
| # UPDATE_OPENAPI=1 cargo test -p busbar -p busbar-core --features openapi-schema openapi_json_matches_committed_file | |
| openapi-schema: | |
| name: openapi-schema clippy · drift · coverage | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Clippy (--features openapi-schema) | |
| run: cargo clippy -p busbar -p busbar-core --all-targets --features openapi-schema --locked -- -D warnings | |
| - name: OpenAPI tests (drift guard + coverage lock) | |
| run: | | |
| set -euo pipefail | |
| # FLOOR ON THE MATCH COUNT. `openapi` is a SUBSTRING filter; a filter that matches nothing | |
| # prints "running 0 tests / test result: ok" and EXITS 0. The floor is not 1 — this step | |
| # claims to run the drift guard AND the coverage lock AND their neighbours, so a filter | |
| # that quietly collapsed to a single surviving test would still be a coverage hole. | |
| out="$(cargo test -p busbar -p busbar-core --features openapi-schema --locked openapi -- --nocapture 2>&1 | tee /dev/stderr)" | |
| n="$(echo "$out" | sed -nE 's/^test result: ok\. ([0-9]+) passed.*/\1/p' | awk '{s+=$1} END{print s+0}')" | |
| if [ "$n" -lt 8 ]; then | |
| echo "::error::the OpenAPI suite ran only ${n} tests, expected >= 8." | |
| echo "::error::A zero/low-match 'cargo test' exits 0 — this is NOT a pass. Fix the filter" | |
| echo "::error::(or lower this floor deliberately, in the same commit that removes tests)." | |
| exit 1 | |
| fi | |
| echo "OpenAPI suite: ${n} tests ran (floor 8)" | |
| # CONFIG-STABILITY gate. 1.5.3 is the LAST config-breaking release; after | |
| # it the config grammar is FROZEN and every future feature may add only NEW OPTIONAL keys/sections/ | |
| # enum-variants. This job ENFORCES that per-PR, exactly like the openapi drift guard above but with | |
| # the mechanic that guard lacks: an ADDITIVE-ONLY classifier. The self-test runs FIRST (never trust | |
| # the gate's verdict before proving its RED/GREEN discipline still holds — like every sibling lint), | |
| # then the gate: (1) DRIFT — the committed config-schema.snapshot.json must byte-match a fresh render | |
| # of the config surface (a config-type change that forgot to regen fails loud with UPDATE_CONFIG_SCHEMA=1); | |
| # (2) ADDITIVE-ONLY — the committed baseline (read from a git ref, NEVER the working tree, so a | |
| # snapshot refresh cannot launder a break) vs the fresh render, classified: new optional field / new | |
| # section / enum-append = OK; field removed/retyped, newly-required, enum-drop = FAIL naming the field. | |
| # Every config busbar ever SHIPPED must still migrate to the current shape. The corpus is the real | |
| # `config.yaml` (and companion `providers.yaml`) from every non-rc tag, so this asks the only | |
| # question that matters to an operator upgrading: does the migrator still work for the version I | |
| # am actually on? Motivated by the terraform provider, where a config shape aged out, broke | |
| # that consumer, and nothing noticed until a daily poll went red. | |
| # | |
| # Needs full history: the corpus is checked in, but `refresh.sh` reads tags, and a shallow clone | |
| # would make a regenerated corpus silently smaller rather than failing loudly. | |
| migration-corpus: | |
| name: migration corpus (every shipped config still migrates) | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| - name: Corpus is present and covers the tags | |
| run: | | |
| set -euo pipefail | |
| n=$(find tests/migration-corpus/from-tags -name '*.yaml' | wc -l) | |
| tags=$(git tag | grep -vcE 'rc' || true) | |
| echo "corpus: $n config(s) across $tags non-rc tag(s)" | |
| # A corpus that silently shrank is the failure mode this whole job exists to prevent, so | |
| # an empty or truncated one fails HERE with a clear message rather than passing a test | |
| # that iterated over nothing. | |
| if [ "$n" -lt 20 ]; then | |
| echo "::error::migration corpus has only $n config(s); regenerate with tests/migration-corpus/refresh.sh" | |
| exit 1 | |
| fi | |
| - name: Every shipped config migrates to a valid current config | |
| run: cargo test -p busbar --test migration_corpus --locked -- --nocapture | |
| config-stability: | |
| name: config-stability gate (frozen grammar · additive-only) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # Need history so the additive check can diff against the PR base (not just the tip). | |
| fetch-depth: 0 | |
| # On a PR, the anti-launder baseline is the BASE branch's snapshot (a break committed alongside | |
| # a refreshed snapshot on the PR tip is still caught against the base). On a push, HEAD is the | |
| # baseline (no-op delta). schemars is NOT needed — the gate is a stdlib-python fingerprint, so | |
| # this job needs no Rust toolchain and stays cheap. | |
| - name: Config-stability gate self-test (prove RED/GREEN before trusting the verdict) | |
| run: scripts/config-stability-gate.sh --selftest | |
| - name: Config-stability gate (drift + additive-only) | |
| env: | |
| CONFIG_SCHEMA_BASELINE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || 'HEAD' }} | |
| run: scripts/config-stability-gate.sh --check | |
| # PUBLIC-HYGIENE gate. busbar is sold to enterprises, and this repo, the docs site and the | |
| # generated openapi.json are all public. A customer who finds an internal tracking id, a developer's | |
| # home directory or test-process narration in a shipped file does not conclude "untidy" — they | |
| # conclude the product was assembled by a process they were not shown. | |
| # That is a commercial fact about the product, so it gets a control rather than a sweep. | |
| # | |
| # Each of these is a TEXT class. A manual sweep removes instances; this removes the class, and | |
| # keeps it removed. | |
| # | |
| # Eleven rules, each derived from text really found in this repo's history, each carrying its own | |
| # RED fixture AND a GREEN twin. The false-positive controls are as load-bearing as the rules — the | |
| # vendor names this gateway routes to (Anthropic, OpenAI, Claude, Gemini), technical invariants | |
| # like `fail closed`, and identifiers that merely contain a flagged word are all explicit GREEN | |
| # controls — because a gate that cries wolf gets switched off, and then it protects nothing. | |
| # | |
| # No toolchain and no build: it is pure stdlib python over the file list `git ls-files` reports, | |
| # which is the exact definition of "what the public gets". Self-test FIRST, and a scan that | |
| # discovers ZERO files is a HARD FAILURE, so neither a broken rule table nor a mistyped path can | |
| # read as "clean". The same script runs over every PLUGIN repo via the plugin-ci reusable workflow. | |
| public-hygiene: | |
| name: docs hygiene (public prose describes the software, not the process) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Public-hygiene self-test (every rule proven RED, every twin proven GREEN) | |
| run: python3 scripts/public-hygiene-lint.py --selftest | |
| - name: Public-hygiene gate | |
| run: python3 scripts/public-hygiene-lint.py --root . | |
| # EXECUTABLE-CONFIG gate. Every config-grammar guard that existed before this one was scoped to | |
| # DOCS — `crates/busbar/tests/docs_examples.rs` reads `docs/**`, marketing's check-config-blocks.mjs | |
| # reads what it PUBLISHES. Nothing ever looked at the configs a MACHINE runs: the `cat > config.yaml | |
| # <<EOF` heredocs in CI workflows and shell scripts, the config strings inside Rust integration | |
| # tests, the yaml under examples/ and docker/. Those are exactly the ones that rot, because a docs | |
| # example gets read by a human every release and an e2e heredoc gets read by nobody until an engine | |
| # upgrade refuses to boot it — which is how the 1.5.3 retired-auth-grammar defect reached a long | |
| # list of plugin repos, core's own plugin-ci.yml, signing-gate.sh, release-check.sh, the shipped | |
| # docker/config.yaml and a shipped example, all at once and all invisible. | |
| # | |
| # It judges with the REAL binary (`busbar --validate`, the same mechanism docs_examples.rs uses), so | |
| # it can never drift from `detect_legacy_markers`. Its own job because it needs a compiled busbar; | |
| # only the one binary is built, not the workspace. Self-test FIRST — a scanner that has quietly | |
| # stopped extracting anything would otherwise pass vacuously, which is worse than no gate at all. | |
| # The same script runs over every PLUGIN repo via the plugin-ci reusable workflow, so the fleet | |
| # inherits it with no per-repo work. | |
| executable-config-lint: | |
| name: executable-config gate (heredocs · test literals · shipped yaml) | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| - name: Build busbar (the validator this gate judges with) | |
| run: cargo build --locked --bin busbar | |
| - name: Executable-config gate self-test (RED/GREEN before the verdict is trusted) | |
| run: | | |
| python3 -c "import yaml" 2>/dev/null || pip install --quiet pyyaml | |
| python3 scripts/executable-config-lint.py --busbar target/debug/busbar --selftest | |
| - name: Executable-config gate | |
| # --min-docs: the gate is vacuously green over an empty extraction set. 50 documents are | |
| # found today; the floor is 40 so genuine churn passes and a collapsed extractor cannot. | |
| run: python3 scripts/executable-config-lint.py --busbar target/debug/busbar --root . --min-docs 40 | |
| # Compliance-by-compilation gate: busbar must build + lint clean with the built-in auth plugin | |
| # COMPILED OUT (`--no-default-features`), so a regulated deployment can ship a binary that provably | |
| # contains no such auth code. Build, clippy, AND the test suite all stay green on the featureless | |
| # binary — the feature-dependent behavior tests (admin-token auth, native ranking policies) are | |
| # `#[cfg(feature = ...)]`-gated, so what remains still passes. | |
| # | |
| # SCOPE, STATED HONESTLY: this job proves the featureless binary COMPILES, LINTS, and passes the | |
| # unit tests that remain compiled into it. It does NOT boot the binary and does NOT serve a request, | |
| # so it cannot see a core path that compiles fine and then fails at runtime because the module it | |
| # reaches for is absent. Proving the featureless binary WORKS is the `no-plugins-gate` job below, | |
| # which boots it and drives real HTTP through it. | |
| no-default-features: | |
| name: no-default-features build · clippy · test | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Clippy (no-default-features) | |
| run: cargo clippy --no-default-features --locked -- -D warnings | |
| - name: Build (no-default-features) | |
| run: cargo build --no-default-features --locked --verbose | |
| - name: Test (no-default-features) | |
| # A reduced-feature build must still RUN tests — a feature gate that erases the whole | |
| # no-default-features suite would otherwise print `ok` and exit 0 (vacuous green). The | |
| # count here is deliberately a floor of >0 rather than a measured high-water: this build | |
| # excludes optional features, so its total legitimately differs from the full workspace and | |
| # a tight number would false-red on feature churn. PIPESTATUS[0] is cargo's own exit. | |
| run: | | |
| set -uo pipefail | |
| cargo test --no-default-features --locked --verbose 2>&1 | tee "$RUNNER_TEMP/ndf-test.log" | |
| rc=${PIPESTATUS[0]} | |
| total=$(grep -oE 'test result: (ok|FAILED)\. [0-9]+ passed' "$RUNNER_TEMP/ndf-test.log" \ | |
| | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' \ | |
| | awk '{s+=$1} END {print s+0}') | |
| echo "no-default-features: ${total} tests passed" | |
| if [ "$rc" -ne 0 ]; then | |
| echo "::error::cargo test --no-default-features FAILED (exit ${rc})." | |
| exit "$rc" | |
| fi | |
| if [ "$total" -lt 1 ]; then | |
| echo "::error::the no-default-features suite ran ZERO tests — a feature gate erased it." | |
| echo "::error::A zero-test 'cargo test' prints 'ok' and exits 0. This is NOT a pass." | |
| exit 1 | |
| fi | |
| # THE PROTOCOL DELETION GATE (step 4 of 1.6.0; anthropic, openai-chat and gemini are the | |
| # extracted dialects so far, each run independently, plus mcp on its own leg). Builds the binary WITHOUT ONE extracted protocol | |
| # crate at a time and proves the deletion at boot, not just at compile: the deleted binary | |
| # must come up, answer its operator surface, refuse that dialect's `protocol:` config with the | |
| # unknown-protocol refusal naming the remaining dialects, and still validate the dialects that | |
| # remain — "fewer protocols is a valid busbar" (owner ruling R-D), executed rather than | |
| # asserted. Lives in this job because it is the same reduced-feature axis with the same | |
| # toolchain + cache warm. | |
| - name: Protocol deletion gate | |
| run: scripts/proto-deletion-gate.sh | |
| # THE MECHANICAL DEFINITION OF "PLUGIN". Anything busbar calls a plugin must be 100% a plugin: make | |
| # it downloadable-only tomorrow, ship it uninstalled, and CORE MUST STILL WORK. If core stops | |
| # working, core assumed it always had that module — and that module is not a plugin, it is part of | |
| # the engine wearing a plugin's name. | |
| # | |
| # This job RUNS the binary; `no-default-features` above only compiles it. Two axes, because a plugin | |
| # can be wrongly assumed two independent ways and neither axis can see the other's failures: | |
| # COMPILED OUT (`--no-default-features` — the built-in plugin features are not in the binary) and | |
| # NOT INSTALLED (default features, but `plugins.dir` holds zero artifacts — catches core assuming a | |
| # `dlopen`ed store/auth/hook/secret plugin is on disk). Against a config that references ZERO | |
| # plugins, each axis must boot, serve `/healthz`, ANSWER ITS ADMIN PLANE (real reads and a real | |
| # write — not just the unauthenticated liveness route), and proxy a real request end-to-end to a | |
| # real mock upstream, asserted on the upstream's unique marker. | |
| # | |
| # It has its own job for the reason txn-guards states: a gate nothing runs is not a gate. The | |
| # self-test runs FIRST and is a hard prerequisite — it drives the featureless binary against | |
| # fixtures that genuinely depend on a compiled-out plugin, plus stub servers that pass every earlier | |
| # assertion and break exactly one later one, so a gate that had rotted into passing vacuously fails | |
| # here instead of manufacturing false confidence. | |
| no-plugins-gate: | |
| name: no-plugins gate (compiled out · not installed) | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: No-plugins gate SELF-TEST (prove RED/GREEN before trusting the verdict) | |
| run: scripts/no-plugins-gate.sh --selftest | |
| - name: No-plugins gate (axis 1 compiled out · axis 2 not installed) | |
| run: scripts/no-plugins-gate.sh --check | |
| # CONCURRENCY GATES for the config-mutation transaction (choke point C). Neither of these can run | |
| # inside `cargo test --workspace`: the compile fence must FAIL to build (it is a negative test | |
| # behind the `txn-fence-red` feature) and the loom model explores interleavings exhaustively behind | |
| # `loom-model`, far too slowly for the default suite. A gate nothing runs is not a gate, so they | |
| # get their own job — otherwise the transaction guard could be dismantled with the tree still green. | |
| txn-guards: | |
| name: txn compile fence · loom model | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Transaction compile fence (must fail to compile) | |
| run: scripts/txn-fence.sh | |
| - name: Loom model of the swap invariant | |
| run: scripts/loom.sh | |
| # TIMING GATE: the ignored hot-path latency test, in release mode. Its bounds are deliberately | |
| # generous (25ms p50 / 250ms p99 through the full in-process router+mock round trip) so runner | |
| # noise can never trip it — it exists to catch GROSS hot-path regressions (sync I/O, stray | |
| # sleeps) the instant they land. Fine-grained overhead numbers are bench/latency/'s job. | |
| timing: | |
| name: timing gate (release) | |
| runs-on: ubuntu-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Hot-path timing gate | |
| run: | | |
| set -euo pipefail | |
| # FLOOR ON THE MATCH COUNT. `timing_gate` is a SUBSTRING filter and these tests are | |
| # `#[ignore]`d, so this job is their ONLY execution anywhere. A filter that matches nothing | |
| # prints "running 0 tests / test result: ok" and EXITS 0 — rename the tests and the hot-path | |
| # timing gate is green forever having measured nothing, with no other job to notice. | |
| out="$(cargo test --release --locked timing_gate -- --ignored 2>&1 | tee /dev/stderr)" | |
| echo "$out" | grep -qE 'test result: ok\. [1-9][0-9]* passed' || { | |
| echo "::error::the hot-path timing gate ran ZERO tests (renamed, moved, or un-ignored?)." | |
| echo "::error::A zero-match 'cargo test' exits 0 — this is NOT a pass." | |
| exit 1 | |
| } | |
| # Portability gate: busbar must build + pass tests on Windows too (no OS-specific code). | |
| # | |
| # WINDOWS IS A PUBLISHED RELEASE TARGET, not a nice-to-have: `.github/release-targets.json` ships | |
| # `x86_64-pc-windows-msvc`, which is the DEFAULT HOST TARGET of `windows-latest` — so this job | |
| # builds the same triple the release does, with no `--target` needed. | |
| # | |
| # WHAT THIS JOB DOES NOT PROVE, stated so a green check is not over-read. The most | |
| # platform-sensitive subsystem in the tree is the stdio MCP transport (process spawn, pipes, child | |
| # teardown), and its tests are `#[cfg(unix)]` because the fixture children are `/bin/sh` scripts | |
| # (see crates/busbar-core/src/mcp/client/tests/stdio_tests.rs). They therefore compile to NOTHING here: | |
| # this job is green while the spawn half is unexecuted on Windows. `cargo test` cannot notice that | |
| # — a test that does not exist cannot fail — so the gap is written down rather than inferred. | |
| # Closing it needs cmd.exe fixture children, which is real work and not a CI edit. | |
| windows: | |
| name: windows build · clippy · test | |
| runs-on: windows-latest | |
| # FULL TIER ONLY. Skipped on pushes to non-promotion branches; runs on every pull | |
| # request, on dev/qa/main, and on workflow_dispatch. See the tier note at the top. | |
| if: github.event_name != 'push' || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) | |
| env: | |
| # TWO numbers, in ONE place, so a change to either is unmissable in a diff. | |
| # | |
| # WINDOWS_MIN_TESTS is the HARD floor: below it the job fails. WINDOWS_EXPECTED_TESTS is the | |
| # observed high-water mark: below it the job WARNS but still passes. | |
| # | |
| # Why two rather than one. A single floor pinned at the observed total makes a required check | |
| # on `main` fail every time anyone legitimately consolidates or renames a test, which trains | |
| # people to edit the number without reading it — and a floor nobody reads is a floor that gets | |
| # lowered past a real regression. A single floor with slack, on the other hand, cannot see a | |
| # whole suite disappear. So: the hard floor has slack, and the high-water mark carries the | |
| # sensitivity, as a warning that costs nothing to be wrong about. | |
| # | |
| # BOTH ARE MEASURED, NOT GUESSED. windows-latest observed `4755 tests passed across the | |
| # workspace`. Independently: `cargo test --workspace -- --list` lists 4793 on unix, of which | |
| # 10 are `#[ignore]`d and 24 are erased by cfg(unix) gates -> 4759 predicted, 4755 observed. | |
| # The two agree to within 4 tests. | |
| # | |
| # RAISE THESE AS THE SUITE GROWS. Lowering either is only correct when a diff explains why. | |
| WINDOWS_MIN_TESTS: "4700" | |
| WINDOWS_EXPECTED_TESTS: "4755" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build | |
| run: cargo build --workspace --verbose | |
| # CLIPPY ON WINDOWS, which `build` above does not subsume in either direction. `cargo build` | |
| # compiles lib+bin targets only, so a `#[cfg(windows)]` branch reachable only from a test or an | |
| # example is never even parsed by it; and clippy carries lint passes rustc does not. The | |
| # platform-gated code in this tree is exactly the shape that goes stale unseen — a | |
| # `#[cfg(unix)]` import whose `#[cfg(windows)]` twin was never added is a warning HERE and | |
| # nowhere else. `-D warnings` matches the workspace-wide RUSTFLAGS this workflow already sets, | |
| # so this adds a gate and relaxes none. | |
| - name: Clippy | |
| run: cargo clippy --workspace --all-targets --verbose -- -D warnings | |
| - name: Test | |
| # `shell: bash` is REQUIRED and not a style choice: `run:` on a windows runner defaults to | |
| # pwsh, where none of the pipeline below parses. | |
| shell: bash | |
| run: | | |
| set -uo pipefail | |
| # `tee` keeps the full log in the job output (the point of --verbose) while the same bytes | |
| # are captured for the count. PIPESTATUS[0], not $?, is cargo's own exit status. | |
| cargo test --workspace --verbose 2>&1 | tee "$RUNNER_TEMP/windows-test.log" | |
| rc=${PIPESTATUS[0]} | |
| # SUM across every test binary, not a single grep line: `test result: ok. N passed` | |
| # is printed once PER binary, so a single-line match would floor against one binary and | |
| # miss a whole suite vanishing. Sum every binary's `passed` count. | |
| total=$(grep -oE 'test result: (ok|FAILED)\. [0-9]+ passed' "$RUNNER_TEMP/windows-test.log" \ | |
| | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' \ | |
| | awk '{s+=$1} END {print s+0}') | |
| echo "windows: ${total} tests passed across the workspace (floor: ${WINDOWS_MIN_TESTS}, expected: ${WINDOWS_EXPECTED_TESTS})" | |
| if [ "$rc" -ne 0 ]; then | |
| echo "::error::cargo test --workspace FAILED on windows (exit ${rc})." | |
| exit "$rc" | |
| fi | |
| # The sensitive half: a drop of even one test is surfaced, without failing a required | |
| # check over legitimate churn. A drop nobody can explain is the shape this job exists for. | |
| if [ "$total" -lt "$WINDOWS_EXPECTED_TESTS" ]; then | |
| echo "::warning::windows ran ${total} tests, ${WINDOWS_EXPECTED_TESTS} were expected." | |
| echo "::warning::If tests were legitimately removed or merged, LOWER WINDOWS_EXPECTED_TESTS in this job." | |
| echo "::warning::If not, something stopped being collected — a cfg gate, a renamed harness, a dropped target." | |
| elif [ "$total" -gt "$WINDOWS_EXPECTED_TESTS" ]; then | |
| echo "::notice::windows ran ${total} tests, above the recorded ${WINDOWS_EXPECTED_TESTS}. Raise WINDOWS_EXPECTED_TESTS." | |
| fi | |
| if [ "$total" -lt "$WINDOWS_MIN_TESTS" ]; then | |
| echo "::error::the windows portability gate ran ${total} tests, below its floor of ${WINDOWS_MIN_TESTS}." | |
| echo "::error::A workspace whose suites compiled to nothing still prints 'test result: ok' and exits 0." | |
| echo "::error::This is NOT a pass. Either a suite stopped being built/collected, or a cfg gate erased one." | |
| echo "::error::If the drop is intended, LOWER the WINDOWS_MIN_TESTS floor in this job deliberately." | |
| exit 1 | |
| fi | |
| # THE BLIND SPOT, PRINTED INTO THIS RUN'S OWN SUMMARY. Deliberately NOT a gate — it asserts | |
| # nothing and cannot fail the job — because what it reports is a known, accepted, written-down | |
| # gap and turning it red would only teach people to route around it. | |
| # | |
| # It exists because of an asymmetry no gate can fix from here: a `#[cfg(unix)]` test compiles | |
| # to NOTHING on this runner, so the green check above is green partly because those tests were | |
| # never built, let alone run. `cargo test` cannot notice that — a test that does not exist | |
| # cannot fail — and the gap is therefore invisible at exactly the moment someone reads the | |
| # check mark and concludes "Windows is covered". The comment at the top of this job says so | |
| # once, in a file nobody opens while looking at a green tick; this puts the same sentence, with | |
| # the CURRENT file list rather than a remembered one, in front of whoever is reading the run. | |
| # | |
| # `|| true` on the grep: no match is exit 1 for grep, which would fail the step for the BEST | |
| # possible reason (no unix-only tests left), and a step that fails on success is worse than no | |
| # step at all. | |
| - name: Windows blind spots (informational, never a gate) | |
| if: always() | |
| shell: bash | |
| run: | | |
| { | |
| echo "## Windows blind spots in this run" | |
| echo | |
| echo "\`cargo test\` above passed. These test items are \`#[cfg(unix)]\`, so on this" | |
| echo "runner they compiled to nothing and did NOT run. Green here does not cover them." | |
| echo | |
| echo '```' | |
| grep -rn --include='*.rs' -E '^\s*#!?\[cfg\(unix\)\]' crates/ || true | |
| echo '```' | |
| echo | |
| echo "The largest of these is the stdio MCP transport (spawn, pipes, child teardown):" | |
| echo "its fixture children are \`/bin/sh\` scripts. Closing it needs cmd.exe fixtures," | |
| echo "which is real work and not a CI edit. See docs/operations.md, 'Running on Windows'." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # ── ci umbrella: the SINGLE required status for branch protection on `qa` and `main`. | |
| # | |
| # Branch protection should require ONE context per stage, not a growing list that silently drifts | |
| # when a job is renamed (the `windows build · test` trap: `main` required a context name that | |
| # `dev` had already renamed, so any PR hung forever on a check that could no longer be reported). | |
| # With this umbrella, protection never changes as CI evolves, and a renamed or deleted job can | |
| # never silently drop out of the gate — it turns this job red instead. | |
| # | |
| # `if: always()` so it reports even when a need FAILED, was CANCELLED, or was SKIPPED. It then | |
| # asserts each need's result itself. The rules, and why: | |
| # * failure / cancelled → RED, always. | |
| # * skipped → RED, UNLESS the job is a full-tier-only job AND this is a fast-tier | |
| # run (a feature-branch push). On qa/main/dev/PR/dispatch every job | |
| # runs, so on those refs NOTHING may skip — a skipped required job must | |
| # never read as a pass. This is the whole point of the gate. | |
| # Because the umbrella is a required context ONLY on `qa` and `main` (both FULL_TIER), the | |
| # allowlist below never fires there: it exists solely so a feature-branch push still gets an | |
| # honest-green umbrella without pretending the full tier ran. | |
| # | |
| # It reports ALL offenders (the loop never short-circuits), reads each need's own `result` | |
| # (never a pipe's exit), and fails once at the end with a count. | |
| ci-umbrella: | |
| name: ci umbrella | |
| if: always() | |
| needs: | |
| - gate-tier | |
| - structure-lint | |
| - check | |
| - openapi-schema | |
| - migration-corpus | |
| - config-stability | |
| - public-hygiene | |
| - executable-config-lint | |
| - no-default-features | |
| - no-plugins-gate | |
| - txn-guards | |
| - timing | |
| - windows | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| env: | |
| # 'true' on any run where the full tier is expected to execute: every event except a push, | |
| # plus pushes to the three promotion branches. Mirrors the `if:` on the full-tier jobs above, | |
| # so "expected to run" and "required to have run" cannot drift apart. | |
| FULL_TIER: ${{ (github.event_name != 'push') || contains(fromJSON('["refs/heads/main", "refs/heads/dev", "refs/heads/qa"]'), github.ref) }} | |
| # jobkey|tier|result — tier is 'fast' (always runs) or 'full' (skips on a fast-tier push). | |
| RESULTS: | | |
| gate-tier|fast|${{ needs.gate-tier.result }} | |
| structure-lint|fast|${{ needs.structure-lint.result }} | |
| check|fast|${{ needs.check.result }} | |
| config-stability|fast|${{ needs.config-stability.result }} | |
| public-hygiene|fast|${{ needs.public-hygiene.result }} | |
| openapi-schema|full|${{ needs.openapi-schema.result }} | |
| migration-corpus|full|${{ needs.migration-corpus.result }} | |
| executable-config-lint|full|${{ needs.executable-config-lint.result }} | |
| no-default-features|full|${{ needs.no-default-features.result }} | |
| no-plugins-gate|full|${{ needs.no-plugins-gate.result }} | |
| txn-guards|full|${{ needs.txn-guards.result }} | |
| timing|full|${{ needs.timing.result }} | |
| windows|full|${{ needs.windows.result }} | |
| steps: | |
| - name: Assert every required job reported green (skip-allowlist only on a fast-tier run) | |
| run: | | |
| set -u | |
| fails=0 | |
| if [ "$FULL_TIER" = "true" ]; then | |
| echo "ci umbrella — FULL tier: every job must have run and passed." | |
| else | |
| echo "ci umbrella — fast tier: full-tier-only jobs may be skipped; fast-tier jobs must pass." | |
| fi | |
| while IFS='|' read -r job tier result; do | |
| [ -n "$job" ] || continue | |
| if [ "$result" = "success" ]; then | |
| status="OK" | |
| elif [ "$result" = "skipped" ] && [ "$tier" = "full" ] && [ "$FULL_TIER" != "true" ]; then | |
| status="skipped (full-tier job on a fast-tier run — allowed)" | |
| else | |
| status="RED (${result})" | |
| fails=$((fails + 1)) | |
| fi | |
| printf ' %-24s %s\n' "$job" "$status" | |
| done <<< "$RESULTS" | |
| if [ "$fails" -ne 0 ]; then | |
| echo "::error::ci umbrella: RED — ${fails} required job(s) did not pass on this ref." | |
| echo "::error::A skipped, failed, or cancelled required job is NOT a pass. DO NOT PROMOTE." | |
| exit 1 | |
| fi | |
| echo "ci umbrella: GREEN" |