Skip to content

test(a2a): the client-leg chain tests take the one sink lock, so a co… #1055

test(a2a): the client-leg chain tests take the one sink lock, so a co…

test(a2a): the client-leg chain tests take the one sink lock, so a co… #1055

Workflow file for this run

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"