The gate ran as one serial 30-minute job with no Cargo caching, so every pull request recompiled the workspace roughly eight times over: the zigbuild cross target, the workspace test build, a second full build under clippy-driver, two feature-flip checks, a debug build, a release build for the release-profile loopback, and release feature flips for demo-direct-node and demo-auth-bypass. A documentation-only pull request paid the full thirty minutes for checks it could not affect. Ordering made it worse. The cross build ran first and `cargo fmt --check` ran seventy lines later, so a missing newline cost a complete gate to learn about. verify-demo-build.sh is now the ordered union of named stages, and `--stage` runs one. Passing no argument still runs every stage, so the release and hardware-UAT workflows are unchanged. `--stage quick` is about five seconds. CI runs those stages as parallel legs behind the required `Complete demo gate` summary job, each restoring its own Cargo cache. The summary job fails unless every leg succeeded or was a leg the audited docs-only scope may skip; the lint and repository-test legs always run, which is what keeps the website, docs, and README claim contracts covered on a docs-only diff. verify_ci_cross_build_policy.py previously pinned the single job byte for byte, which blocked speeding the gate up as effectively as it blocked weakening it. It now asserts invariants instead: legs cannot suppress their own failures, always-run legs carry no `if`, skippable legs carry exactly the audited condition, the summary job depends on every other job so a new job cannot be added outside the gate, the docs-only path allowlist is pinned exactly, and no step can poison the toolchain. Its suite grows from 26 tests to 51. Structural checks cannot prove a stage still reaches a check, so scripts/tests/demo-gate-stages.sh executes the real gate against stubbed tools and asserts both that every check in a required inventory runs and that the per-stage runs spend exactly the same commands as the default complete run.
- commit
- 8941f0b20bbeaecf6b7b87dfd1a84624325e3f99
- parent
- c9ba9802ae8027571a73f35c49b6763a6373f289
- author
- Dami <dami.etoile@gmail.com>
| M | .github/ | 425 | ++++++++++++-- |
| M | AGENTS.md | 11 | + |
| A | scripts/ | 71 | ++ |
| A | scripts/ | 226 | +++++++ |
| M | scripts/ | 672 | +++++++++++++--------- |
| M | scripts/ | 355 | +++++++++--- |
| M | scripts/ | 913 | ++++++++++++++++-------------- |
@@ -12,7 +12,313 @@ concurrency:
1212 group: ci-${{ github.workflow }}-${{ github.ref }}1313 cancel-in-progress: true1414 15+# The complete demo gate used to be one serial 30-minute job. It is now the16+# same set of checks split into parallel legs that each restore a Cargo cache,17+# with `demo-gate` as the single required status check: it fails unless every18+# leg either succeeded or was a leg the audited docs-only scope may skip.19+#20+# scripts/verify_ci_cross_build_policy.py enforces that structure, and21+# scripts/tests/demo-gate-stages.sh proves the legs still spend every check the22+# single job used to run. Adding a leg means adding it to `demo-gate.needs`;23+# the policy checker fails the build if you forget.1524 jobs:25+ scope:26+ name: Change scope27+ runs-on: ubuntu-24.0428+ timeout-minutes: 529+ outputs:30+ code: ${{ steps.classify.outputs.code }}31+ steps:32+ - name: Classify the change scope33+ id: classify34+ env:35+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}36+ EVENT_NAME: ${{ github.event_name }}37+ REPOSITORY: ${{ github.repository }}38+ PULL_NUMBER: ${{ github.event.number }}39+ run: |40+ # Fail closed. Only a pull request whose every changed path is41+ # documentation may skip the compile-heavy legs, and the lint and42+ # repository-test legs run unconditionally either way, so the43+ # website, docs, and README claim contracts keep their coverage.44+ if [[ "$EVENT_NAME" != 'pull_request' ]]; then45+ printf 'code=true\n' >>"$GITHUB_OUTPUT"46+ echo 'scope=full (event is not a pull request)'47+ exit 048+ fi49+ if ! changed="$(gh api --paginate \50+ "repos/$REPOSITORY/pulls/$PULL_NUMBER/files" \51+ --jq '.[].filename')"; then52+ printf 'code=true\n' >>"$GITHUB_OUTPUT"53+ echo 'scope=full (could not enumerate the pull request diff)'54+ exit 055+ fi56+ if [[ -z "$changed" ]]; then57+ printf 'code=true\n' >>"$GITHUB_OUTPUT"58+ echo 'scope=full (empty diff listing)'59+ exit 060+ fi61+ code=false62+ while IFS= read -r changed_path; do63+ [[ -n "$changed_path" ]] || continue64+ case "$changed_path" in65+ docs/* | website/* | *.md | LICENSE) ;;66+ *)67+ code=true68+ printf 'code_path=%s\n' "$changed_path"69+ ;;70+ esac71+ done <<<"$changed"72+ printf 'code=%s\n' "$code" >>"$GITHUB_OUTPUT"73+ if [[ "$code" == true ]]; then74+ echo 'scope=full'75+ else76+ echo 'scope=docs-only'77+ fi78+79+ quick-checks:80+ name: Format, syntax, and lint81+ runs-on: ubuntu-24.0482+ timeout-minutes: 1583+ env:84+ CARGO_TERM_COLOR: always85+ RUST_BACKTRACE: "1"86+ steps:87+ - name: Check out repository88+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.189+90+ - name: Install Linux build dependencies91+ run: ./scripts/install-linux-build-deps.sh --profile lint92+93+ - name: Run the quick gate stage94+ run: ./scripts/verify-demo-build.sh --stage quick95+96+ repo-tests:97+ name: Repository and packaging tests98+ runs-on: ubuntu-24.0499+ timeout-minutes: 30100+ env:101+ CARGO_TERM_COLOR: always102+ RUST_BACKTRACE: "1"103+ steps:104+ - name: Check out repository105+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1106+107+ - name: Install Linux build dependencies108+ run: ./scripts/install-linux-build-deps.sh --profile full109+110+ - name: Restore the Cargo build cache111+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4112+ with:113+ path: |114+ ~/.cargo/registry/index115+ ~/.cargo/registry/cache116+ ~/.cargo/git/db117+ target118+ key: cargo-repo-tests-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}119+ restore-keys: |120+ cargo-repo-tests-${{ runner.os }}-121+122+ - name: Run the repository test stage123+ run: ./scripts/verify-demo-build.sh --stage repo-tests124+125+ native-tests:126+ name: Workspace tests and Clippy127+ runs-on: ubuntu-24.04128+ timeout-minutes: 40129+ needs: scope130+ if: needs.scope.outputs.code == 'true'131+ env:132+ CARGO_TERM_COLOR: always133+ RUST_BACKTRACE: "1"134+ steps:135+ - name: Check out repository136+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1137+138+ - name: Install Linux build dependencies139+ run: ./scripts/install-linux-build-deps.sh --profile full140+141+ - name: Restore the Cargo build cache142+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4143+ with:144+ path: |145+ ~/.cargo/registry/index146+ ~/.cargo/registry/cache147+ ~/.cargo/git/db148+ target149+ key: cargo-native-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}150+ restore-keys: |151+ cargo-native-${{ runner.os }}-152+153+ - name: Run the native gate stage154+ run: ./scripts/verify-demo-build.sh --stage native155+156+ cross-build:157+ name: Linux glibc cross build158+ runs-on: ubuntu-24.04159+ timeout-minutes: 40160+ needs: scope161+ if: needs.scope.outputs.code == 'true'162+ env:163+ CARGO_TERM_COLOR: always164+ RUST_BACKTRACE: "1"165+ steps:166+ - name: Check out repository167+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1168+169+ - name: Install Linux build dependencies170+ run: ./scripts/install-linux-build-deps.sh --profile full171+172+ - name: Restore pinned cargo-zigbuild173+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4174+ with:175+ path: ~/.cargo/bin/cargo-zigbuild176+ key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0177+178+ - name: Restore the Cargo build cache179+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4180+ with:181+ path: |182+ ~/.cargo/registry/index183+ ~/.cargo/registry/cache184+ ~/.cargo/git/db185+ target186+ key: cargo-cross-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}187+ restore-keys: |188+ cargo-cross-${{ runner.os }}-189+190+ - name: Install pinned cross-build tools191+ run: |192+ python3 -m venv "$RUNNER_TEMP/zig-venv"193+ printf '%s\n' \194+ 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \195+ | "$RUNNER_TEMP/zig-venv/bin/pip" install \196+ --disable-pip-version-check \197+ --only-binary=:all: \198+ --require-hashes \199+ --requirement /dev/stdin200+ install -d "$RUNNER_TEMP/ci-bin"201+ # Expand runner paths when the generated wrapper executes.202+ # shellcheck disable=SC2016203+ printf '%s\n' \204+ '#!/usr/bin/env bash' \205+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \206+ >"$RUNNER_TEMP/ci-bin/zig"207+ chmod 0755 "$RUNNER_TEMP/ci-bin/zig"208+ echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"209+ export PATH="$RUNNER_TEMP/ci-bin:$PATH"210+ if ! cargo-zigbuild --version 2>/dev/null \211+ | grep -Fxq 'cargo-zigbuild 0.23.0'; then212+ cargo install cargo-zigbuild --locked --version 0.23.0213+ fi214+ test "$(zig version)" = 0.16.0215+ test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'216+217+ - name: Run the Linux cross-build gate stage218+ env:219+ GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"220+ GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"221+ run: ./scripts/verify-demo-build.sh --stage cross222+223+ gstreamer-gate:224+ name: In-process GStreamer gate225+ runs-on: ubuntu-24.04226+ timeout-minutes: 40227+ needs: scope228+ if: needs.scope.outputs.code == 'true'229+ env:230+ CARGO_TERM_COLOR: always231+ RUST_BACKTRACE: "1"232+ steps:233+ - name: Check out repository234+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1235+236+ - name: Install Linux build dependencies237+ run: ./scripts/install-linux-build-deps.sh --profile full238+239+ - name: Restore the Cargo build cache240+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4241+ with:242+ path: |243+ ~/.cargo/registry/index244+ ~/.cargo/registry/cache245+ ~/.cargo/git/db246+ target247+ key: cargo-gstreamer-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}248+ restore-keys: |249+ cargo-gstreamer-${{ runner.os }}-250+251+ - name: Run the in-process GStreamer gate stage252+ env:253+ GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"254+ run: ./scripts/verify-demo-build.sh --stage gstreamer255+256+ loopback:257+ name: Loopback proof ${{ matrix.case }}258+ runs-on: ubuntu-24.04259+ timeout-minutes: 40260+ needs: scope261+ if: needs.scope.outputs.code == 'true'262+ strategy:263+ fail-fast: false264+ matrix:265+ case: [1, 2, 3, 4]266+ env:267+ CARGO_TERM_COLOR: always268+ RUST_BACKTRACE: "1"269+ steps:270+ - name: Check out repository271+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1272+273+ - name: Install Linux build dependencies274+ run: ./scripts/install-linux-build-deps.sh --profile full275+276+ - name: Restore the Cargo build cache277+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4278+ with:279+ path: |280+ ~/.cargo/registry/index281+ ~/.cargo/registry/cache282+ ~/.cargo/git/db283+ target284+ key: cargo-loopback-${{ matrix.case }}-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}285+ restore-keys: |286+ cargo-loopback-${{ matrix.case }}-${{ runner.os }}-287+288+ - name: Run the loopback proof289+ run: ./scripts/verify-demo-build.sh --stage loopback --loopback-case ${{ matrix.case }}290+291+ release-containment:292+ name: Release-profile bypass containment293+ runs-on: ubuntu-24.04294+ timeout-minutes: 40295+ needs: scope296+ if: needs.scope.outputs.code == 'true'297+ env:298+ CARGO_TERM_COLOR: always299+ RUST_BACKTRACE: "1"300+ steps:301+ - name: Check out repository302+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1303+304+ - name: Install Linux build dependencies305+ run: ./scripts/install-linux-build-deps.sh --profile full306+307+ - name: Restore the Cargo build cache308+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4309+ with:310+ path: |311+ ~/.cargo/registry/index312+ ~/.cargo/registry/cache313+ ~/.cargo/git/db314+ target315+ key: cargo-containment-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}316+ restore-keys: |317+ cargo-containment-${{ runner.os }}-318+319+ - name: Run the release containment stage320+ run: ./scripts/verify-demo-build.sh --stage containment321+16322 decky-gate:17323 name: Decky plugin gate18324 runs-on: ubuntu-24.04@@ -54,6 +360,8 @@ jobs:
54360 name: Portal ${{ matrix.target }} compile guard55361 runs-on: ${{ matrix.os }}56362 timeout-minutes: 40363+ needs: scope364+ if: needs.scope.outputs.code == 'true'57365 strategy:58366 fail-fast: false59367 matrix:@@ -86,73 +394,62 @@ jobs:
86394 demo-gate:87395 name: Complete demo gate88396 runs-on: ubuntu-24.0489- timeout-minutes: 45397+ timeout-minutes: 5398+ # Runs even when a leg fails, because this job is what reports that399+ # failure. It never passes on its own: the step below inspects every400+ # dependency result and exits non-zero unless each one is accounted for.401+ if: always()402+ needs:403+ - scope404+ - quick-checks405+ - repo-tests406+ - native-tests407+ - cross-build408+ - gstreamer-gate409+ - loopback410+ - release-containment411+ - decky-gate412+ - portal-target-matrix90413 env:91414 CARGO_TERM_COLOR: always92415 RUST_BACKTRACE: "1"93416 steps:94- - name: Check out repository95- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.196-97- - name: Install Linux build dependencies98- run: |99- sudo apt-get update100- sudo apt-get install --yes --no-install-recommends \101- build-essential \102- file \103- ffmpeg \104- gstreamer1.0-plugins-base \105- gstreamer1.0-plugins-bad \106- gstreamer1.0-plugins-good \107- gstreamer1.0-plugins-ugly \108- gstreamer1.0-tools \109- libayatana-appindicator3-dev \110- libgstreamer-plugins-base1.0-dev \111- libgstreamer1.0-dev \112- librsvg2-dev \113- libssl-dev \114- libudev-dev \115- libwebkit2gtk-4.1-dev \116- libxdo-dev \117- pkg-config \118- python3-venv \119- shellcheck120-121- - name: Restore pinned cargo-zigbuild122- uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4123- with:124- path: ~/.cargo/bin/cargo-zigbuild125- key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0126-127- - name: Install pinned cross-build tools417+ - name: Require every gate leg to have passed418+ env:419+ LEG_RESULTS: ${{ toJSON(needs) }}420+ SCOPE_CODE: ${{ needs.scope.outputs.code }}128421 run: |129- python3 -m venv "$RUNNER_TEMP/zig-venv"130- printf '%s\n' \131- 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \132- | "$RUNNER_TEMP/zig-venv/bin/pip" install \133- --disable-pip-version-check \134- --only-binary=:all: \135- --require-hashes \136- --requirement /dev/stdin137- install -d "$RUNNER_TEMP/ci-bin"138- # Expand runner paths when the generated wrapper executes.139- # shellcheck disable=SC2016140- printf '%s\n' \141- '#!/usr/bin/env bash' \142- 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \143- >"$RUNNER_TEMP/ci-bin/zig"144- chmod 0755 "$RUNNER_TEMP/ci-bin/zig"145- echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"146- export PATH="$RUNNER_TEMP/ci-bin:$PATH"147- if ! cargo-zigbuild --version 2>/dev/null \148- | grep -Fxq 'cargo-zigbuild 0.23.0'; then149- cargo install cargo-zigbuild --locked --version 0.23.0422+ # A leg may only be missing when the audited docs-only scope skipped423+ # it. Every other non-success result, including cancelled, fails the424+ # required check.425+ skippable='native-tests cross-build gstreamer-gate loopback release-containment portal-target-matrix'426+ docs_only=false427+ if [[ "$SCOPE_CODE" == 'false' ]]; then428+ docs_only=true150429 fi151- test "$(zig version)" = 0.16.0152- test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'153-154- - name: Run complete demo gate155- env:156- GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"157- GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"158- run: ./scripts/verify-demo-build.sh430+ failed=false431+ while IFS=$'\t' read -r leg result; do432+ [[ -n "$leg" ]] || continue433+ case "$result" in434+ success)435+ printf 'leg_passed=%s\n' "$leg"436+ ;;437+ skipped)438+ if [[ "$docs_only" == true && " $skippable " == *" $leg "* ]]; then439+ printf 'leg_skipped_docs_only=%s\n' "$leg"440+ else441+ printf 'leg_must_not_be_skipped=%s\n' "$leg"442+ failed=true443+ fi444+ ;;445+ *)446+ printf 'leg_failed=%s result=%s\n' "$leg" "$result"447+ failed=true448+ ;;449+ esac450+ done < <(jq -r 'to_entries[] | "\(.key)\t\(.value.result)"' <<<"$LEG_RESULTS")451+ if [[ "$failed" == true ]]; then452+ echo 'complete demo gate failed' >&2453+ exit 1454+ fi455+ echo 'complete_demo_gate=ok'@@ -202,6 +202,17 @@ Sigil host.
202202 frontend syntax/tests, ShellCheck, package tests, loopback transport203203 including three-viewer admission and slot-0 focus handoff, legacy204204 second-client rejection, and release-profile containment of `--dev-connect`.205+- The gate is the union of ordered stages, and `--stage` runs one of them:206+ `quick` `cross` `native` `gstreamer` `repo-tests` `loopback` `containment`.207+ Start with `./scripts/verify-demo-build.sh --stage quick`; it is a few208+ seconds and catches format, frontend syntax, ShellCheck, and whitespace209+ damage before you spend a compile on them. `--stage loopback210+ --loopback-case N` runs one of the four loopback proofs. Passing no argument211+ still runs every stage, so a release or hardware-UAT invocation is unchanged.212+ CI runs the same stages as parallel legs behind the required213+ `Complete demo gate` check; `scripts/verify_ci_cross_build_policy.py` and214+ `scripts/tests/demo-gate-stages.sh` are what keep those legs equivalent to215+ the complete gate, so run the repository shell tests after touching either.205216 - Run `./scripts/verify-website.sh` for every website or public installer206217 change. Exercise interactive website changes in a real browser.207218 - Treat `docs/fresh-bazzite-host.md`, `docs/public-release-delivery.md`, and@@ -0,0 +1,71 @@
1+2+set -euo pipefail3+4+# Install the Linux build and runtime dependencies the complete demo gate needs.5+#6+# CI runs the gate as several parallel jobs, and every job that compiles Sigil7+# or Portal needs the same package set. Keeping the list here rather than8+# duplicated per job means a dependency can only be added or removed in one9+# reviewable place, and ShellCheck covers it like every other repository script.10+#11+# --profile full Everything the Rust workspace, GStreamer gate, and12+# loopback proofs need (default).13+# --profile lint Only what format, syntax, and ShellCheck passes need.14+15+profile=full16+17+while [[ $# -gt 0 ]]; do18+ case "$1" in19+ --profile)20+ [[ $# -ge 2 ]] || {21+ printf -- '--profile requires a value\n' >&222+ exit 223+ }24+ profile="$2"25+ shift 226+ ;;27+ *)28+ printf 'unknown argument: %s\n' "$1" >&229+ exit 230+ ;;31+ esac32+done33+34+lint_packages=(35+ shellcheck36+)37+38+full_packages=(39+ build-essential40+ file41+ ffmpeg42+ gstreamer1.0-plugins-base43+ gstreamer1.0-plugins-bad44+ gstreamer1.0-plugins-good45+ gstreamer1.0-plugins-ugly46+ gstreamer1.0-tools47+ libayatana-appindicator3-dev48+ libgstreamer-plugins-base1.0-dev49+ libgstreamer1.0-dev50+ librsvg2-dev51+ libssl-dev52+ libudev-dev53+ libwebkit2gtk-4.1-dev54+ libxdo-dev55+ pkg-config56+ python3-venv57+ shellcheck58+)59+60+case "$profile" in61+ full) packages=("${full_packages[@]}") ;;62+ lint) packages=("${lint_packages[@]}") ;;63+ *)64+ printf -- '--profile must be full or lint\n' >&265+ exit 266+ ;;67+esac68+69+sudo apt-get update70+sudo apt-get install --yes --no-install-recommends "${packages[@]}"71+printf 'linux_build_deps=%s\n' "$profile"@@ -0,0 +1,226 @@
1+2+set -euo pipefail3+4+# Prove that splitting the complete demo gate into stages did not drop a check.5+#6+# The gate is now run by CI as parallel legs, one stage each, so the dangerous7+# regression is a check that belongs to no stage and therefore silently stops8+# running. This exercises the real gate script against stubbed tools in a9+# throwaway repository and asserts two things:10+#11+# 1. every check in a required inventory actually executes, and12+# 2. running the stages one at a time spends exactly the same commands as the13+# default complete run.14+#15+# Static structure is checked separately in verify_ci_cross_build_policy.py.16+# This is the dynamic half, and it is what catches an early `exit 0` or a17+# conditional wrapped around a stage body.18+19+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"20+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"21+temp_parent="${TMPDIR:-/tmp}"22+temp_parent="${temp_parent%/}"23+temp_root="$(mktemp -d "$temp_parent/goq-demo-gate-stages.XXXXXX")"24+25+cleanup() {26+ local status=$?27+ trap - EXIT INT TERM HUP28+ case "$temp_root" in29+ "$temp_parent"/goq-demo-gate-stages.??????) rm -rf -- "$temp_root" ;;30+ esac31+ exit "$status"32+}33+trap cleanup EXIT INT TERM HUP34+35+fake_repo="$temp_root/repo"36+stub_bin="$temp_root/bin"37+fake_home="$temp_root/home"38+mkdir -p "$fake_repo/scripts" "$fake_repo/portal" "$stub_bin" "$fake_home"39+40+# Stub logs live outside the fake repository so they cannot make41+# `git diff --check` see a dirty worktree.42+#43+# The generated stub bodies below deliberately keep `$*` and `$GOQ_STAGE_LOG`44+# unexpanded: they must expand when the stub runs, not when it is written.45+write_stub() {46+ local name="$1"47+ shift48+ {49+ printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail'50+ # shellcheck disable=SC201651+ printf 'printf "%%s\\n" "%s $*" >>"$GOQ_STAGE_LOG"\n' "$name"52+ printf '%s\n' "$@"53+ } >"$stub_bin/$name"54+ chmod 0755 "$stub_bin/$name"55+}56+57+# `cargo test ... -- --list` output has to satisfy require_rust_test, and58+# `cargo tree` output has to satisfy the dependency boundary greps.59+# shellcheck disable=SC201660+write_stub cargo '61+for arg in "$@"; do62+ if [[ "$arg" == "--list" ]]; then63+ printf "%s\n" \64+ "stub::resolves_pinned_upstream_gamescope_pipewire_contract: test" \65+ "stub::encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr: test" \66+ "stub::in_process_gstreamer_x264_smoke: test"67+ exit 068+ fi69+done70+if [[ "${1:-}" == "tree" ]]; then71+ printf "%s\n" "stub-crate v0.0.0"72+ exit 073+fi74+if [[ "${1:-}" == "--version" ]]; then75+ printf "%s\n" "cargo 1.95.0 (stub)"76+fi77+exit 0'78+write_stub rustc 'printf "%s\n" "rustc 1.95.0 (stub)"'79+write_stub node 'exit 0'80+write_stub ffmpeg 'exit 0'81+write_stub shellcheck 'exit 0'82+write_stub gst-inspect-1.0 'exit 0'83+write_stub cargo-zigbuild 'printf "%s\n" "cargo-zigbuild 0.23.0"'84+write_stub zig 'printf "%s\n" "0.16.0"'85+86+# Helper scripts the gate shells out to become logging stubs too, so their87+# arguments are observable without running a real loopback session.88+write_helper() {89+ local relative="$1"90+ local name="$2"91+ {92+ printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail'93+ # shellcheck disable=SC201694+ printf 'printf "%%s\\n" "%s $*" >>"$GOQ_STAGE_LOG"\n' "$name"95+ printf '%s\n' 'exit 0'96+ } >"$fake_repo/$relative"97+ chmod 0755 "$fake_repo/$relative"98+}99+100+write_helper scripts/run-linux-cross-build-gate.sh cross-build-gate101+write_helper scripts/loopback-proof.sh loopback-proof102+write_helper scripts/run-shell-tests.sh run-shell-tests103+104+cp "$repo_dir/scripts/verify-demo-build.sh" "$fake_repo/scripts/verify-demo-build.sh"105+chmod 0755 "$fake_repo/scripts/verify-demo-build.sh"106+107+printf '%s\n' 'export const stub = 1;' >"$fake_repo/portal/main.js"108+printf '%s\n' 'export const stub = 2;' >"$fake_repo/portal/diagnostics.mjs"109+printf '%s\n' 'export const stub = 3;' >"$fake_repo/portal/stub.test.mjs"110+111+GIT_AUTHOR_NAME=stub GIT_AUTHOR_EMAIL=stub@example.invalid \112+ GIT_COMMITTER_NAME=stub GIT_COMMITTER_EMAIL=stub@example.invalid \113+ git -C "$fake_repo" -c init.defaultBranch=main init --quiet114+git -C "$fake_repo" add -A115+GIT_AUTHOR_NAME=stub GIT_AUTHOR_EMAIL=stub@example.invalid \116+ GIT_COMMITTER_NAME=stub GIT_COMMITTER_EMAIL=stub@example.invalid \117+ git -C "$fake_repo" commit --quiet -m 'stub repository'118+119+run_gate() {120+ local log="$1"121+ shift122+ : >"$log"123+ # HOME points at an empty directory so the gate cannot source a real124+ # ~/.cargo/env and put the genuine toolchain ahead of the stubs on PATH.125+ env -i \126+ HOME="$fake_home" \127+ PATH="$stub_bin:/usr/bin:/bin:/usr/sbin:/sbin" \128+ GOQ_STAGE_LOG="$log" \129+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=1 \130+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \131+ LC_ALL=C \132+ bash "$fake_repo/scripts/verify-demo-build.sh" "$@" \133+ >"$log.stdout" 2>"$log.stderr" || {134+ printf 'gate invocation failed: %s\n' "$*" >&2135+ sed -n '1,60p' "$log.stderr" >&2136+ exit 1137+ }138+}139+140+complete_log="$temp_root/complete.log"141+run_gate "$complete_log"142+143+if ! grep -Fxq 'demo_build_preflight=ok' "$complete_log.stdout"; then144+ printf 'the complete gate did not report success\n' >&2145+ exit 1146+fi147+148+# Every check the single serial job used to run. A stage that stops reaching one149+# of these fails here rather than silently passing in CI.150+required_commands=(151+ 'cargo fmt --all -- --check'152+ 'cargo test --locked --workspace --all-targets'153+ 'cargo clippy --locked --workspace --all-targets -- -D warnings'154+ 'cargo check --locked -p portal --all-targets --features experimental-non-macos-pointer-capture'155+ 'cargo build --locked -p sigil-host --bin sigil'156+ 'cargo tree --locked -p sigil-host --edges normal'157+ 'cargo tree --locked -p portal --edges normal'158+ 'cargo check --locked -p sigil-host --all-targets --features in-process-gstreamer'159+ 'cargo test --locked -p sigil-host --features in-process-gstreamer in_process_gstreamer_x264_smoke -- --ignored --nocapture'160+ 'cargo test --locked -p sigil-host encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr'161+ 'cargo test --locked -p portal --release commands::state::tests::rejects_direct_node_when_debug_mode_is_disabled -- --exact'162+ 'cargo test --locked -p portal --release commands::state::tests::ordinary_release_excludes_direct_node_bypass -- --exact'163+ 'cargo test --locked -p portal --release --features demo-direct-node commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds -- --exact'164+ 'cargo test --locked -p sigil-host --release tests::ordinary_release_excludes_configured_host_auth_bypass -- --exact'165+ 'cargo test --locked -p sigil-host --release --features demo-auth-bypass tests::configured_host_auth_bypass_is_explicitly_build_contained -- --exact'166+ 'cross-build-gate '167+ 'run-shell-tests scripts/tests'168+ 'shellcheck '169+ 'node --check portal/main.js'170+ 'node --check portal/diagnostics.mjs'171+ 'node --test portal/stub.test.mjs'172+ 'gst-inspect-1.0 videotestsrc'173+ 'gst-inspect-1.0 x264enc'174+ 'gst-inspect-1.0 appsink'175+ 'loopback-proof '176+ 'loopback-proof --profile debug --control-v2 --viewers 3 --primary-frames 600 --reconnect-cycles 3'177+ 'loopback-proof --profile debug --control-v2 --viewers 3 --focus-handoffs 3 --assert-neutral-before-successor --primary-frames 600 --reconnect-cycles 3'178+ 'loopback-proof --profile release --legacy-exclusive --viewers 2 --expect-second-rejected'179+)180+181+missing=0182+for required in "${required_commands[@]}"; do183+ if ! grep -Fq -- "$required" "$complete_log"; then184+ printf 'the complete gate never ran: %s\n' "$required" >&2185+ missing=1186+ fi187+done188+if (( missing != 0 )); then189+ printf 'complete gate command log follows\n' >&2190+ sed -n '1,200p' "$complete_log" >&2191+ exit 1192+fi193+194+# Running the stages one at a time must spend exactly the same commands as the195+# default complete run. This is what makes the parallel CI legs equivalent to196+# the serial job they replaced.197+union_log="$temp_root/union.log"198+: >"$union_log"199+for stage in quick cross native gstreamer repo-tests containment; do200+ stage_log="$temp_root/stage-$stage.log"201+ run_gate "$stage_log" --stage "$stage"202+ cat "$stage_log" >>"$union_log"203+done204+for case_number in 1 2 3 4; do205+ stage_log="$temp_root/stage-loopback-$case_number.log"206+ run_gate "$stage_log" --stage loopback --loopback-case "$case_number"207+ cat "$stage_log" >>"$union_log"208+done209+210+# Each invocation prints its own toolchain banner, so ten stage runs report it211+# ten times. That is diagnostics rather than a check, so compare the checks.212+strip_banner() {213+ grep -Fvx -e 'cargo --version' -e 'rustc --version' "$1" | sort214+}215+216+strip_banner "$complete_log" >"$temp_root/complete.sorted"217+strip_banner "$union_log" >"$temp_root/union.sorted"218+if ! diff -u "$temp_root/complete.sorted" "$temp_root/union.sorted" \219+ >"$temp_root/union.diff"; then220+ printf 'the per-stage runs do not spend the same commands as the complete gate\n' >&2221+ printf '(-) only in the complete run, (+) only in the per-stage runs\n' >&2222+ sed -n '1,80p' "$temp_root/union.diff" >&2223+ exit 1224+fi225+226+printf 'demo_gate_stage_coverage=ok\n'@@ -7,9 +7,7 @@ from scripts.verify_ci_cross_build_policy import PolicyError, verify, verify_gat
77 88 99 REPO_DIR = Path(__file__).resolve().parents[2]10-WORKFLOW = (REPO_DIR / ".github" / "workflows" / "ci.yml").read_text(11- encoding="utf-8"12-)10+WORKFLOW = (REPO_DIR / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")1311 GATE_SCRIPT = (REPO_DIR / "scripts" / "verify-demo-build.sh").read_text(1412 encoding="utf-8"1513 )@@ -21,377 +19,487 @@ def replace_once(source: str, old: str, new: str) -> str:
2119 return source.replace(old, new, 1)2220 2321 24-def remove_named_step(source: str, name: str, next_name: str) -> tuple[str, str]:25- marker = f" - name: {name}\n"26- next_marker = f" - name: {next_name}\n"27- start = source.index(marker)28- end = source.index(next_marker, start)29- return source[:start] + source[end:], source[start:end]22+def job_marker(name: str) -> str:23+ return f" {name}:\n"24+25+26+def add_job_field(source: str, job: str, field: str) -> str:27+ """Insert a field as the first entry of the named job."""28+ return replace_once(source, job_marker(job), f"{job_marker(job)} {field}\n")3029 3130 32-def replace_once_in_demo_gate(source: str, old: str, new: str) -> str:33- prefix, marker, demo_gate = source.partition(" demo-gate:\n")34- if not marker:35- raise AssertionError("fixture source must contain the demo-gate job")36- return prefix + marker + replace_once(demo_gate, old, new)31+def in_job(source: str, job: str, old: str, new: str) -> str:32+ """Replace the first occurrence of `old` at or after the named job."""33+ start = source.index(job_marker(job))34+ offset = source.index(old, start)35+ return source[:offset] + new + source[offset + len(old) :]3736 3837 39-class CiCrossBuildPolicyTests(unittest.TestCase):40- def test_repository_workflow_passes(self) -> None:38+class WorkflowStructureTests(unittest.TestCase):39+ def test_repository_workflow_and_gate_script_pass(self) -> None:4140 verify(WORKFLOW)4241 verify_gate_script(GATE_SCRIPT)4342 44- def test_rejects_cross_build_helper_inside_false_branch(self) -> None:45- mutated = replace_once(46- GATE_SCRIPT,47- "./scripts/run-linux-cross-build-gate.sh",48- "if false; then\n./scripts/run-linux-cross-build-gate.sh\nfi",49- )50- with self.assertRaisesRegex(51- PolicyError, "executable prefix changed"52- ):53- verify_gate_script(mutated)43+ def test_rejects_extra_top_level_field(self) -> None:44+ mutated = replace_once(WORKFLOW, "name: CI\n", "name: CI\nenv:\n X: '1'\n")45+ with self.assertRaisesRegex(PolicyError, "top-level fields changed"):46+ verify(mutated)5447 55- def test_rejects_successful_early_exit_before_cross_build_helper(self) -> None:48+ def test_rejects_failure_suppressing_workflow_shell_default(self) -> None:5649 mutated = replace_once(57- GATE_SCRIPT,58- "./scripts/run-linux-cross-build-gate.sh",59- "if true; then\n exit 0\nfi\n"60- "./scripts/run-linux-cross-build-gate.sh",50+ WORKFLOW,51+ "name: CI\n\non:",52+ "name: CI\n\ndefaults:\n run:\n shell: 'bash {0} || true'\n\non:",6153 )62- with self.assertRaisesRegex(PolicyError, "executable prefix changed"):63- verify_gate_script(mutated)54+ with self.assertRaisesRegex(PolicyError, "workflow-level defaults are forbidden"):55+ verify(mutated)56+57+ def test_rejects_missing_pull_request_trigger(self) -> None:58+ mutated = replace_once(WORKFLOW, " pull_request:\n", "")59+ with self.assertRaisesRegex(PolicyError, "empty pull_request trigger"):60+ verify(mutated)6461 65- def test_rejects_failure_control_changes_before_cross_build_helper(self) -> None:66- prefixes = [67- "exit 0",68- "return 0",69- "trap 'exit 0' EXIT",70- "exec true",71- "set +e",72- ]73- for prefix in prefixes:74- with self.subTest(prefix=prefix):62+ def test_rejects_filtered_pull_request_trigger(self) -> None:63+ for filter_line in (64+ " branches: [main]",65+ " paths: ['crates/**']",66+ " types: [opened]",67+ ):68+ with self.subTest(filter_line=filter_line):7569 mutated = replace_once(76- GATE_SCRIPT,77- "./scripts/run-linux-cross-build-gate.sh",78- f"{prefix}\n./scripts/run-linux-cross-build-gate.sh",70+ WORKFLOW, " pull_request:\n", f" pull_request:\n{filter_line}\n"7971 )80- with self.assertRaisesRegex(81- PolicyError, "executable prefix changed"82- ):83- verify_gate_script(mutated)72+ with self.assertRaisesRegex(PolicyError, "exactly empty and unfiltered"):73+ verify(mutated)8474 85- def test_rejects_failure_masked_cross_build_helper(self) -> None:75+ def test_rejects_inline_pull_request_mapping(self) -> None:76+ mutated = replace_once(WORKFLOW, " pull_request:", " pull_request: {}")77+ with self.assertRaisesRegex(PolicyError, "exactly empty and unfiltered"):78+ verify(mutated)79+80+ def test_rejects_widened_push_trigger(self) -> None:8681 mutated = replace_once(87- GATE_SCRIPT,88- "./scripts/run-linux-cross-build-gate.sh",89- "./scripts/run-linux-cross-build-gate.sh || true",82+ WORKFLOW, " branches: [main]", " branches: [main, release]"9083 )91- with self.assertRaisesRegex(PolicyError, "executable prefix changed"):92- verify_gate_script(mutated)84+ with self.assertRaisesRegex(PolicyError, "limited to main"):85+ verify(mutated)9386 94- def test_rejects_demo_gate_run_bypass_even_with_decoy_comment(self) -> None:95- mutated = replace_once(96- WORKFLOW,97- " run: ./scripts/verify-demo-build.sh",98- " run: echo bypassed",87+ def test_rejects_unparseable_workflow(self) -> None:88+ mutated = replace_once(WORKFLOW, "permissions:\n", "permissions:\n\t- bogus\n")89+ with self.assertRaises(PolicyError):90+ verify(mutated)91+92+93+class JobInventoryTests(unittest.TestCase):94+ def test_rejects_job_added_outside_the_gate(self) -> None:95+ mutated = WORKFLOW + (96+ "\n sneaky:\n"97+ " runs-on: ubuntu-24.04\n"98+ " steps:\n"99+ " - name: Do something unreviewed\n"100+ " run: echo unexpected\n"99101 )100- mutated += "\n# run: ./scripts/verify-demo-build.sh\n"101- with self.assertRaisesRegex(102- PolicyError, "does not execute the repository gate"103- ):102+ with self.assertRaisesRegex(PolicyError, "jobs changed from the CI policy"):104103 verify(mutated)105104 106- def test_rejects_conditionally_disabled_cross_install(self) -> None:105+ def test_rejects_removed_gate_leg(self) -> None:106+ start = WORKFLOW.index(job_marker("release-containment"))107+ end = WORKFLOW.index(job_marker("decky-gate"))108+ mutated = WORKFLOW[:start] + WORKFLOW[end:]109+ with self.assertRaisesRegex(PolicyError, "jobs changed from the CI policy"):110+ verify(mutated)111+112+ def test_rejects_summary_job_missing_a_dependency(self) -> None:113+ mutated = replace_once(WORKFLOW, " - release-containment\n", "")114+ with self.assertRaisesRegex(PolicyError, "must depend on every other job"):115+ verify(mutated)116+117+ def test_rejects_duplicate_summary_dependency(self) -> None:107118 mutated = replace_once(108119 WORKFLOW,109- " - name: Install pinned cross-build tools\n run: |",110- " - name: Install pinned cross-build tools\n"111- " if: false\n"112- " run: |",120+ " - release-containment\n",121+ " - release-containment\n - release-containment\n",113122 )114- with self.assertRaisesRegex(PolicyError, "unexpected or missing fields"):123+ with self.assertRaisesRegex(PolicyError, "duplicate"):115124 verify(mutated)116125 117- def test_rejects_checkout_ref_override(self) -> None:118- mutated = replace_once_in_demo_gate(119- WORKFLOW,120- " uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",121- " uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n"122- " with:\n"123- " ref: main",126+127+class SummaryJobTests(unittest.TestCase):128+ def test_rejects_renamed_summary_job(self) -> None:129+ mutated = replace_once(130+ WORKFLOW, " name: Complete demo gate", " name: Optional demo gate"124131 )125- with self.assertRaisesRegex(126- PolicyError, "checkout step has unexpected or missing fields"127- ):132+ with self.assertRaisesRegex(PolicyError, "required name"):133+ verify(mutated)134+135+ def test_rejects_summary_job_off_the_pinned_runner(self) -> None:136+ mutated = in_job(WORKFLOW, "demo-gate", "runs-on: ubuntu-24.04", "runs-on: ubuntu-latest")137+ with self.assertRaisesRegex(PolicyError, "must run on ubuntu-24.04"):128138 verify(mutated)129139 130- def test_rejects_checkout_action_change(self) -> None:131- mutated = replace_once_in_demo_gate(140+ def test_rejects_summary_job_that_cannot_report_failures(self) -> None:141+ for condition in ("if: false", "if: success()", "if: github.ref == 'x'"):142+ with self.subTest(condition=condition):143+ mutated = in_job(WORKFLOW, "demo-gate", "if: always()", condition)144+ with self.assertRaisesRegex(PolicyError, "if: always\\(\\)"):145+ verify(mutated)146+147+ def test_rejects_summary_job_that_never_fails(self) -> None:148+ mutated = replace_once(132149 WORKFLOW,133- "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",134- "actions/checkout@main",150+ " echo 'complete demo gate failed' >&2\n exit 1\n",151+ " echo 'complete demo gate failed' >&2\n",135152 )136- with self.assertRaisesRegex(PolicyError, "checkout action is not digest-pinned"):153+ with self.assertRaisesRegex(PolicyError, "must fail when a leg did not pass"):137154 verify(mutated)138155 139- def test_rejects_step_poisoning_cargo_environment(self) -> None:156+ def test_rejects_widened_skippable_leg_list(self) -> None:140157 mutated = replace_once(141158 WORKFLOW,142- " - name: Install Linux build dependencies\n",143- " - name: Poison Cargo environment\n"144- " run: printf '%s\\n' 'export RUSTC_WRAPPER=true' >> ~/.cargo/env\n\n"145- " - name: Install Linux build dependencies\n",159+ "skippable='native-tests cross-build",160+ "skippable='quick-checks repo-tests native-tests cross-build",146161 )147- with self.assertRaisesRegex(148- PolicyError, "step list or order changed from the CI policy contract"149- ):162+ with self.assertRaisesRegex(PolicyError, "skippable legs changed"):150163 verify(mutated)151164 152- def test_rejects_step_replacing_cargo_zigbuild(self) -> None:165+ def test_rejects_skip_tolerated_without_the_scope_decision(self) -> None:153166 mutated = replace_once(154167 WORKFLOW,155- " - name: Run complete demo gate\n",156- " - name: Replace cargo-zigbuild\n"157- " run: |\n"158- " printf '%s\\n' '#!/usr/bin/env bash' 'exit 0' "159- "> ~/.cargo/bin/cargo-zigbuild\n"160- " chmod 0755 ~/.cargo/bin/cargo-zigbuild\n\n"161- " - name: Run complete demo gate\n",168+ " if [[ \"$SCOPE_CODE\" == 'false' ]]; then\n docs_only=true\n fi",169+ " docs_only=true",162170 )163171 with self.assertRaisesRegex(164- PolicyError, "step list or order changed from the CI policy contract"172+ PolicyError, "only tolerate a skip the docs-only scope made"165173 ):166174 verify(mutated)167175 168- def test_rejects_extra_steps_at_every_boundary(self) -> None:169- markers = [170- " - name: Check out repository\n",171- " - name: Install Linux build dependencies\n",172- " - name: Restore pinned cargo-zigbuild\n",173- " - name: Install pinned cross-build tools\n",174- " - name: Run complete demo gate\n",175- ]176- for position, marker in enumerate(markers):177- with self.subTest(position=position):178- mutated = replace_once_in_demo_gate(176+177+class LegConditionTests(unittest.TestCase):178+ def test_rejects_conditionally_disabled_always_run_leg(self) -> None:179+ for leg in ("quick-checks", "repo-tests", "decky-gate", "scope"):180+ with self.subTest(leg=leg):181+ mutated = add_job_field(WORKFLOW, leg, "if: false")182+ with self.assertRaises(PolicyError):183+ verify(mutated)184+185+ def test_rejects_quoted_conditionally_disabled_leg(self) -> None:186+ mutated = add_job_field(WORKFLOW, "quick-checks", '"if": false')187+ with self.assertRaises(PolicyError):188+ verify(mutated)189+190+ def test_rejects_skippable_leg_with_a_different_condition(self) -> None:191+ for condition in (192+ "if: false",193+ "if: github.actor != 'nobody'",194+ "if: needs.scope.outputs.code != 'true'",195+ ):196+ with self.subTest(condition=condition):197+ mutated = in_job(179198 WORKFLOW,180- marker,181- f" - name: Extra step {position}\n"182- " run: echo unexpected\n\n"183- f"{marker}",199+ "native-tests",200+ "if: needs.scope.outputs.code == 'true'",201+ condition,184202 )185- with self.assertRaisesRegex(186- PolicyError, "step list or order changed from the CI policy contract"187- ):203+ with self.assertRaisesRegex(PolicyError, "audited docs-only scope"):188204 verify(mutated)189205 190- mutated = WORKFLOW + (191- "\n"192- " - name: Extra trailing step\n"193- " run: echo unexpected\n"206+ def test_rejects_leg_continue_on_error(self) -> None:207+ for leg in ("cross-build", "release-containment", "demo-gate"):208+ with self.subTest(leg=leg):209+ mutated = add_job_field(WORKFLOW, leg, "continue-on-error: true")210+ with self.assertRaisesRegex(PolicyError, "must not suppress failures"):211+ verify(mutated)212+213+ def test_rejects_leg_run_defaults_override(self) -> None:214+ mutated = add_job_field(215+ WORKFLOW, "cross-build", "defaults:\n run:\n shell: 'bash {0} || true'"194216 )195- with self.assertRaisesRegex(196- PolicyError, "step list or order changed from the CI policy contract"197- ):217+ with self.assertRaisesRegex(PolicyError, "must not override run defaults"):198218 verify(mutated)199219 200- def test_rejects_required_step_reordering(self) -> None:201- cache_marker = " - name: Restore pinned cargo-zigbuild\n"202- install_marker = " - name: Install pinned cross-build tools\n"203- gate_marker = " - name: Run complete demo gate\n"204- cache_start = WORKFLOW.index(cache_marker)205- install_start = WORKFLOW.index(install_marker, cache_start)206- gate_start = WORKFLOW.index(gate_marker, install_start)207- cache_step = WORKFLOW[cache_start:install_start]208- install_step = WORKFLOW[install_start:gate_start]209- mutated = (210- WORKFLOW[:cache_start]211- + install_step212- + cache_step213- + WORKFLOW[gate_start:]220+221+class StepIntegrityTests(unittest.TestCase):222+ def test_rejects_step_that_masks_failure(self) -> None:223+ for token in ("|| true", "set +e", "trap 'exit 0' EXIT", "exec true"):224+ with self.subTest(token=token):225+ mutated = in_job(226+ WORKFLOW,227+ "native-tests",228+ "run: ./scripts/verify-demo-build.sh --stage native",229+ f"run: ./scripts/verify-demo-build.sh --stage native {token}",230+ )231+ with self.assertRaisesRegex(PolicyError, "forbidden token"):232+ verify(mutated)233+234+ def test_rejects_step_poisoning_the_cargo_toolchain(self) -> None:235+ mutated = in_job(236+ WORKFLOW,237+ "native-tests",238+ " - name: Run the native gate stage\n",239+ " - name: Poison Cargo environment\n"240+ " run: printf 'x\\n' 'export RUSTC_WRAPPER=true' >>~/.cargo/env\n\n"241+ " - name: Run the native gate stage\n",214242 )215- with self.assertRaisesRegex(216- PolicyError, "step list or order changed from the CI policy contract"217- ):243+ with self.assertRaisesRegex(PolicyError, "forbidden token"):218244 verify(mutated)219245 220- def test_rejects_partial_linux_dependency_body_mutations(self) -> None:221- mutations = [222- (223- " ffmpeg \\\n",224- "",225- ),226- (227- " shellcheck\n",228- " shellcheck \\\n"229- " unzip\n",230- ),231- (232- " sudo apt-get update\n",233- " sudo apt-get update --quiet\n",234- ),235- ]236- for old, new in mutations:237- with self.subTest(replacement=new):238- mutated = replace_once(WORKFLOW, old, new)239- with self.assertRaisesRegex(240- PolicyError, "Linux dependency step body changed"241- ):242- verify(mutated)246+ def test_rejects_conditionally_disabled_step(self) -> None:247+ mutated = in_job(248+ WORKFLOW,249+ "cross-build",250+ " - name: Install pinned cross-build tools\n",251+ " - name: Install pinned cross-build tools\n if: false\n",252+ )253+ with self.assertRaisesRegex(PolicyError, "conditionally disabled step"):254+ verify(mutated)243255 244- def test_rejects_linux_dependency_step_field_changes(self) -> None:245- mutated = replace_once(256+ def test_rejects_step_continue_on_error(self) -> None:257+ mutated = in_job(246258 WORKFLOW,247- " - name: Install Linux build dependencies\n run: |",248- " - name: Install Linux build dependencies\n"249- " env:\n"250- " BASH_ENV: ~/.cargo/env\n"251- " run: |",259+ "cross-build",260+ " - name: Install pinned cross-build tools\n",261+ " - name: Install pinned cross-build tools\n"262+ " continue-on-error: true\n",252263 )253- with self.assertRaisesRegex(254- PolicyError, "Linux dependency step has unexpected or missing fields"255- ):264+ with self.assertRaisesRegex(PolicyError, "step that suppresses failures"):256265 verify(mutated)257266 258- def test_rejects_conditionally_disabled_demo_job(self) -> None:259- mutated = replace_once(267+ def test_rejects_action_that_is_not_digest_pinned(self) -> None:268+ mutated = in_job(260269 WORKFLOW,261- " demo-gate:\n name: Complete demo gate",262- " demo-gate:\n if: false\n name: Complete demo gate",270+ "quick-checks",271+ "uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",272+ "uses: actions/checkout@v6",263273 )264- with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):274+ with self.assertRaisesRegex(PolicyError, "not digest-pinned"):265275 verify(mutated)266276 267- def test_rejects_quoted_conditionally_disabled_demo_job(self) -> None:268- mutated = replace_once(277+ def test_rejects_checkout_ref_override(self) -> None:278+ mutated = in_job(269279 WORKFLOW,270- " demo-gate:\n name: Complete demo gate",271- ' demo-gate:\n "if": false\n name: Complete demo gate',280+ "cross-build",281+ "uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",282+ "uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n"283+ " with:\n ref: main",272284 )273- with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):285+ with self.assertRaisesRegex(PolicyError, "overrides the checked-out ref"):274286 verify(mutated)275287 276- def test_rejects_demo_job_continue_on_error(self) -> None:277- mutated = replace_once(288+ def test_rejects_swapped_checkout_digest(self) -> None:289+ mutated = in_job(278290 WORKFLOW,279- " demo-gate:\n name: Complete demo gate",280- " demo-gate:\n continue-on-error: true\n"281- " name: Complete demo gate",291+ "quick-checks",292+ "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8",293+ "actions/checkout@" + "b" * 40,282294 )283- with self.assertRaisesRegex(PolicyError, "must not suppress failures"):295+ with self.assertRaisesRegex(PolicyError, "not digest-pinned to the policy"):284296 verify(mutated)285297 286- def test_rejects_missing_pull_request_trigger(self) -> None:287- mutated = replace_once(WORKFLOW, " pull_request:\n", "")298+299+class GateLegTests(unittest.TestCase):300+ def test_rejects_leg_running_the_wrong_stage(self) -> None:301+ mutated = in_job(302+ WORKFLOW,303+ "release-containment",304+ "run: ./scripts/verify-demo-build.sh --stage containment",305+ "run: ./scripts/verify-demo-build.sh --stage quick",306+ )307+ with self.assertRaisesRegex(PolicyError, "must run the repository gate stage"):308+ verify(mutated)309+310+ def test_rejects_leg_that_does_not_run_the_gate(self) -> None:311+ mutated = in_job(312+ WORKFLOW,313+ "native-tests",314+ "run: ./scripts/verify-demo-build.sh --stage native",315+ "run: echo bypassed",316+ )317+ mutated += "\n# run: ./scripts/verify-demo-build.sh --stage native\n"318+ with self.assertRaisesRegex(PolicyError, "must invoke the repository gate"):319+ verify(mutated)320+321+ def test_rejects_cross_build_leg_without_the_required_cross_build(self) -> None:322+ mutated = in_job(323+ WORKFLOW, "cross-build", 'GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"', 'GOQ_REQUIRE_LINUX_CROSS_BUILD: "0"'324+ )325+ with self.assertRaisesRegex(PolicyError, "does not require the Linux cross build"):326+ verify(mutated)327+328+ def test_rejects_cross_build_leg_without_the_gstreamer_backend(self) -> None:329+ mutated = in_job(330+ WORKFLOW,331+ "cross-build",332+ ' GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"\n',333+ "",334+ )335+ with self.assertRaisesRegex(PolicyError, "in-process GStreamer backend"):336+ verify(mutated)337+338+ def test_rejects_gstreamer_leg_without_its_gate(self) -> None:339+ mutated = in_job(340+ WORKFLOW,341+ "gstreamer-gate",342+ ' GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"\n',343+ "",344+ )288345 with self.assertRaisesRegex(289- PolicyError, "empty pull_request trigger"346+ PolicyError, "does not require the in-process GStreamer gate"290347 ):291348 verify(mutated)292349 293- def test_rejects_filtered_pull_request_trigger(self) -> None:294- filters = [295- " branches: [main]",296- " paths: ['crates/**']",297- " types: [opened]",298- ]299- for filter_line in filters:300- with self.subTest(filter_line=filter_line):301- mutated = replace_once(302- WORKFLOW,303- " pull_request:\n",304- f" pull_request:\n{filter_line}\n",305- )306- with self.assertRaisesRegex(307- PolicyError, "exactly empty and unfiltered"308- ):309- verify(mutated)350+ def test_rejects_leg_that_skips_dependency_installation(self) -> None:351+ mutated = in_job(352+ WORKFLOW,353+ "loopback",354+ " run: ./scripts/install-linux-build-deps.sh --profile full\n",355+ " run: echo skipped\n",356+ )357+ with self.assertRaisesRegex(PolicyError, "install the Linux build dependencies"):358+ verify(mutated)310359 311- def test_rejects_inline_pull_request_mapping(self) -> None:312- mutated = replace_once(WORKFLOW, " pull_request:", " pull_request: {}")313- with self.assertRaisesRegex(PolicyError, "must be exactly empty"):314- verify(mutated)315-316- def test_rejects_changed_demo_job_identity_and_execution_limits(self) -> None:317- changes = [318- (" name: Complete demo gate", " name: Optional demo gate", "required name"),319- (320- " demo-gate:\n name: Complete demo gate\n"321- " runs-on: ubuntu-24.04",322- " demo-gate:\n name: Complete demo gate\n"323- " runs-on: ubuntu-latest",324- "ubuntu-24.04",325- ),326- (" timeout-minutes: 45", " timeout-minutes: 5", "45 minutes"),327- ]328- for old, new, error in changes:329- with self.subTest(field=old):330- mutated = replace_once(WORKFLOW, old, new)331- with self.assertRaisesRegex(PolicyError, error):360+ def test_rejects_leg_off_the_pinned_runner(self) -> None:361+ mutated = in_job(362+ WORKFLOW, "loopback", "runs-on: ubuntu-24.04", "runs-on: ubuntu-latest"363+ )364+ with self.assertRaisesRegex(PolicyError, "must run on ubuntu-24.04"):365+ verify(mutated)366+367+368+class ScopeJobTests(unittest.TestCase):369+ def test_rejects_missing_fail_closed_branch(self) -> None:370+ for branch in (371+ " echo 'scope=full (event is not a pull request)'\n",372+ " echo 'scope=full (could not enumerate the pull request diff)'\n",373+ " echo 'scope=full (empty diff listing)'\n",374+ ):375+ with self.subTest(branch=branch):376+ mutated = replace_once(WORKFLOW, branch, "")377+ with self.assertRaisesRegex(PolicyError, "fail-closed branch"):332378 verify(mutated)333379 334- def test_rejects_changed_demo_job_environment(self) -> None:335- changes = [336- (' CARGO_TERM_COLOR: always', ' CARGO_TERM_COLOR: never'),337- (' RUST_BACKTRACE: "1"\n', ""),338- (339- ' RUST_BACKTRACE: "1"',340- ' RUST_BACKTRACE: "1"\n CI_FAILURES_OPTIONAL: "1"',341- ),342- ]343- for old, new in changes:344- with self.subTest(replacement=new):345- mutated = replace_once(WORKFLOW, old, new)346- with self.assertRaisesRegex(347- PolicyError, "env changed from the CI policy contract"348- ):380+ def test_rejects_widened_documentation_allowlist(self) -> None:381+ for arm in (382+ "docs/* | website/* | *.md | LICENSE | crates/*) ;;",383+ "docs/* | website/* | *.md | LICENSE | src-tauri/*) ;;",384+ "*) ;;",385+ ):386+ with self.subTest(arm=arm):387+ mutated = replace_once(388+ WORKFLOW, "docs/* | website/* | *.md | LICENSE) ;;", arm389+ )390+ with self.assertRaisesRegex(PolicyError, "allowlist changed"):349391 verify(mutated)350392 351- def test_rejects_failure_suppressing_workflow_shell_default(self) -> None:393+ def test_rejects_extra_allowlist_arm(self) -> None:352394 mutated = replace_once(353395 WORKFLOW,354- "name: CI\n\non:",355- "name: CI\n\ndefaults:\n"356- " run:\n"357- " shell: 'bash {0} || true'\n\n"358- "on:",396+ " docs/* | website/* | *.md | LICENSE) ;;\n",397+ " docs/* | website/* | *.md | LICENSE) ;;\n"398+ " crates/*) ;;\n",359399 )360- with self.assertRaisesRegex(PolicyError, "workflow-level defaults are forbidden"):400+ with self.assertRaisesRegex(PolicyError, "allowlist changed"):401+ verify(mutated)402+403+ def test_rejects_scope_job_that_cannot_be_trusted(self) -> None:404+ mutated = add_job_field(WORKFLOW, "scope", "if: false")405+ with self.assertRaisesRegex(PolicyError, "must carry no if condition"):361406 verify(mutated)362407 363- def test_ignores_tokens_in_comments_and_later_jobs(self) -> None:364- mutated, removed = remove_named_step(408+ def test_rejects_extra_scope_output(self) -> None:409+ mutated = replace_once(365410 WORKFLOW,366- "Install pinned cross-build tools",367- "Run complete demo gate",411+ " code: ${{ steps.classify.outputs.code }}\n",412+ " code: ${{ steps.classify.outputs.code }}\n"413+ " other: ${{ steps.classify.outputs.other }}\n",368414 )369- decoy = (370- "\n# Install pinned cross-build tools\n"371- "# GOQ_REQUIRE_LINUX_CROSS_BUILD: \"1\"\n"372- " later-decoy:\n"373- " runs-on: ubuntu-24.04\n"374- " steps:\n"375- f"{removed}"415+ with self.assertRaisesRegex(PolicyError, "exactly the code output"):416+ verify(mutated)417+418+419+class GateScriptTests(unittest.TestCase):420+ def test_rejects_changed_executable_prefix(self) -> None:421+ for mutation in ("set -eu\n", "set -euo pipefail\nexit 0\n"):422+ with self.subTest(mutation=mutation):423+ mutated = replace_once(GATE_SCRIPT, "set -euo pipefail\n", mutation)424+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):425+ verify_gate_script(mutated)426+427+ def test_rejects_failure_masked_cross_build_helper(self) -> None:428+ mutated = replace_once(429+ GATE_SCRIPT,430+ " ./scripts/run-linux-cross-build-gate.sh\n",431+ " ./scripts/run-linux-cross-build-gate.sh || true\n",376432 )377- with self.assertRaisesRegex(PolicyError, "must exist exactly once"):378- verify(mutated + decoy)433+ with self.assertRaisesRegex(PolicyError, "forbidden token"):434+ verify_gate_script(mutated)379435 380- def test_rejects_wrong_install_body_even_with_tokens_in_later_job(self) -> None:436+ def test_rejects_conditionally_wrapped_cross_build_helper(self) -> None:381437 mutated = replace_once(382- WORKFLOW,383- " cargo install cargo-zigbuild --locked --version 0.23.0",384- " echo skipped cargo install",438+ GATE_SCRIPT,439+ " ./scripts/run-linux-cross-build-gate.sh\n",440+ " if false; then\n ./scripts/run-linux-cross-build-gate.sh\n fi\n",385441 )386- mutated += (387- "\n later-decoy:\n"388- " runs-on: ubuntu-24.04\n"389- " steps:\n"390- " - name: Decoy\n"391- " run: cargo install cargo-zigbuild --locked --version 0.23.0\n"442+ with self.assertRaisesRegex(PolicyError, "invoked unconditionally"):443+ verify_gate_script(mutated)444+445+ def test_rejects_duplicated_or_removed_cross_build_helper(self) -> None:446+ removed = replace_once(447+ GATE_SCRIPT, " ./scripts/run-linux-cross-build-gate.sh\n", ""392448 )393- with self.assertRaisesRegex(PolicyError, "install step body changed"):394- verify(mutated)449+ with self.assertRaisesRegex(PolicyError, "exactly once"):450+ verify_gate_script(removed)451+452+ duplicated = replace_once(453+ GATE_SCRIPT,454+ " ./scripts/run-linux-cross-build-gate.sh\n",455+ " ./scripts/run-linux-cross-build-gate.sh\n"456+ " ./scripts/run-linux-cross-build-gate.sh\n",457+ )458+ with self.assertRaisesRegex(PolicyError, "exactly once"):459+ verify_gate_script(duplicated)460+461+ def test_rejects_failure_control_tokens_anywhere(self) -> None:462+ for token in ("set +e", "trap 'exit 0' EXIT", "exec true"):463+ with self.subTest(token=token):464+ mutated = replace_once(465+ GATE_SCRIPT,466+ "run_stage_quick() {\n",467+ f"run_stage_quick() {{\n {token}\n",468+ )469+ with self.assertRaisesRegex(PolicyError, "forbidden token"):470+ verify_gate_script(mutated)471+472+ def test_rejects_dropped_stage(self) -> None:473+ mutated = replace_once(474+ GATE_SCRIPT,475+ "readonly ALL_STAGES=(quick cross native gstreamer repo-tests loopback containment)",476+ "readonly ALL_STAGES=(quick native gstreamer repo-tests loopback containment)",477+ )478+ with self.assertRaisesRegex(PolicyError, "stages changed"):479+ verify_gate_script(mutated)480+481+ def test_rejects_undispatched_stage(self) -> None:482+ mutated = replace_once(483+ GATE_SCRIPT, " cross) run_stage_cross ;;\n", ""484+ )485+ with self.assertRaisesRegex(PolicyError, "does not dispatch"):486+ verify_gate_script(mutated)487+488+ def test_rejects_missing_stage_function(self) -> None:489+ mutated = replace_once(490+ GATE_SCRIPT, "run_stage_containment() {", "run_stage_containment_disabled() {"491+ )492+ with self.assertRaises(PolicyError):493+ verify_gate_script(mutated)494+495+ def test_rejects_default_run_that_skips_stages(self) -> None:496+ mutated = replace_once(497+ GATE_SCRIPT,498+ ' for current_stage in "${ALL_STAGES[@]}"; do',499+ " for current_stage in quick; do",500+ )501+ with self.assertRaisesRegex(PolicyError, "must run every stage"):502+ verify_gate_script(mutated)395503 396504 397505 if __name__ == "__main__":@@ -10,17 +10,98 @@ if [[ -f "$HOME/.cargo/env" ]]; then
1010 source "$HOME/.cargo/env"1111 fi1212 13-./scripts/run-linux-cross-build-gate.sh13+# The complete gate is the ordered union of these stages. Running with no14+# arguments runs every one of them, so the release and hardware-UAT workflows15+# and a local `./scripts/verify-demo-build.sh` keep their exact prior coverage.16+# CI runs the same stages as separate parallel jobs purely to cut wall clock;17+# scripts/tests/demo-gate-stages.sh proves the union still spends every check.18+#19+# `quick` deliberately runs first: format, syntax, ShellCheck, and whitespace20+# failures used to surface only after the multi-minute cross build had already21+# run, which cost a full gate to learn about a missing newline.22+readonly ALL_STAGES=(quick cross native gstreamer repo-tests loopback containment)23+readonly LOOPBACK_CASES=(1 2 3 4)1424 15-for command_name in cargo rustc node git ffmpeg shellcheck; do16- if ! command -v "$command_name" >/dev/null 2>&1; then17- printf 'required command is missing: %s\n' "$command_name" >&218- exit 125+usage() {26+ cat >&2 <<'USAGE'27+usage: verify-demo-build.sh [--stage STAGE] [--loopback-case NUMBER]28+29+ --stage STAGE Run a single stage instead of the complete gate.30+ One of: quick cross native gstreamer repo-tests31+ loopback containment all (default: all).32+ --loopback-case NUMBER Run a single loopback proof (1-4) instead of all33+ four. Requires --stage loopback.34+35+With no arguments this runs the complete repository gate.36+USAGE37+}38+39+stage=all40+loopback_case=all41+42+while [[ $# -gt 0 ]]; do43+ case "$1" in44+ --stage)45+ [[ $# -ge 2 ]] || {46+ printf -- '--stage requires a value\n' >&247+ exit 248+ }49+ stage="$2"50+ shift 251+ ;;52+ --loopback-case)53+ [[ $# -ge 2 ]] || {54+ printf -- '--loopback-case requires a value\n' >&255+ exit 256+ }57+ loopback_case="$2"58+ shift 259+ ;;60+ -h | --help)61+ usage62+ exit 063+ ;;64+ *)65+ printf 'unknown argument: %s\n' "$1" >&266+ usage67+ exit 268+ ;;69+ esac70+done71+72+stage_is_known=false73+for known_stage in "${ALL_STAGES[@]}" all; do74+ if [[ "$stage" == "$known_stage" ]]; then75+ stage_is_known=true76+ break1977 fi2078 done79+if [[ "$stage_is_known" != true ]]; then80+ printf 'unknown stage: %s\n' "$stage" >&281+ usage82+ exit 283+fi84+85+if [[ "$loopback_case" != all ]]; then86+ if [[ "$stage" != loopback ]]; then87+ printf -- '--loopback-case requires --stage loopback\n' >&288+ exit 289+ fi90+ case_is_known=false91+ for known_case in "${LOOPBACK_CASES[@]}"; do92+ if [[ "$loopback_case" == "$known_case" ]]; then93+ case_is_known=true94+ break95+ fi96+ done97+ if [[ "$case_is_known" != true ]]; then98+ printf -- '--loopback-case must be one of %s\n' "${LOOPBACK_CASES[*]}" >&299+ exit 2100+ fi101+fi21102 22103 case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in23- 0|1) ;;104+ 0 | 1) ;;24105 *)25106 printf 'GOQ_VERIFY_IN_PROCESS_GSTREAMER must be 0 or 1\n' >&226107 exit 1@@ -28,22 +109,22 @@ case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in
28109 esac29110 30111 case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in31- 0|1) ;;112+ 0 | 1) ;;32113 *)33114 printf 'GOQ_REQUIRE_LINUX_CROSS_BUILD must be 0 or 1\n' >&234115 exit 135116 ;;36117 esac37118 38-if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then39- for cross_command in cargo-zigbuild zig; do40- if ! command -v "$cross_command" >/dev/null 2>&1; then41- printf 'required Linux cross-build command is missing: %s\n' \42- "$cross_command" >&2119+require_commands() {120+ local command_name121+ for command_name in "$@"; do122+ if ! command -v "$command_name" >/dev/null 2>&1; then123+ printf 'required command is missing: %s\n' "$command_name" >&243124 exit 144125 fi45126 done46-fi127+}47128 48129 require_rust_test() {49130 local test_name="$1"@@ -57,11 +138,72 @@ require_rust_test() {
57138 fi58139 }59140 60-if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then61- command -v gst-inspect-1.0 >/dev/null 2>&1 || {62- echo 'in-process GStreamer gate requires gst-inspect-1.0' >&2141+run_stage_quick() {142+ require_commands cargo node git shellcheck143+ cargo fmt --all -- --check144+ while IFS= read -r frontend_source; do145+ node --check "$frontend_source"146+ done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)147+ node --test portal/*.test.mjs148+ find scripts -type f -name '*.sh' -exec shellcheck {} +149+ git diff --check150+ echo 'demo_gate_stage_quick=ok'151+}152+153+run_stage_cross() {154+ local cross_command155+ if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then156+ for cross_command in cargo-zigbuild zig; do157+ if ! command -v "$cross_command" >/dev/null 2>&1; then158+ printf 'required Linux cross-build command is missing: %s\n' \159+ "$cross_command" >&2160+ exit 1161+ fi162+ done163+ fi164+ ./scripts/run-linux-cross-build-gate.sh165+ echo 'demo_gate_stage_cross=ok'166+}167+168+run_stage_native() {169+ require_commands cargo rustc git170+ require_rust_test 'resolves_pinned_upstream_gamescope_pipewire_contract' \171+ -p sigil-host --bin sigil172+ cargo test --locked --workspace --all-targets173+ cargo clippy --locked --workspace --all-targets -- -D warnings174+ cargo check --locked -p portal --all-targets \175+ --features experimental-non-macos-pointer-capture176+ echo 'experimental_non_macos_pointer_capture_compile=ok'177+ cargo build --locked -p sigil-host --bin sigil178+179+ local host_dependencies180+ host_dependencies="$(cargo tree --locked -p sigil-host --edges normal)"181+ if grep -Eiq '(^|[[:space:]├└│─])(tauri|wry|webkit)([[:space:]-]|$)' <<<"$host_dependencies"; then182+ echo 'pure host dependency gate failed: desktop/webview dependency detected' >&2183+ grep -Ei 'tauri|wry|webkit' <<<"$host_dependencies" >&263184 exit 164- }185+ fi186+187+ local catalog_dependencies188+ catalog_dependencies="$({189+ cargo tree --locked -p sigil-host --edges normal190+ cargo tree --locked -p portal --edges normal191+ })"192+ if grep -Eiq '(^|[[:space:]├└│─])(moq-media|moq-mux)([[:space:]-]|$)' <<<"$catalog_dependencies"; then193+ echo 'MoQ catalog boundary failed: standard-media dependency detected' >&2194+ grep -Ei 'moq-media|moq-mux' <<<"$catalog_dependencies" >&2195+ exit 1196+ fi197+ echo 'demo_gate_stage_native=ok'198+}199+200+run_stage_gstreamer() {201+ local gstreamer_element202+ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" != 1 ]]; then203+ echo 'in_process_gstreamer_gate=skipped (GOQ_VERIFY_IN_PROCESS_GSTREAMER is not 1)'204+ return 0205+ fi206+ require_commands cargo gst-inspect-1.065207 for gstreamer_element in videotestsrc queue videoconvert videoscale capsfilter x264enc h264parse appsink; do66208 gst-inspect-1.0 "$gstreamer_element" >/dev/null || {67209 printf 'in-process GStreamer gate requires the %s plugin\n' \@@ -69,24 +211,8 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
69211 exit 170212 }71213 done72-fi73-74-printf 'repo=%s\n' "$repo_dir"75-printf 'revision=%s\n' "$(git rev-parse HEAD)"76-rustc --version77-cargo --version78-79-require_rust_test 'resolves_pinned_upstream_gamescope_pipewire_contract' \80- -p sigil-host --bin sigil81-cargo fmt --all -- --check82-cargo test --locked --workspace --all-targets83-cargo clippy --locked --workspace --all-targets -- -D warnings84-cargo check --locked -p portal --all-targets \85- --features experimental-non-macos-pointer-capture86-echo 'experimental_non_macos_pointer_capture_compile=ok'87-if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then88- control_test='encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr'89- gstreamer_test='in_process_gstreamer_x264_smoke'214+ local control_test='encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr'215+ local gstreamer_test='in_process_gstreamer_x264_smoke'90216 require_rust_test "$control_test" -p sigil-host91217 cargo test --locked -p sigil-host "$control_test"92218 cargo check --locked -p sigil-host --all-targets --features in-process-gstreamer@@ -94,61 +220,110 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
94220 cargo test --locked -p sigil-host --features in-process-gstreamer \95221 "$gstreamer_test" -- --ignored --nocapture96222 echo 'in_process_gstreamer_gate=ok'97-fi98-while IFS= read -r frontend_source; do99- node --check "$frontend_source"100-done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)101-node --test portal/*.test.mjs102-103-cargo build --locked -p sigil-host --bin sigil104-find scripts -type f -name '*.sh' -exec shellcheck {} +105-./scripts/run-shell-tests.sh scripts/tests106-107-host_dependencies="$(cargo tree --locked -p sigil-host --edges normal)"108-if grep -Eiq '(^|[[:space:]├└│─])(tauri|wry|webkit)([[:space:]-]|$)' <<<"$host_dependencies"; then109- echo 'pure host dependency gate failed: desktop/webview dependency detected' >&2110- grep -Ei 'tauri|wry|webkit' <<<"$host_dependencies" >&2111- exit 1112-fi223+}224+225+run_stage_repo_tests() {226+ require_commands cargo rustc node git ffmpeg shellcheck227+ ./scripts/run-shell-tests.sh scripts/tests228+ echo 'demo_gate_stage_repo_tests=ok'229+}230+231+run_loopback_case() {232+ case "$1" in233+ 1)234+ ./scripts/loopback-proof.sh235+ ;;236+ 2)237+ ./scripts/loopback-proof.sh --profile debug --control-v2 --viewers 3 \238+ --primary-frames 600 --reconnect-cycles 3239+ ;;240+ 3)241+ ./scripts/loopback-proof.sh --profile debug --control-v2 --viewers 3 \242+ --focus-handoffs 3 --assert-neutral-before-successor \243+ --primary-frames 600 --reconnect-cycles 3244+ ;;245+ 4)246+ ./scripts/loopback-proof.sh --profile release --legacy-exclusive --viewers 2 \247+ --expect-second-rejected248+ ;;249+ *)250+ printf 'unknown loopback case: %s\n' "$1" >&2251+ exit 2252+ ;;253+ esac254+}255+256+run_stage_loopback() {257+ local case_number258+ require_commands cargo rustc git ffmpeg259+ if [[ "$loopback_case" == all ]]; then260+ for case_number in "${LOOPBACK_CASES[@]}"; do261+ run_loopback_case "$case_number"262+ done263+ else264+ run_loopback_case "$loopback_case"265+ fi266+ echo 'demo_gate_stage_loopback=ok'267+}113268 114-catalog_dependencies="$({115- cargo tree --locked -p sigil-host --edges normal116- cargo tree --locked -p portal --edges normal117-})"118-if grep -Eiq '(^|[[:space:]├└│─])(moq-media|moq-mux)([[:space:]-]|$)' <<<"$catalog_dependencies"; then119- echo 'MoQ catalog boundary failed: standard-media dependency detected' >&2120- grep -Ei 'moq-media|moq-mux' <<<"$catalog_dependencies" >&2121- exit 1269+run_stage_containment() {270+ require_commands cargo271+ cargo test --locked -p portal --release \272+ commands::state::tests::rejects_direct_node_when_debug_mode_is_disabled \273+ -- --exact274+275+ cargo test --locked -p portal --release \276+ commands::state::tests::ordinary_release_excludes_direct_node_bypass \277+ -- --exact278+279+ cargo test --locked -p portal --release --features demo-direct-node \280+ commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds \281+ -- --exact282+283+ cargo test --locked -p sigil-host --release \284+ tests::ordinary_release_excludes_configured_host_auth_bypass \285+ -- --exact286+287+ cargo test --locked -p sigil-host --release --features demo-auth-bypass \288+ tests::configured_host_auth_bypass_is_explicitly_build_contained \289+ -- --exact290+ echo 'demo_gate_stage_containment=ok'291+}292+293+run_stage() {294+ case "$1" in295+ quick) run_stage_quick ;;296+ cross) run_stage_cross ;;297+ native) run_stage_native ;;298+ gstreamer) run_stage_gstreamer ;;299+ repo-tests) run_stage_repo_tests ;;300+ loopback) run_stage_loopback ;;301+ containment) run_stage_containment ;;302+ *)303+ printf 'unknown stage: %s\n' "$1" >&2304+ exit 2305+ ;;306+ esac307+}308+309+if [[ "$stage" == all ]]; then310+ require_commands cargo rustc node git ffmpeg shellcheck122311 fi123312 124-./scripts/loopback-proof.sh125-./scripts/loopback-proof.sh --profile debug --control-v2 --viewers 3 \126- --primary-frames 600 --reconnect-cycles 3127-./scripts/loopback-proof.sh --profile debug --control-v2 --viewers 3 \128- --focus-handoffs 3 --assert-neutral-before-successor \129- --primary-frames 600 --reconnect-cycles 3130-./scripts/loopback-proof.sh --profile release --legacy-exclusive --viewers 2 \131- --expect-second-rejected132-133-cargo test --locked -p portal --release \134- commands::state::tests::rejects_direct_node_when_debug_mode_is_disabled \135- -- --exact136-137-cargo test --locked -p portal --release \138- commands::state::tests::ordinary_release_excludes_direct_node_bypass \139- -- --exact140-141-cargo test --locked -p portal --release --features demo-direct-node \142- commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds \143- -- --exact144-145-cargo test --locked -p sigil-host --release \146- tests::ordinary_release_excludes_configured_host_auth_bypass \147- -- --exact148-149-cargo test --locked -p sigil-host --release --features demo-auth-bypass \150- tests::configured_host_auth_bypass_is_explicitly_build_contained \151- -- --exact152-153-git diff --check154-echo 'demo_build_preflight=ok'313+printf 'repo=%s\n' "$repo_dir"314+printf 'revision=%s\n' "$(git rev-parse HEAD)"315+printf 'stage=%s\n' "$stage"316+rustc --version317+cargo --version318+319+if [[ "$stage" == all ]]; then320+ for current_stage in "${ALL_STAGES[@]}"; do321+ run_stage "$current_stage"322+ done323+ # `quick` already checked this, but re-check after every build so a stage324+ # that dirties the worktree still fails the complete gate.325+ git diff --check326+ echo 'demo_build_preflight=ok'327+else328+ run_stage "$stage"329+fi@@ -1,28 +1,117 @@
11 #!/usr/bin/env python32-"""Validate the executable semantics of CI's required glibc cross-build path."""2+"""Validate the executable semantics of CI's required complete demo gate.3+4+The gate used to be a single serial job, and this checker pinned that job byte5+for byte: the exact step list, the exact order, the exact run bodies. That6+stopped anyone weakening the gate, but it equally stopped anyone speeding it7+up, so a documentation pull request paid thirty minutes for checks it could not8+affect.9+10+The gate is now several parallel legs behind one required summary job. This11+checker therefore asserts *invariants* rather than byte equality:12+13+ * every leg the gate needs exists, runs the stage it claims to run, and14+ cannot suppress its own failure;15+ * the legs that must always run carry no `if:` at all;16+ * the legs that may be skipped carry exactly the audited docs-only condition17+ and nothing else;18+ * the summary job depends on every other job in the workflow, so a new job19+ cannot be added outside the gate;20+ * the summary job fails unless each dependency succeeded or was a skip the21+ docs-only scope is allowed to make;22+ * the change-scope job fails closed on anything but a provably docs-only diff;23+ * no step can poison the toolchain or mask a failure.24+25+Coverage of the individual checks inside each stage is proven separately, by26+executing the gate against stubbed tools in scripts/tests/demo-gate-stages.sh.27+Static structure here, dynamic coverage there.28+"""329 430 from __future__ import annotations531 632 import argparse733 import re834 import sys9-from dataclasses import dataclass, field1035 from pathlib import Path1136 1237 1338 CHECKOUT_ACTION = "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8"14-CACHE_ACTION = "actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809"15-CACHE_PATH = "~/.cargo/bin/cargo-zigbuild"16-CACHE_KEY = "cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0"39+GATE_SCRIPT = "./scripts/verify-demo-build.sh"1740 LINUX_CROSS_BUILD_GATE = "./scripts/run-linux-cross-build-gate.sh"18-MAPPING_KEY = r"""(?:[A-Za-z0-9_-]+|"[A-Za-z0-9_-]+"|'[A-Za-z0-9_-]+')"""19-REQUIRED_STEP_NAMES = [20- "Check out repository",21- "Install Linux build dependencies",22- "Restore pinned cargo-zigbuild",23- "Install pinned cross-build tools",24- "Run complete demo gate",25-]41+DEPENDENCY_SCRIPT = "./scripts/install-linux-build-deps.sh"42+SUMMARY_JOB = "demo-gate"43+SUMMARY_JOB_NAME = "Complete demo gate"44+SCOPE_JOB = "scope"45+SCOPE_CONDITION = "needs.scope.outputs.code == 'true'"46+REQUIRED_RUNNER = "ubuntu-24.04"47+48+# Legs that run the repository gate, and the stage each one must run. The gate49+# script's own stage list is checked against this in verify_gate_script.50+GATE_LEG_STAGES = {51+ "quick-checks": "quick",52+ "repo-tests": "repo-tests",53+ "native-tests": "native",54+ "cross-build": "cross",55+ "gstreamer-gate": "gstreamer",56+ "loopback": "loopback",57+ "release-containment": "containment",58+}59+60+# Legs that must run for every change, documentation included. The website,61+# docs, and README claim contracts are exercised by the repository test suite,62+# so these legs are what keep a docs-only pull request honest.63+ALWAYS_RUN_JOBS = frozenset({SCOPE_JOB, "quick-checks", "repo-tests", "decky-gate"})64+65+# Legs the audited docs-only scope may skip. Must match the summary job body.66+SKIPPABLE_JOBS = frozenset(67+ {68+ "native-tests",69+ "cross-build",70+ "gstreamer-gate",71+ "loopback",72+ "release-containment",73+ "portal-target-matrix",74+ }75+)76+77+# Tokens that would let a step or the gate script pass while something inside78+# it failed, or would let a step swap out the toolchain the gate measures.79+FORBIDDEN_RUN_TOKENS = (80+ "|| true",81+ "|| :",82+ "set +e",83+ "exec true",84+ "trap ",85+ "RUSTC_WRAPPER",86+ "continue-on-error",87+ ">~/.cargo/env",88+ ">> ~/.cargo/env",89+ ">>~/.cargo/env",90+)91+92+# The change-scope job is security logic: every path that cannot prove the diff93+# is documentation must classify the change as code. Require each of those94+# fail-closed branches to be present verbatim.95+REQUIRED_SCOPE_BRANCHES = (96+ "if [[ \"$EVENT_NAME\" != 'pull_request' ]]; then",97+ "scope=full (event is not a pull request)",98+ "scope=full (could not enumerate the pull request diff)",99+ "scope=full (empty diff listing)",100+)101+102+# The path allowlist decides which diffs may skip the compile-heavy legs, so it103+# is pinned exactly rather than by substring. Widening it by adding another case104+# arm is the change this is here to catch; indentation may still change freely.105+REQUIRED_SCOPE_CASE = (106+ 'case "$changed_path" in',107+ "docs/* | website/* | *.md | LICENSE) ;;",108+ "*)",109+ "code=true",110+ "printf 'code_path=%s\\n' \"$changed_path\"",111+ ";;",112+ "esac",113+)114+26115 GATE_SCRIPT_PREFIX = """\27116 #!/usr/bin/env bash28117 set -euo pipefail@@ -30,79 +119,13 @@ set -euo pipefail
30119 script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"31120 repo_dir="$(cd -- "$script_dir/.." && pwd -P)"32121 cd "$repo_dir"33-34-if [[ -f "$HOME/.cargo/env" ]]; then35- # shellcheck source=/dev/null36- source "$HOME/.cargo/env"37-fi38-39-./scripts/run-linux-cross-build-gate.sh40122 """41123 42-DEPENDENCY_RUN = """\43-sudo apt-get update44-sudo apt-get install --yes --no-install-recommends \\45- build-essential \\46- file \\47- ffmpeg \\48- gstreamer1.0-plugins-base \\49- gstreamer1.0-plugins-bad \\50- gstreamer1.0-plugins-good \\51- gstreamer1.0-plugins-ugly \\52- gstreamer1.0-tools \\53- libayatana-appindicator3-dev \\54- libgstreamer-plugins-base1.0-dev \\55- libgstreamer1.0-dev \\56- librsvg2-dev \\57- libssl-dev \\58- libudev-dev \\59- libwebkit2gtk-4.1-dev \\60- libxdo-dev \\61- pkg-config \\62- python3-venv \\63- shellcheck"""64-65-INSTALL_RUN = """\66-python3 -m venv "$RUNNER_TEMP/zig-venv"67-printf '%s\\n' \\68- 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \\69- | "$RUNNER_TEMP/zig-venv/bin/pip" install \\70- --disable-pip-version-check \\71- --only-binary=:all: \\72- --require-hashes \\73- --requirement /dev/stdin74-install -d "$RUNNER_TEMP/ci-bin"75-# Expand runner paths when the generated wrapper executes.76-# shellcheck disable=SC201677-printf '%s\\n' \\78- '#!/usr/bin/env bash' \\79- 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \\80- >"$RUNNER_TEMP/ci-bin/zig"81-chmod 0755 "$RUNNER_TEMP/ci-bin/zig"82-echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"83-export PATH="$RUNNER_TEMP/ci-bin:$PATH"84-if ! cargo-zigbuild --version 2>/dev/null \\85- | grep -Fxq 'cargo-zigbuild 0.23.0'; then86- cargo install cargo-zigbuild --locked --version 0.23.087-fi88-test "$(zig version)" = 0.16.089-test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'"""124+DIGEST_PINNED = re.compile(r"[A-Za-z0-9._/-]+@[0-9a-f]{40}")90125 91126 92127 class PolicyError(ValueError):93- """The workflow does not meet the mandatory cross-build policy."""94-95-96-@dataclass97-class Step:98- name: str99- scalars: dict[str, str] = field(default_factory=dict)100- mappings: dict[str, dict[str, str]] = field(default_factory=dict)101- blocks: dict[str, str] = field(default_factory=dict)102-103- @property104- def field_names(self) -> set[str]:105- return {"name", *self.scalars, *self.mappings, *self.blocks}128+ """The workflow does not meet the mandatory complete demo gate policy."""106129 107130 108131 def _indent(line: str) -> int:@@ -115,378 +138,446 @@ def _is_content(line: str) -> bool:
115138 116139 117140 def _scalar(value: str) -> str:118- value = value.split(" #", 1)[0].strip()141+ value = value.strip()142+ if value[:1] in "\"'" and len(value) >= 2:143+ quote = value[0]144+ end = value.find(quote, 1)145+ if end != -1:146+ return value[1:end]147+ value = re.sub(r"\s+#.*$", "", value).strip()119148 if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":120149 return value[1:-1]121150 return value122151 123152 124-def _mapping_entry(line: str, indent: int) -> tuple[str, str] | None:125- if _indent(line) != indent or not _is_content(line):126- return None127- match = re.fullmatch(128- rf" {{{indent}}}({MAPPING_KEY}):(?:\s*(.*))?",129- line,130- )131- if not match:132- return None133- return _scalar(match.group(1)), (match.group(2) or "").strip()134-135-136-def _mapping_key(line: str, indent: int) -> str | None:137- entry = _mapping_entry(line, indent)138- return entry[0] if entry else None139-140-141-def _block_end(lines: list[str], start: int, indent: int) -> int:142- for index in range(start + 1, len(lines)):143- if _mapping_entry(lines[index], indent) is not None:144- return index145- if _is_content(lines[index]) and _indent(lines[index]) < indent:146- return index147- return len(lines)148-149-150-def _validate_workflow_layout(workflow: str) -> None:151- lines = workflow.splitlines()152- for line in lines:153- if _is_content(line) and _indent(line) == 0:154- if _mapping_entry(line, 0) is None:155- raise PolicyError(f"cannot parse top-level workflow field: {line}")156- top_level = [157- (index, entry)158- for index, line in enumerate(lines)159- if (entry := _mapping_entry(line, 0)) is not None160- ]161- top_level_names = [key for _, (key, _) in top_level]162- if len(top_level_names) != len(set(top_level_names)):163- raise PolicyError("workflow contains a duplicate top-level field")164-165- if any(key == "defaults" for _, (key, _) in top_level):153+def _flow_sequence(value: str) -> list[str]:154+ inner = value.strip()[1:-1].strip()155+ if not inner:156+ return []157+ return [_scalar(item) for item in inner.split(",")]158+159+160+class Parser:161+ """A strict parser for the YAML subset these workflows use.162+163+ Anything it does not recognise raises PolicyError. An unparseable workflow164+ is a rejected workflow, which keeps the checker fail-closed instead of165+ silently ignoring a construct it does not understand.166+ """167+168+ def __init__(self, text: str) -> None:169+ self.lines = text.splitlines()170+171+ def parse(self) -> dict:172+ value, index = self._block(0, 0)173+ index = self._next_content(index)174+ if index != len(self.lines):175+ raise PolicyError(f"cannot parse workflow line: {self.lines[index]}")176+ if not isinstance(value, dict):177+ raise PolicyError("workflow must be a mapping at the top level")178+ return value179+180+ def _next_content(self, index: int) -> int:181+ while index < len(self.lines) and not _is_content(self.lines[index]):182+ index += 1183+ return index184+185+ def _block(self, index: int, indent: int) -> tuple[object, int]:186+ index = self._next_content(index)187+ if index >= len(self.lines) or _indent(self.lines[index]) < indent:188+ return None, index189+ if self.lines[index].lstrip(" ").startswith("- "):190+ return self._sequence(index, indent)191+ return self._mapping_from(None, index, indent)192+193+ def _sequence(self, index: int, indent: int) -> tuple[list, int]:194+ items: list[object] = []195+ while True:196+ index = self._next_content(index)197+ if index >= len(self.lines):198+ break199+ line = self.lines[index]200+ if _indent(line) != indent or not line.lstrip(" ").startswith("- "):201+ break202+ rest = line.lstrip(" ")[2:]203+ if ":" in rest:204+ entry, index = self._mapping_from(rest, index + 1, indent + 2)205+ items.append(entry)206+ else:207+ items.append(_scalar(rest))208+ index += 1209+ return items, index210+211+ def _mapping_from(212+ self, first: str | None, index: int, indent: int213+ ) -> tuple[dict, int]:214+ entries: dict[str, object] = {}215+216+ def add(key: str, value: object) -> None:217+ if key in entries:218+ raise PolicyError(f"duplicate key {key!r} in the workflow")219+ entries[key] = value220+221+ if first is not None:222+ key, value, index = self._entry(first, index, indent)223+ add(key, value)224+ while True:225+ index = self._next_content(index)226+ if index >= len(self.lines):227+ break228+ line = self.lines[index]229+ if _indent(line) < indent:230+ break231+ if _indent(line) > indent:232+ raise PolicyError(f"unexpected indentation: {line}")233+ if line.lstrip(" ").startswith("- "):234+ break235+ key, value, index = self._entry(line.lstrip(" "), index + 1, indent)236+ add(key, value)237+ return entries, index238+239+ def _entry(self, text: str, index: int, indent: int) -> tuple[str, object, int]:240+ match = re.fullmatch(241+ r"(\"[^\"]+\"|'[^']+'|[A-Za-z0-9_.-]+):(?:\s+(.*))?", text242+ )243+ if not match:244+ raise PolicyError(f"cannot parse workflow field: {text}")245+ key = _scalar(match.group(1))246+ raw = (match.group(2) or "").strip()247+ if raw in ("|", "|-", ">", ">-"):248+ body, index = self._block_scalar(index, indent, raw)249+ return key, body, index250+ if raw.startswith("["):251+ return key, _flow_sequence(raw), index252+ if raw:253+ return key, _scalar(raw), index254+ value, index = self._block(index, indent + 2)255+ return key, value, index256+257+ def _block_scalar(self, index: int, indent: int, style: str) -> tuple[str, int]:258+ body: list[str] = []259+ content_indent: int | None = None260+ while index < len(self.lines):261+ line = self.lines[index]262+ if not line.strip():263+ body.append("")264+ index += 1265+ continue266+ if _indent(line) <= indent:267+ break268+ if content_indent is None:269+ content_indent = _indent(line)270+ if _indent(line) < content_indent:271+ break272+ body.append(line[content_indent:])273+ index += 1274+ text = "\n".join(body).rstrip()275+ if style.endswith("-"):276+ return text, index277+ return text + "\n", index278+279+280+def _require_mapping(value: object, what: str) -> dict:281+ if not isinstance(value, dict):282+ raise PolicyError(f"{what} must be a mapping")283+ return value284+285+286+def _steps(job: dict, name: str) -> list[dict]:287+ steps = job.get("steps")288+ if not isinstance(steps, list) or not steps:289+ raise PolicyError(f"jobs.{name} must define a non-empty steps list")290+ for step in steps:291+ if not isinstance(step, dict):292+ raise PolicyError(f"jobs.{name} contains a step that is not a mapping")293+ return steps294+295+296+def _run_body(step: dict) -> str:297+ run = step.get("run")298+ return run if isinstance(run, str) else ""299+300+301+def _check_no_failure_suppression(job_name: str, job: dict) -> None:302+ if "continue-on-error" in job:303+ raise PolicyError(f"jobs.{job_name} must not suppress failures")304+ if "defaults" in job:305+ raise PolicyError(306+ f"jobs.{job_name} must not override run defaults or shell failure behavior"307+ )308+ for step in _steps(job, job_name):309+ if "continue-on-error" in step:310+ raise PolicyError(f"jobs.{job_name} has a step that suppresses failures")311+ if "if" in step:312+ raise PolicyError(313+ f"jobs.{job_name} has a conditionally disabled step; gate a whole leg instead"314+ )315+ body = _run_body(step)316+ for token in FORBIDDEN_RUN_TOKENS:317+ if token in body:318+ raise PolicyError(319+ f"jobs.{job_name} has a step containing the forbidden token {token!r}"320+ )321+ uses = step.get("uses")322+ if uses is None:323+ continue324+ if not isinstance(uses, str) or not DIGEST_PINNED.fullmatch(uses):325+ raise PolicyError(326+ f"jobs.{job_name} uses an action that is not digest-pinned: {uses!r}"327+ )328+ if uses.startswith("actions/checkout@"):329+ if uses != CHECKOUT_ACTION:330+ raise PolicyError(331+ "checkout action is not digest-pinned to the policy contract"332+ )333+ if "with" in step:334+ raise PolicyError(f"jobs.{job_name} overrides the checked-out ref")335+336+337+def _gate_invocation(job_name: str, job: dict) -> dict:338+ matches = [step for step in _steps(job, job_name) if GATE_SCRIPT in _run_body(step)]339+ if len(matches) != 1:340+ raise PolicyError(341+ f"jobs.{job_name} must invoke the repository gate exactly once"342+ )343+ return matches[0]344+345+346+def _validate_triggers(workflow: dict) -> None:347+ if "defaults" in workflow:166348 raise PolicyError(167349 "workflow-level defaults are forbidden because they can suppress gate failures"168350 )169- allowed_top_level = {"name", "on", "permissions", "concurrency", "jobs"}170- if set(top_level_names) != allowed_top_level:171- raise PolicyError("workflow top-level fields changed from the CI policy contract")172-173- on_entries = [(index, value) for index, (key, value) in top_level if key == "on"]174- if len(on_entries) != 1:175- raise PolicyError("workflow must contain exactly one top-level on mapping")176- on_start, on_value = on_entries[0]177- if _scalar(on_value):178- raise PolicyError("workflow on field must be a trigger mapping")179- on_end = _block_end(lines, on_start, 0)180- pull_request_indices = [181- index182- for index in range(on_start + 1, on_end)183- if _mapping_key(lines[index], 2) == "pull_request"184- ]185- if len(pull_request_indices) != 1:351+ if set(workflow) != {"name", "on", "permissions", "concurrency", "jobs"}:352+ raise PolicyError(353+ "workflow top-level fields changed from the CI policy contract"354+ )355+356+ triggers = _require_mapping(workflow["on"], "workflow on field")357+ if "pull_request" not in triggers:186358 raise PolicyError(187359 "ordinary CI must contain exactly one empty pull_request trigger"188360 )189- pull_request_index = pull_request_indices[0]190- pull_request_entry = _mapping_entry(lines[pull_request_index], 2)191- if pull_request_entry is None or _scalar(pull_request_entry[1]):192- raise PolicyError("ordinary CI pull_request trigger must be exactly empty")193-194- pull_request_end = on_end195- for index in range(pull_request_index + 1, on_end):196- if _is_content(lines[index]) and _indent(lines[index]) <= 2:197- pull_request_end = index198- break199- if any(200- _is_content(line)201- for line in lines[pull_request_index + 1 : pull_request_end]202- ):361+ if triggers["pull_request"] is not None:203362 raise PolicyError(204363 "ordinary CI pull_request trigger must be exactly empty and unfiltered"205364 )365+ push = _require_mapping(triggers.get("push"), "workflow push trigger")366+ if push.get("branches") != ["main"]:367+ raise PolicyError("workflow push trigger must remain limited to main")368+369+370+def _validate_scope_job(jobs: dict) -> None:371+ job = _require_mapping(jobs.get(SCOPE_JOB), f"jobs.{SCOPE_JOB}")372+ if "if" in job:373+ raise PolicyError(f"jobs.{SCOPE_JOB} must not be conditionally disabled")374+ outputs = _require_mapping(job.get("outputs"), f"jobs.{SCOPE_JOB}.outputs")375+ if set(outputs) != {"code"}:376+ raise PolicyError(f"jobs.{SCOPE_JOB} must publish exactly the code output")377+378+ body = "\n".join(_run_body(step) for step in _steps(job, SCOPE_JOB))379+ for branch in REQUIRED_SCOPE_BRANCHES:380+ if branch not in body:381+ raise PolicyError(382+ f"jobs.{SCOPE_JOB} is missing its fail-closed branch: {branch}"383+ )384+ if body.count("code=true") < 3:385+ raise PolicyError(386+ f"jobs.{SCOPE_JOB} must classify every unprovable diff as code"387+ )206388 389+ lines = [line.strip() for line in body.splitlines() if line.strip()]390+ try:391+ start = lines.index(REQUIRED_SCOPE_CASE[0])392+ except ValueError:393+ raise PolicyError(394+ f"jobs.{SCOPE_JOB} must classify each changed path with a case statement"395+ ) from None396+ try:397+ end = lines.index("esac", start)398+ except ValueError:399+ raise PolicyError(400+ f"jobs.{SCOPE_JOB} path classification is not a closed case statement"401+ ) from None402+ if tuple(lines[start : end + 1]) != REQUIRED_SCOPE_CASE:403+ raise PolicyError(404+ f"jobs.{SCOPE_JOB} documentation path allowlist changed from the "405+ "CI policy contract"406+ )207407 208-def _job_lines(workflow: str, job_name: str) -> list[str]:209- lines = workflow.splitlines()210- jobs_indices = [211- index212- for index, line in enumerate(lines)213- if _mapping_key(line, 0) == "jobs"214- ]215- if len(jobs_indices) != 1:216- raise PolicyError("workflow must contain exactly one top-level jobs mapping")217- jobs_entry = _mapping_entry(lines[jobs_indices[0]], 0)218- if jobs_entry is None or _scalar(jobs_entry[1]):219- raise PolicyError("top-level jobs field must be a mapping")220-221- jobs_start = jobs_indices[0] + 1222- jobs_end = len(lines)223- for index in range(jobs_start, len(lines)):224- if _is_content(lines[index]) and _indent(lines[index]) == 0:225- jobs_end = index226- break227-228- matches = [229- index230- for index in range(jobs_start, jobs_end)231- if _mapping_key(lines[index], 2) == job_name232- ]233- if len(matches) != 1:234- raise PolicyError(f"jobs.{job_name} must exist exactly once")235-236- job_start = matches[0]237- job_entry = _mapping_entry(lines[job_start], 2)238- if job_entry is None or _scalar(job_entry[1]):239- raise PolicyError(f"jobs.{job_name} must be a mapping")240- job_end = jobs_end241- for index in range(job_start + 1, jobs_end):242- if _mapping_key(lines[index], 2) is not None:243- job_end = index244- break245- return lines[job_start:job_end]246-247-248-def _parse_step(block: list[str]) -> Step:249- match = re.fullmatch(r" - name:\s*(.+?)\s*", block[0])250- if not match:251- raise PolicyError("every demo-gate step must begin with an explicit name")252- step = Step(name=_scalar(match.group(1)))253-254- index = 1255- while index < len(block):256- line = block[index]257- if not _is_content(line):258- index += 1259- continue260- if _indent(line) != 8:261- raise PolicyError(f"unexpected YAML structure in step {step.name!r}: {line}")262- match = re.fullmatch(rf" ({MAPPING_KEY}):(?:\s*(.*))?", line)263- if not match:264- raise PolicyError(f"cannot parse field in step {step.name!r}: {line}")265- key = _scalar(match.group(1))266- value = (match.group(2) or "").strip()267- if key in step.field_names:268- raise PolicyError(f"duplicate field {key!r} in step {step.name!r}")269-270- if value == "|":271- index += 1272- body: list[str] = []273- while index < len(block):274- child = block[index]275- if _is_content(child) and _indent(child) <= 8:276- break277- if child.strip():278- if _indent(child) < 10:279- raise PolicyError(280- f"invalid block indentation in step {step.name!r}"281- )282- body.append(child[10:])283- else:284- body.append("")285- index += 1286- step.blocks[key] = "\n".join(body).rstrip()287- continue288408 289- if not value:290- index += 1291- entries: dict[str, str] = {}292- while index < len(block):293- child = block[index]294- if not _is_content(child):295- index += 1296- continue297- if _indent(child) <= 8:298- break299- child_match = re.fullmatch(300- rf" ({MAPPING_KEY}):\s*(.+?)\s*", child409+def _validate_gate_legs(jobs: dict) -> None:410+ for leg, stage in GATE_LEG_STAGES.items():411+ job = _require_mapping(jobs.get(leg), f"jobs.{leg}")412+ if job.get("runs-on") != REQUIRED_RUNNER:413+ raise PolicyError(f"jobs.{leg} must run on {REQUIRED_RUNNER}")414+415+ step = _gate_invocation(leg, job)416+ body = _run_body(step)417+ if f"--stage {stage}" not in body:418+ raise PolicyError(419+ f"jobs.{leg} must run the repository gate stage {stage!r}"420+ )421+422+ env = step.get("env")423+ env = env if isinstance(env, dict) else {}424+ if leg == "cross-build":425+ if env.get("GOQ_REQUIRE_LINUX_CROSS_BUILD") != "1":426+ raise PolicyError(427+ "the cross-build leg does not require the Linux cross build"428+ )429+ if env.get("GOQ_VERIFY_IN_PROCESS_GSTREAMER") != "1":430+ raise PolicyError(431+ "the cross-build leg must cross-build the in-process GStreamer backend"432+ )433+ if leg == "gstreamer-gate":434+ if env.get("GOQ_VERIFY_IN_PROCESS_GSTREAMER") != "1":435+ raise PolicyError(436+ "the GStreamer leg does not require the in-process GStreamer gate"301437 )302- if not child_match:303- raise PolicyError(304- f"cannot parse {key!r} mapping in step {step.name!r}: {child}"305- )306- child_key = _scalar(child_match.group(1))307- if child_key in entries:308- raise PolicyError(309- f"duplicate {key}.{child_key} in step {step.name!r}"310- )311- entries[child_key] = _scalar(child_match.group(2))312- index += 1313- step.mappings[key] = entries314- continue315-316- step.scalars[key] = _scalar(value)317- index += 1318438 319- return step439+ installs = [440+ candidate441+ for candidate in _steps(job, leg)442+ if DEPENDENCY_SCRIPT in _run_body(candidate)443+ ]444+ if len(installs) != 1:445+ raise PolicyError(446+ f"jobs.{leg} must install the Linux build dependencies exactly once"447+ )448+449+450+def _validate_summary_job(jobs: dict) -> None:451+ job = _require_mapping(jobs.get(SUMMARY_JOB), f"jobs.{SUMMARY_JOB}")452+ if job.get("name") != SUMMARY_JOB_NAME:453+ raise PolicyError(f"jobs.{SUMMARY_JOB} must retain its required name")454+ if job.get("runs-on") != REQUIRED_RUNNER:455+ raise PolicyError(f"jobs.{SUMMARY_JOB} must run on {REQUIRED_RUNNER}")456+ # The summary job is the one place an `if` is mandatory rather than457+ # forbidden: it has to run when a leg has already failed so that it can458+ # report that failure.459+ if job.get("if") != "always()":460+ raise PolicyError(461+ f"jobs.{SUMMARY_JOB} must run with if: always() so a failed leg is reported"462+ )320463 464+ needs = job.get("needs")465+ if not isinstance(needs, list):466+ raise PolicyError(f"jobs.{SUMMARY_JOB} must declare a needs list")467+ if len(needs) != len(set(needs)):468+ raise PolicyError(f"jobs.{SUMMARY_JOB} needs list contains a duplicate")469+ expected = set(jobs) - {SUMMARY_JOB}470+ if set(needs) != expected:471+ missing = sorted(expected - set(needs))472+ unknown = sorted(set(needs) - expected)473+ raise PolicyError(474+ f"jobs.{SUMMARY_JOB} must depend on every other job; "475+ f"missing={missing} unknown={unknown}"476+ )321477 322-def _parse_steps(job: list[str]) -> list[Step]:323- job_entries: list[tuple[str, str]] = []324- for line in job:325- if not _is_content(line) or _indent(line) != 4:326- continue327- entry = _mapping_entry(line, 4)328- if entry is None:329- raise PolicyError(f"cannot parse jobs.demo-gate field: {line}")330- job_entries.append(entry)331- job_field_list = [key for key, _ in job_entries]332- if len(job_field_list) != len(set(job_field_list)):333- raise PolicyError("jobs.demo-gate contains a duplicate field")334- job_fields = set(job_field_list)335- if "if" in job_fields:336- raise PolicyError("jobs.demo-gate must not be conditionally disabled")337- if "continue-on-error" in job_fields:338- raise PolicyError("jobs.demo-gate must not suppress failures")339- if "defaults" in job_fields:478+ body = "\n".join(_run_body(step) for step in _steps(job, SUMMARY_JOB))479+ if "exit 1" not in body:480+ raise PolicyError(f"jobs.{SUMMARY_JOB} must fail when a leg did not pass")481+ declared = re.search(r"skippable='([^']*)'", body)482+ if declared is None:340483 raise PolicyError(341- "jobs.demo-gate must not override run defaults or shell failure behavior"484+ f"jobs.{SUMMARY_JOB} must declare which legs a docs-only diff may skip"485+ )486+ if set(declared.group(1).split()) != set(SKIPPABLE_JOBS):487+ raise PolicyError(488+ f"jobs.{SUMMARY_JOB} skippable legs changed from the CI policy contract"489+ )490+ for required in ("\"$SCOPE_CODE\" == 'false'", "leg_must_not_be_skipped"):491+ if required not in body:492+ raise PolicyError(493+ f"jobs.{SUMMARY_JOB} must only tolerate a skip the docs-only scope made"494+ )495+496+497+def verify(workflow_text: str) -> None:498+ workflow = Parser(workflow_text).parse()499+ _validate_triggers(workflow)500+ jobs = _require_mapping(workflow["jobs"], "top-level jobs field")501+502+ contract = ALWAYS_RUN_JOBS | SKIPPABLE_JOBS | {SUMMARY_JOB}503+ if set(jobs) != contract:504+ missing = sorted(contract - set(jobs))505+ unknown = sorted(set(jobs) - contract)506+ raise PolicyError(507+ "workflow jobs changed from the CI policy contract: "508+ f"missing={missing} unknown={unknown}"342509 )343- if job_fields != {"name", "runs-on", "timeout-minutes", "env", "steps"}:344- raise PolicyError("jobs.demo-gate fields changed from the CI policy contract")345- job_values = {key: value for key, value in job_entries}346- if _scalar(job_values["name"]) != "Complete demo gate":347- raise PolicyError("jobs.demo-gate must retain its required name")348- if _scalar(job_values["runs-on"]) != "ubuntu-24.04":349- raise PolicyError("jobs.demo-gate must run on ubuntu-24.04")350- if _scalar(job_values["timeout-minutes"]) != "45":351- raise PolicyError("jobs.demo-gate timeout must remain 45 minutes")352- if _scalar(job_values["env"]):353- raise PolicyError("jobs.demo-gate env field must be an exact mapping")354- if _scalar(job_values["steps"]):355- raise PolicyError("jobs.demo-gate steps field must be a list")356-357- env_indices = [358- index359- for index, line in enumerate(job)360- if _mapping_key(line, 4) == "env"361- ]362- if len(env_indices) != 1:363- raise PolicyError("jobs.demo-gate must contain exactly one env mapping")364- env_start = env_indices[0]365- env_end = len(job)366- for index in range(env_start + 1, len(job)):367- if _is_content(job[index]) and _indent(job[index]) <= 4:368- env_end = index369- break370- env_entries: list[tuple[str, str]] = []371- for line in job[env_start + 1 : env_end]:372- if not _is_content(line):373- continue374- entry = _mapping_entry(line, 6)375- if entry is None:376- raise PolicyError(f"cannot parse jobs.demo-gate env field: {line}")377- env_entries.append(entry)378- env_names = [key for key, _ in env_entries]379- if len(env_names) != len(set(env_names)):380- raise PolicyError("jobs.demo-gate env contains a duplicate field")381- env = {key: _scalar(value) for key, value in env_entries}382- if env != {"CARGO_TERM_COLOR": "always", "RUST_BACKTRACE": "1"}:383- raise PolicyError("jobs.demo-gate env changed from the CI policy contract")384-385- steps_indices = [386- index387- for index, line in enumerate(job)388- if line == " steps:"389- ]390- if len(steps_indices) != 1:391- raise PolicyError("jobs.demo-gate must contain exactly one steps list")392-393- start = steps_indices[0] + 1394- starts = [395- index396- for index in range(start, len(job))397- if re.fullmatch(r" - name:\s*.+", job[index])398- ]399- if not starts:400- raise PolicyError("jobs.demo-gate contains no named steps")401-402- steps: list[Step] = []403- for position, step_start in enumerate(starts):404- step_end = starts[position + 1] if position + 1 < len(starts) else len(job)405- steps.append(_parse_step(job[step_start:step_end]))406- return steps407510 511+ for name, job in jobs.items():512+ _check_no_failure_suppression(name, _require_mapping(job, f"jobs.{name}"))408513 409-def _named_step(steps: list[Step], name: str) -> tuple[int, Step]:410- matches = [411- (index, step)412- for index, step in enumerate(steps)413- if step.name == name414- ]415- if len(matches) != 1:416- raise PolicyError(f"demo-gate step {name!r} must exist exactly once")417- return matches[0]514+ for name in sorted(ALWAYS_RUN_JOBS):515+ if "if" in jobs[name]:516+ raise PolicyError(517+ f"jobs.{name} must run for every change and must carry no if condition"518+ )519+ for name in sorted(SKIPPABLE_JOBS):520+ if jobs[name].get("if") != SCOPE_CONDITION:521+ raise PolicyError(522+ f"jobs.{name} may only be skipped by the audited docs-only scope, "523+ f"not by {jobs[name].get('if')!r}"524+ )525+526+ _validate_scope_job(jobs)527+ _validate_gate_legs(jobs)528+ _validate_summary_job(jobs)418529 419530 420531 def verify_gate_script(gate_script: str) -> None:421532 if not gate_script.startswith(GATE_SCRIPT_PREFIX):422533 raise PolicyError(423- "complete repository gate executable prefix changed before the required Linux cross build"534+ "complete repository gate executable prefix changed before its stage dispatch"424535 )425536 537+ # Checked before the structural assertions so that masking a failure is538+ # reported as masking a failure rather than as a shape problem.539+ for token in FORBIDDEN_RUN_TOKENS:540+ if token in gate_script:541+ raise PolicyError(542+ f"complete repository gate contains the forbidden token {token!r}"543+ )544+426545 if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:427546 raise PolicyError(428547 "complete repository gate must invoke the Linux cross-build gate exactly once"429548 )430549 550+ # Unconditional at its stage function's top level, so it cannot be buried in551+ # a branch that never runs. scripts/tests/demo-gate-stages.sh is what proves552+ # the stage actually reaches it; this only rejects the obvious shapes.553+ if not re.search(rf"^ {re.escape(LINUX_CROSS_BUILD_GATE)}$", gate_script, re.M):554+ raise PolicyError(555+ "the Linux cross-build gate must be invoked unconditionally by its stage"556+ )431557 432-def verify(workflow: str) -> None:433- _validate_workflow_layout(workflow)434- steps = _parse_steps(_job_lines(workflow, "demo-gate"))435-436- checkout_index, checkout = _named_step(steps, "Check out repository")437- dependency_index, dependency = _named_step(438- steps, "Install Linux build dependencies"439- )440- cache_index, cache = _named_step(steps, "Restore pinned cargo-zigbuild")441- install_index, install = _named_step(steps, "Install pinned cross-build tools")442- gate_index, gate = _named_step(steps, "Run complete demo gate")443-444- if [step.name for step in steps] != REQUIRED_STEP_NAMES:558+ declared = re.search(r"readonly ALL_STAGES=\(([^)]*)\)", gate_script)559+ if declared is None:560+ raise PolicyError("complete repository gate must declare its stage list")561+ stages = declared.group(1).split()562+ if set(stages) != set(GATE_LEG_STAGES.values()):445563 raise PolicyError(446- "demo-gate step list or order changed from the CI policy contract"564+ "complete repository gate stages changed from the CI policy contract"447565 )448566 449- if checkout.field_names != {"name", "uses"}:450- raise PolicyError("checkout step has unexpected or missing fields")451- if checkout.scalars.get("uses") != CHECKOUT_ACTION:452- raise PolicyError("checkout action is not digest-pinned to the policy contract")453-454- if dependency.field_names != {"name", "run"}:455- raise PolicyError("Linux dependency step has unexpected or missing fields")456- if dependency.blocks.get("run") != DEPENDENCY_RUN:457- raise PolicyError("Linux dependency step body changed")458-459- if cache.field_names != {"name", "uses", "with"}:460- raise PolicyError("cargo-zigbuild cache step has unexpected or missing fields")461- if cache.scalars.get("uses") != CACHE_ACTION:462- raise PolicyError("cargo-zigbuild cache action is not digest-pinned")463- if cache.mappings.get("with") != {"path": CACHE_PATH, "key": CACHE_KEY}:464- raise PolicyError("cargo-zigbuild cache contract changed")465-466- if install.field_names != {"name", "run"}:467- raise PolicyError("cross-build install step has unexpected or missing fields")468- if install.blocks.get("run") != INSTALL_RUN:469- raise PolicyError("cross-build install step body changed")470-471- if gate.field_names != {"name", "env", "run"}:472- raise PolicyError("complete demo gate step has unexpected or missing fields")473- if gate.mappings.get("env") != {474- "GOQ_VERIFY_IN_PROCESS_GSTREAMER": "1",475- "GOQ_REQUIRE_LINUX_CROSS_BUILD": "1",476- }:477- raise PolicyError("complete demo gate does not require the Linux cross build")478- if gate.scalars.get("run") != "./scripts/verify-demo-build.sh":479- raise PolicyError("complete demo gate does not execute the repository gate")480-481- if [482- checkout_index,483- dependency_index,484- cache_index,485- install_index,486- gate_index,487- ] != list(range(len(REQUIRED_STEP_NAMES))):567+ for stage in stages:568+ function = f"run_stage_{stage.replace('-', '_')}"569+ if f"{function}()" not in gate_script:570+ raise PolicyError(f"complete repository gate is missing {function}")571+ if not re.search(rf"^\s*{re.escape(stage)}\) {function} ;;", gate_script, re.M):572+ raise PolicyError(573+ f"complete repository gate does not dispatch the {stage!r} stage"574+ )575+576+ # With no arguments the gate must still run every stage, so the release and577+ # hardware-UAT workflows keep the coverage they had as one serial job.578+ if 'for current_stage in "${ALL_STAGES[@]}"' not in gate_script:488579 raise PolicyError(489- "demo-gate step list or order changed from the CI policy contract"580+ "complete repository gate must run every stage when invoked with no stage"490581 )491582 492583