ci(gate): require the glibc 2.17 cross build in PRs
- commit
- 35b7ce0b1eba2b93711559a9c005ce77a37aa99a
- parent
- 7495a320801c64dfbe2c96c5b6b278b8b6ea09b6
- parent
- ac5d8786cf89526a80e47e34d781bb59bbaf5772
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | .github/ | 35 | ++ |
| A | scripts/ | 58 | +++ |
| A | scripts/ | 11 | + |
| A | scripts/ | 95 | ++++++ |
| A | scripts/ | 398 | +++++++++++++++++++++++ |
| M | scripts/ | 36 | +- |
| A | scripts/ | 509 | ++++++++++++++++++++++++++++++ |
@@ -82,9 +82,44 @@ jobs:
8282 libwebkit2gtk-4.1-dev \8383 libxdo-dev \8484 pkg-config \85+ python3-venv \8586 shellcheck8687 88+ - name: Restore pinned cargo-zigbuild89+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.490+ with:91+ path: ~/.cargo/bin/cargo-zigbuild92+ key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.093+94+ - name: Install pinned cross-build tools95+ run: |96+ python3 -m venv "$RUNNER_TEMP/zig-venv"97+ printf '%s\n' \98+ 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \99+ | "$RUNNER_TEMP/zig-venv/bin/pip" install \100+ --disable-pip-version-check \101+ --only-binary=:all: \102+ --require-hashes \103+ --requirement /dev/stdin104+ install -d "$RUNNER_TEMP/ci-bin"105+ # Expand runner paths when the generated wrapper executes.106+ # shellcheck disable=SC2016107+ printf '%s\n' \108+ '#!/usr/bin/env bash' \109+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \110+ >"$RUNNER_TEMP/ci-bin/zig"111+ chmod 0755 "$RUNNER_TEMP/ci-bin/zig"112+ echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"113+ export PATH="$RUNNER_TEMP/ci-bin:$PATH"114+ if ! cargo-zigbuild --version 2>/dev/null \115+ | grep -Fxq 'cargo-zigbuild 0.23.0'; then116+ cargo install cargo-zigbuild --locked --version 0.23.0117+ fi118+ test "$(zig version)" = 0.16.0119+ test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'120+87121 - name: Run complete demo gate88122 env:89123 GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"124+ GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"90125 run: ./scripts/verify-demo-build.sh@@ -0,0 +1,58 @@
1+2+set -euo pipefail3+4+case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in5+ 0|1) ;;6+ *)7+ printf 'GOQ_VERIFY_IN_PROCESS_GSTREAMER must be 0 or 1\n' >&28+ exit 19+ ;;10+esac11+12+case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in13+ 0|1) ;;14+ *)15+ printf 'GOQ_REQUIRE_LINUX_CROSS_BUILD must be 0 or 1\n' >&216+ exit 117+ ;;18+esac19+20+require_cross_commands() {21+ local cross_command22+23+ for cross_command in cargo-zigbuild zig; do24+ if ! command -v "$cross_command" >/dev/null 2>&1; then25+ printf 'required Linux cross-build command is missing at build time: %s\n' \26+ "$cross_command" >&227+ exit 128+ fi29+ done30+}31+32+run_cross_build() {33+ # Re-check at the point of use. The complete repository gate may spend34+ # several minutes on native tests after its initial tool preflight.35+ require_cross_commands36+37+ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then38+ # Zig intentionally omits the host's default system-library paths for an39+ # explicit target. Preserve pkg-config's system -L entries so the dynamic40+ # GStreamer/GLib development libraries remain linkable without an rpath.41+ PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \42+ cargo zigbuild --locked -p sigil-host --bins \43+ --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer44+ else45+ cargo zigbuild --locked -p sigil-host --bins \46+ --target x86_64-unknown-linux-gnu.2.1747+ fi48+ echo 'linux_cross_build=ok'49+}50+51+if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then52+ run_cross_build53+elif command -v cargo-zigbuild >/dev/null 2>&1 \54+ && command -v zig >/dev/null 2>&1; then55+ run_cross_build56+else57+ echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'58+fi@@ -0,0 +1,11 @@
1+2+set -euo pipefail3+4+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"5+repo_dir="$(cd -- "$script_dir/../.." && pwd -P)"6+7+cd "$repo_dir"8+python3 scripts/verify_ci_cross_build_policy.py \9+ .github/workflows/ci.yml \10+ scripts/verify-demo-build.sh11+python3 -m unittest scripts.tests.test_ci_cross_build_policy -v@@ -0,0 +1,95 @@
1+2+# shellcheck disable=SC20163+set -euo pipefail4+5+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(cd -- "$script_dir/../.." && pwd -P)"7+gate="$repo_dir/scripts/run-linux-cross-build-gate.sh"8+temp_root="$(mktemp -d "${TMPDIR:-/tmp}/goq-cross-build-gate.XXXXXX")"9+10+cleanup() {11+ rm -r -- "$temp_root"12+}13+trap cleanup EXIT14+15+make_command() {16+ local bin_dir="$1"17+ local command_name="$2"18+ shift 219+20+ install -d "$bin_dir"21+ if [[ ! -e "$bin_dir/bash" ]]; then22+ ln -s "$(command -v bash)" "$bin_dir/bash"23+ fi24+ printf '%s\n' '#!/usr/bin/env bash' "$@" >"$bin_dir/$command_name"25+ chmod 0755 "$bin_dir/$command_name"26+}27+28+success_bin="$temp_root/success-bin"29+make_command "$success_bin" cargo \30+ 'printf "%s\n" "$*" >"$GOQ_CARGO_ARGS_LOG"' \31+ 'printf "%s %s\n" "${PKG_CONFIG_ALLOW_CROSS:-}" "${PKG_CONFIG_ALLOW_SYSTEM_LIBS:-}" >"$GOQ_CARGO_ENV_LOG"'32+make_command "$success_bin" cargo-zigbuild 'exit 0'33+make_command "$success_bin" zig 'exit 0'34+35+success_log="$temp_root/success.log"36+GOQ_CARGO_ARGS_LOG="$temp_root/cargo-args.log" \37+ GOQ_CARGO_ENV_LOG="$temp_root/cargo-env.log" \38+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \39+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \40+ PATH="$success_bin" \41+ "$gate" >"$success_log"42+grep -Fxq 'linux_cross_build=ok' "$success_log"43+grep -Fxq \44+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17' \45+ "$temp_root/cargo-args.log"46+47+GOQ_CARGO_ARGS_LOG="$temp_root/cargo-args.log" \48+ GOQ_CARGO_ENV_LOG="$temp_root/cargo-env.log" \49+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \50+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=1 \51+ PATH="$success_bin" \52+ "$gate" >"$success_log"53+grep -Fxq \54+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer' \55+ "$temp_root/cargo-args.log"56+grep -Fxq '1 1' "$temp_root/cargo-env.log"57+58+missing_bin="$temp_root/missing-bin"59+make_command "$missing_bin" cargo \60+ 'echo "required mode invoked cargo without both tools" >&2' \61+ 'exit 91'62+make_command "$missing_bin" cargo-zigbuild 'exit 0'63+64+missing_log="$temp_root/missing.log"65+if GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \66+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \67+ PATH="$missing_bin" \68+ "$gate" >"$temp_root/missing.stdout" 2>"$missing_log"; then69+ echo 'required cross-build gate accepted a missing point-of-use tool' >&270+ exit 171+fi72+grep -Fxq \73+ 'required Linux cross-build command is missing at build time: zig' \74+ "$missing_log"75+if grep -Fq 'required mode invoked cargo' "$missing_log"; then76+ echo 'required cross-build gate invoked cargo after tool validation failed' >&277+ exit 178+fi79+if grep -Fq 'linux_cross_build=skipped' "$temp_root/missing.stdout"; then80+ echo 'required cross-build gate entered the optional skip path' >&281+ exit 182+fi83+84+optional_bin="$temp_root/optional-bin"85+make_command "$optional_bin" cargo 'exit 92'86+optional_log="$temp_root/optional.log"87+GOQ_REQUIRE_LINUX_CROSS_BUILD=0 \88+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \89+ PATH="$optional_bin" \90+ "$gate" >"$optional_log"91+grep -Fxq \92+ 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)' \93+ "$optional_log"94+95+echo 'linux_cross_build_gate=ok'@@ -0,0 +1,398 @@
1+from __future__ import annotations2+3+import unittest4+from pathlib import Path5+6+from scripts.verify_ci_cross_build_policy import PolicyError, verify, verify_gate_script7+8+9+REPO_DIR = Path(__file__).resolve().parents[2]10+WORKFLOW = (REPO_DIR / ".github" / "workflows" / "ci.yml").read_text(11+ encoding="utf-8"12+)13+GATE_SCRIPT = (REPO_DIR / "scripts" / "verify-demo-build.sh").read_text(14+ encoding="utf-8"15+)16+17+18+def replace_once(source: str, old: str, new: str) -> str:19+ if source.count(old) != 1:20+ raise AssertionError(f"fixture source must contain exactly one {old!r}")21+ return source.replace(old, new, 1)22+23+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]30+31+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)37+38+39+class CiCrossBuildPolicyTests(unittest.TestCase):40+ def test_repository_workflow_passes(self) -> None:41+ verify(WORKFLOW)42+ verify_gate_script(GATE_SCRIPT)43+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)54+55+ def test_rejects_successful_early_exit_before_cross_build_helper(self) -> None:56+ 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",61+ )62+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):63+ verify_gate_script(mutated)64+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):75+ 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",79+ )80+ with self.assertRaisesRegex(81+ PolicyError, "executable prefix changed"82+ ):83+ verify_gate_script(mutated)84+85+ def test_rejects_failure_masked_cross_build_helper(self) -> None:86+ mutated = replace_once(87+ GATE_SCRIPT,88+ "./scripts/run-linux-cross-build-gate.sh",89+ "./scripts/run-linux-cross-build-gate.sh || true",90+ )91+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):92+ verify_gate_script(mutated)93+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",99+ )100+ mutated += "\n# run: ./scripts/verify-demo-build.sh\n"101+ with self.assertRaisesRegex(102+ PolicyError, "does not execute the repository gate"103+ ):104+ verify(mutated)105+106+ def test_rejects_conditionally_disabled_cross_install(self) -> None:107+ mutated = replace_once(108+ WORKFLOW,109+ " - name: Install pinned cross-build tools\n run: |",110+ " - name: Install pinned cross-build tools\n"111+ " if: false\n"112+ " run: |",113+ )114+ with self.assertRaisesRegex(PolicyError, "unexpected or missing fields"):115+ verify(mutated)116+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",124+ )125+ with self.assertRaisesRegex(126+ PolicyError, "checkout step has unexpected or missing fields"127+ ):128+ verify(mutated)129+130+ def test_rejects_checkout_action_change(self) -> None:131+ mutated = replace_once_in_demo_gate(132+ WORKFLOW,133+ "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",134+ "actions/checkout@main",135+ )136+ with self.assertRaisesRegex(PolicyError, "checkout action is not digest-pinned"):137+ verify(mutated)138+139+ def test_rejects_step_poisoning_cargo_environment(self) -> None:140+ mutated = replace_once(141+ 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",146+ )147+ with self.assertRaisesRegex(148+ PolicyError, "step list or order changed from the CI policy contract"149+ ):150+ verify(mutated)151+152+ def test_rejects_step_replacing_cargo_zigbuild(self) -> None:153+ mutated = replace_once(154+ 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",162+ )163+ with self.assertRaisesRegex(164+ PolicyError, "step list or order changed from the CI policy contract"165+ ):166+ verify(mutated)167+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(179+ WORKFLOW,180+ marker,181+ f" - name: Extra step {position}\n"182+ " run: echo unexpected\n\n"183+ f"{marker}",184+ )185+ with self.assertRaisesRegex(186+ PolicyError, "step list or order changed from the CI policy contract"187+ ):188+ verify(mutated)189+190+ mutated = WORKFLOW + (191+ "\n"192+ " - name: Extra trailing step\n"193+ " run: echo unexpected\n"194+ )195+ with self.assertRaisesRegex(196+ PolicyError, "step list or order changed from the CI policy contract"197+ ):198+ verify(mutated)199+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:]214+ )215+ with self.assertRaisesRegex(216+ PolicyError, "step list or order changed from the CI policy contract"217+ ):218+ verify(mutated)219+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)243+244+ def test_rejects_linux_dependency_step_field_changes(self) -> None:245+ mutated = replace_once(246+ 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: |",252+ )253+ with self.assertRaisesRegex(254+ PolicyError, "Linux dependency step has unexpected or missing fields"255+ ):256+ verify(mutated)257+258+ def test_rejects_conditionally_disabled_demo_job(self) -> None:259+ mutated = replace_once(260+ WORKFLOW,261+ " demo-gate:\n name: Complete demo gate",262+ " demo-gate:\n if: false\n name: Complete demo gate",263+ )264+ with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):265+ verify(mutated)266+267+ def test_rejects_quoted_conditionally_disabled_demo_job(self) -> None:268+ mutated = replace_once(269+ WORKFLOW,270+ " demo-gate:\n name: Complete demo gate",271+ ' demo-gate:\n "if": false\n name: Complete demo gate',272+ )273+ with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):274+ verify(mutated)275+276+ def test_rejects_demo_job_continue_on_error(self) -> None:277+ mutated = replace_once(278+ WORKFLOW,279+ " demo-gate:\n name: Complete demo gate",280+ " demo-gate:\n continue-on-error: true\n"281+ " name: Complete demo gate",282+ )283+ with self.assertRaisesRegex(PolicyError, "must not suppress failures"):284+ verify(mutated)285+286+ def test_rejects_missing_pull_request_trigger(self) -> None:287+ mutated = replace_once(WORKFLOW, " pull_request:\n", "")288+ with self.assertRaisesRegex(289+ PolicyError, "empty pull_request trigger"290+ ):291+ verify(mutated)292+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)310+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):332+ verify(mutated)333+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+ ):349+ verify(mutated)350+351+ def test_rejects_failure_suppressing_workflow_shell_default(self) -> None:352+ mutated = replace_once(353+ WORKFLOW,354+ "name: CI\n\non:",355+ "name: CI\n\ndefaults:\n"356+ " run:\n"357+ " shell: 'bash {0} || true'\n\n"358+ "on:",359+ )360+ with self.assertRaisesRegex(PolicyError, "workflow-level defaults are forbidden"):361+ verify(mutated)362+363+ def test_ignores_tokens_in_comments_and_later_jobs(self) -> None:364+ mutated, removed = remove_named_step(365+ WORKFLOW,366+ "Install pinned cross-build tools",367+ "Run complete demo gate",368+ )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}"376+ )377+ with self.assertRaisesRegex(PolicyError, "must exist exactly once"):378+ verify(mutated + decoy)379+380+ def test_rejects_wrong_install_body_even_with_tokens_in_later_job(self) -> None:381+ mutated = replace_once(382+ WORKFLOW,383+ " cargo install cargo-zigbuild --locked --version 0.23.0",384+ " echo skipped cargo install",385+ )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"392+ )393+ with self.assertRaisesRegex(PolicyError, "install step body changed"):394+ verify(mutated)395+396+397+if __name__ == "__main__":398+ unittest.main()@@ -10,6 +10,8 @@ if [[ -f "$HOME/.cargo/env" ]]; then
1010 source "$HOME/.cargo/env"1111 fi1212 13+./scripts/run-linux-cross-build-gate.sh14+1315 for command_name in cargo rustc node git ffmpeg shellcheck; do1416 if ! command -v "$command_name" >/dev/null 2>&1; then1517 printf 'required command is missing: %s\n' "$command_name" >&2@@ -25,6 +27,24 @@ case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in
2527 ;;2628 esac2729 30+case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in31+ 0|1) ;;32+ *)33+ printf 'GOQ_REQUIRE_LINUX_CROSS_BUILD must be 0 or 1\n' >&234+ exit 135+ ;;36+esac37+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" >&243+ exit 144+ fi45+ done46+fi47+2848 require_rust_test() {2949 local test_name="$1"3050 shift@@ -75,22 +95,6 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
7595 "$gstreamer_test" -- --ignored --nocapture7696 echo 'in_process_gstreamer_gate=ok'7797 fi78-if command -v cargo-zigbuild >/dev/null 2>&1 && command -v zig >/dev/null 2>&1; then79- if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then80- # Zig intentionally omits the host's default system-library paths for an81- # explicit target. Preserve pkg-config's system -L entries so the dynamic82- # GStreamer/GLib development libraries remain linkable without an rpath.83- PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \84- cargo zigbuild --locked -p sigil-host --bins \85- --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer86- else87- cargo zigbuild --locked -p sigil-host --bins \88- --target x86_64-unknown-linux-gnu.2.1789- fi90- echo 'linux_cross_build=ok'91-else92- echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'93-fi9498 while IFS= read -r frontend_source; do9599 node --check "$frontend_source"96100 done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)@@ -0,0 +1,509 @@
1+#!/usr/bin/env python32+"""Validate the executable semantics of CI's required glibc cross-build path."""3+4+from __future__ import annotations5+6+import argparse7+import re8+import sys9+from dataclasses import dataclass, field10+from pathlib import Path11+12+13+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"17+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+]26+GATE_SCRIPT_PREFIX = """\27+#!/usr/bin/env bash28+set -euo pipefail29+30+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"31+repo_dir="$(cd -- "$script_dir/.." && pwd -P)"32+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.sh40+"""41+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'"""90+91+92+class PolicyError(ValueError):93+ """The workflow does not meet the mandatory cross-build policy."""94+95+96+97+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+104+ def field_names(self) -> set[str]:105+ return {"name", *self.scalars, *self.mappings, *self.blocks}106+107+108+def _indent(line: str) -> int:109+ return len(line) - len(line.lstrip(" "))110+111+112+def _is_content(line: str) -> bool:113+ stripped = line.strip()114+ return bool(stripped) and not stripped.startswith("#")115+116+117+def _scalar(value: str) -> str:118+ value = value.split(" #", 1)[0].strip()119+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":120+ return value[1:-1]121+ return value122+123+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):166+ raise PolicyError(167+ "workflow-level defaults are forbidden because they can suppress gate failures"168+ )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:186+ raise PolicyError(187+ "ordinary CI must contain exactly one empty pull_request trigger"188+ )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+ ):203+ raise PolicyError(204+ "ordinary CI pull_request trigger must be exactly empty and unfiltered"205+ )206+207+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+ continue288+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*", child301+ )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 += 1318+319+ return step320+321+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:340+ raise PolicyError(341+ "jobs.demo-gate must not override run defaults or shell failure behavior"342+ )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 steps407+408+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]418+419+420+def verify_gate_script(gate_script: str) -> None:421+ if not gate_script.startswith(GATE_SCRIPT_PREFIX):422+ raise PolicyError(423+ "complete repository gate executable prefix changed before the required Linux cross build"424+ )425+426+ if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:427+ raise PolicyError(428+ "complete repository gate must invoke the Linux cross-build gate exactly once"429+ )430+431+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:445+ raise PolicyError(446+ "demo-gate step list or order changed from the CI policy contract"447+ )448+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))):488+ raise PolicyError(489+ "demo-gate step list or order changed from the CI policy contract"490+ )491+492+493+def main() -> int:494+ parser = argparse.ArgumentParser()495+ parser.add_argument("workflow", type=Path)496+ parser.add_argument("gate_script", type=Path)497+ args = parser.parse_args()498+ try:499+ verify(args.workflow.read_text(encoding="utf-8"))500+ verify_gate_script(args.gate_script.read_text(encoding="utf-8"))501+ except (OSError, PolicyError) as error:502+ print(f"CI cross-build policy failed: {error}", file=sys.stderr)503+ return 1504+ print("ci_cross_build_policy=ok")505+ return 0506+507+508+if __name__ == "__main__":509+ raise SystemExit(main())