- commit
- 85e6a8ecd93723060cb86cf1365cd82dcbdbe13a
- parent
- da9a38af72ae752313b274ba2c64a624a9f18692
- author
- Dami <dami.etoile@gmail.com>
| A | scripts/ | 58 | ++++++ |
| M | scripts/ | 79 | +------- |
| A | scripts/ | 92 | +++++++++ |
| A | scripts/ | 100 | ++++++++++ |
| M | scripts/ | 17 | +-- |
| A | scripts/ | 293 | ++++++++++++++++++++++++++++++ |
@@ -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@@ -1,79 +1,18 @@
11 2-# shellcheck disable=SC201632 set -euo pipefail43 54 script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"65 repo_dir="$(cd -- "$script_dir/../.." && pwd -P)"7-workflow="$repo_dir/.github/workflows/ci.yml"8-gate="$repo_dir/scripts/verify-demo-build.sh"9-demo_job="$(sed -n '/^ demo-gate:/,$p' "$workflow")"106 11-[[ -n "$demo_job" ]] || {12- echo 'CI cross-build policy failed: ordinary CI lacks the demo-gate job' >&213- exit 114-}7+cd "$repo_dir"8+python3 scripts/verify_ci_cross_build_policy.py .github/workflows/ci.yml9+python3 -m unittest scripts.tests.test_ci_cross_build_policy -v1510 16-fail() {17- printf 'CI cross-build policy failed: %s\n' "$*" >&211+gate_call_count="$(12+ grep -Fxc './scripts/run-linux-cross-build-gate.sh' \13+ scripts/verify-demo-build.sh || true14+)"15+[[ "$gate_call_count" == 1 ]] || {16+ echo 'complete repository gate must invoke the Linux cross-build gate exactly once' >&21817 exit 11918 }20-21-require_workflow_text() {22- local expected="$1"23- grep -Fq -- "$expected" <<<"$demo_job" \24- || fail "ordinary CI is missing required text: $expected"25-}26-27-require_gate_text() {28- local expected="$1"29- grep -Fq -- "$expected" "$gate" \30- || fail "repository gate is missing required text: $expected"31-}32-33-require_workflow_text 'python3-venv'34-require_workflow_text \35- 'uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4'36-require_workflow_text 'path: ~/.cargo/bin/cargo-zigbuild'37-require_workflow_text \38- 'key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0'39-require_workflow_text \40- 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b'41-require_workflow_text '--only-binary=:all:'42-require_workflow_text '--require-hashes'43-require_workflow_text \44- 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"'45-require_workflow_text 'cargo install cargo-zigbuild --locked --version 0.23.0'46-require_workflow_text 'test "$(zig version)" = 0.16.0'47-require_workflow_text \48- "test \"\$(cargo-zigbuild --version)\" = 'cargo-zigbuild 0.23.0'"49-require_workflow_text 'GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"'50-51-require_gate_text 'case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in'52-require_gate_text \53- 'if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then'54-require_gate_text \55- 'for cross_command in cargo-zigbuild zig; do'56-require_gate_text \57- 'required Linux cross-build command is missing: %s'58-require_gate_text \59- 'cargo zigbuild --locked -p sigil-host --bins'60-require_gate_text \61- '--target x86_64-unknown-linux-gnu.2.17'62-require_gate_text "echo 'linux_cross_build=ok'"63-64-install_step_line="$(65- grep -nF ' - name: Install pinned cross-build tools' "$workflow" \66- | cut -d: -f167-)"68-gate_step_line="$(69- grep -nF ' - name: Run complete demo gate' "$workflow" \70- | cut -d: -f171-)"72-[[ "$install_step_line" =~ ^[0-9]+$ ]] \73- || fail 'cross-build install step must occur exactly once'74-[[ "$gate_step_line" =~ ^[0-9]+$ ]] \75- || fail 'complete demo gate step must occur exactly once'76-((install_step_line < gate_step_line)) \77- || fail 'cross-build tools must be installed before the complete demo gate'78-79-echo 'ci_cross_build_policy=ok'@@ -0,0 +1,92 @@
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+ PATH="$success_bin" \40+ "$gate" >"$success_log"41+grep -Fxq 'linux_cross_build=ok' "$success_log"42+grep -Fxq \43+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17' \44+ "$temp_root/cargo-args.log"45+46+GOQ_CARGO_ARGS_LOG="$temp_root/cargo-args.log" \47+ GOQ_CARGO_ENV_LOG="$temp_root/cargo-env.log" \48+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \49+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=1 \50+ PATH="$success_bin" \51+ "$gate" >"$success_log"52+grep -Fxq \53+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer' \54+ "$temp_root/cargo-args.log"55+grep -Fxq '1 1' "$temp_root/cargo-env.log"56+57+missing_bin="$temp_root/missing-bin"58+make_command "$missing_bin" cargo \59+ 'echo "required mode invoked cargo without both tools" >&2' \60+ 'exit 91'61+make_command "$missing_bin" cargo-zigbuild 'exit 0'62+63+missing_log="$temp_root/missing.log"64+if GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \65+ PATH="$missing_bin" \66+ "$gate" >"$temp_root/missing.stdout" 2>"$missing_log"; then67+ echo 'required cross-build gate accepted a missing point-of-use tool' >&268+ exit 169+fi70+grep -Fxq \71+ 'required Linux cross-build command is missing at build time: zig' \72+ "$missing_log"73+if grep -Fq 'required mode invoked cargo' "$missing_log"; then74+ echo 'required cross-build gate invoked cargo after tool validation failed' >&275+ exit 176+fi77+if grep -Fq 'linux_cross_build=skipped' "$temp_root/missing.stdout"; then78+ echo 'required cross-build gate entered the optional skip path' >&279+ exit 180+fi81+82+optional_bin="$temp_root/optional-bin"83+make_command "$optional_bin" cargo 'exit 92'84+optional_log="$temp_root/optional.log"85+GOQ_REQUIRE_LINUX_CROSS_BUILD=0 \86+ PATH="$optional_bin" \87+ "$gate" >"$optional_log"88+grep -Fxq \89+ 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)' \90+ "$optional_log"91+92+echo 'linux_cross_build_gate=ok'@@ -0,0 +1,100 @@
1+from __future__ import annotations2+3+import unittest4+from pathlib import Path5+6+from scripts.verify_ci_cross_build_policy import PolicyError, verify7+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+14+15+def replace_once(source: str, old: str, new: str) -> str:16+ if source.count(old) != 1:17+ raise AssertionError(f"fixture source must contain exactly one {old!r}")18+ return source.replace(old, new, 1)19+20+21+def remove_named_step(source: str, name: str, next_name: str) -> tuple[str, str]:22+ marker = f" - name: {name}\n"23+ next_marker = f" - name: {next_name}\n"24+ start = source.index(marker)25+ end = source.index(next_marker, start)26+ return source[:start] + source[end:], source[start:end]27+28+29+class CiCrossBuildPolicyTests(unittest.TestCase):30+ def test_repository_workflow_passes(self) -> None:31+ verify(WORKFLOW)32+33+ def test_rejects_demo_gate_run_bypass_even_with_decoy_comment(self) -> None:34+ mutated = replace_once(35+ WORKFLOW,36+ " run: ./scripts/verify-demo-build.sh",37+ " run: echo bypassed",38+ )39+ mutated += "\n# run: ./scripts/verify-demo-build.sh\n"40+ with self.assertRaisesRegex(41+ PolicyError, "does not execute the repository gate"42+ ):43+ verify(mutated)44+45+ def test_rejects_conditionally_disabled_cross_install(self) -> None:46+ mutated = replace_once(47+ WORKFLOW,48+ " - name: Install pinned cross-build tools\n run: |",49+ " - name: Install pinned cross-build tools\n"50+ " if: false\n"51+ " run: |",52+ )53+ with self.assertRaisesRegex(PolicyError, "unexpected or missing fields"):54+ verify(mutated)55+56+ def test_rejects_conditionally_disabled_demo_job(self) -> None:57+ mutated = replace_once(58+ WORKFLOW,59+ " demo-gate:\n name: Complete demo gate",60+ " demo-gate:\n if: false\n name: Complete demo gate",61+ )62+ with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):63+ verify(mutated)64+65+ def test_ignores_tokens_in_comments_and_later_jobs(self) -> None:66+ mutated, removed = remove_named_step(67+ WORKFLOW,68+ "Install pinned cross-build tools",69+ "Run complete demo gate",70+ )71+ decoy = (72+ "\n# Install pinned cross-build tools\n"73+ "# GOQ_REQUIRE_LINUX_CROSS_BUILD: \"1\"\n"74+ " later-decoy:\n"75+ " runs-on: ubuntu-24.04\n"76+ " steps:\n"77+ f"{removed}"78+ )79+ with self.assertRaisesRegex(PolicyError, "must exist exactly once"):80+ verify(mutated + decoy)81+82+ def test_rejects_wrong_install_body_even_with_tokens_in_later_job(self) -> None:83+ mutated = replace_once(84+ WORKFLOW,85+ " cargo install cargo-zigbuild --locked --version 0.23.0",86+ " echo skipped cargo install",87+ )88+ mutated += (89+ "\n later-decoy:\n"90+ " runs-on: ubuntu-24.04\n"91+ " steps:\n"92+ " - name: Decoy\n"93+ " run: cargo install cargo-zigbuild --locked --version 0.23.0\n"94+ )95+ with self.assertRaisesRegex(PolicyError, "install step body changed"):96+ verify(mutated)97+98+99+if __name__ == "__main__":100+ unittest.main()@@ -90,22 +90,7 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
9090 "$gstreamer_test" -- --ignored --nocapture9191 echo 'in_process_gstreamer_gate=ok'9292 fi93-if command -v cargo-zigbuild >/dev/null 2>&1 && command -v zig >/dev/null 2>&1; then94- if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then95- # Zig intentionally omits the host's default system-library paths for an96- # explicit target. Preserve pkg-config's system -L entries so the dynamic97- # GStreamer/GLib development libraries remain linkable without an rpath.98- PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \99- cargo zigbuild --locked -p sigil-host --bins \100- --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer101- else102- cargo zigbuild --locked -p sigil-host --bins \103- --target x86_64-unknown-linux-gnu.2.17104- fi105- echo 'linux_cross_build=ok'106-else107- echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'108-fi93+./scripts/run-linux-cross-build-gate.sh10994 while IFS= read -r frontend_source; do11095 node --check "$frontend_source"11196 done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)@@ -0,0 +1,293 @@
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+CACHE_ACTION = "actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809"14+CACHE_PATH = "~/.cargo/bin/cargo-zigbuild"15+CACHE_KEY = "cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0"16+17+INSTALL_RUN = """\18+python3 -m venv "$RUNNER_TEMP/zig-venv"19+printf '%s\\n' \\20+ 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \\21+ | "$RUNNER_TEMP/zig-venv/bin/pip" install \\22+ --disable-pip-version-check \\23+ --only-binary=:all: \\24+ --require-hashes \\25+ --requirement /dev/stdin26+install -d "$RUNNER_TEMP/ci-bin"27+# Expand runner paths when the generated wrapper executes.28+# shellcheck disable=SC201629+printf '%s\\n' \\30+ '#!/usr/bin/env bash' \\31+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \\32+ >"$RUNNER_TEMP/ci-bin/zig"33+chmod 0755 "$RUNNER_TEMP/ci-bin/zig"34+echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"35+export PATH="$RUNNER_TEMP/ci-bin:$PATH"36+if ! cargo-zigbuild --version 2>/dev/null \\37+ | grep -Fxq 'cargo-zigbuild 0.23.0'; then38+ cargo install cargo-zigbuild --locked --version 0.23.039+fi40+test "$(zig version)" = 0.16.041+test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'"""42+43+44+class PolicyError(ValueError):45+ """The workflow does not meet the mandatory cross-build policy."""46+47+48+49+class Step:50+ name: str51+ scalars: dict[str, str] = field(default_factory=dict)52+ mappings: dict[str, dict[str, str]] = field(default_factory=dict)53+ blocks: dict[str, str] = field(default_factory=dict)54+55+56+ def field_names(self) -> set[str]:57+ return {"name", *self.scalars, *self.mappings, *self.blocks}58+59+60+def _indent(line: str) -> int:61+ return len(line) - len(line.lstrip(" "))62+63+64+def _is_content(line: str) -> bool:65+ stripped = line.strip()66+ return bool(stripped) and not stripped.startswith("#")67+68+69+def _scalar(value: str) -> str:70+ value = value.split(" #", 1)[0].strip()71+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":72+ return value[1:-1]73+ return value74+75+76+def _mapping_key(line: str, indent: int) -> str | None:77+ if _indent(line) != indent or not _is_content(line):78+ return None79+ match = re.fullmatch(rf" {{{indent}}}([A-Za-z0-9_-]+):(?:\s.*)?", line)80+ return match.group(1) if match else None81+82+83+def _job_lines(workflow: str, job_name: str) -> list[str]:84+ lines = workflow.splitlines()85+ jobs_indices = [86+ index87+ for index, line in enumerate(lines)88+ if line == "jobs:"89+ ]90+ if len(jobs_indices) != 1:91+ raise PolicyError("workflow must contain exactly one top-level jobs mapping")92+93+ jobs_start = jobs_indices[0] + 194+ jobs_end = len(lines)95+ for index in range(jobs_start, len(lines)):96+ if _is_content(lines[index]) and _indent(lines[index]) == 0:97+ jobs_end = index98+ break99+100+ matches = [101+ index102+ for index in range(jobs_start, jobs_end)103+ if re.fullmatch(rf" {re.escape(job_name)}:\s*(?:#.*)?", lines[index])104+ ]105+ if len(matches) != 1:106+ raise PolicyError(f"jobs.{job_name} must exist exactly once")107+108+ job_start = matches[0]109+ job_end = jobs_end110+ for index in range(job_start + 1, jobs_end):111+ if _mapping_key(lines[index], 2) is not None:112+ job_end = index113+ break114+ return lines[job_start:job_end]115+116+117+def _parse_step(block: list[str]) -> Step:118+ match = re.fullmatch(r" - name:\s*(.+?)\s*", block[0])119+ if not match:120+ raise PolicyError("every demo-gate step must begin with an explicit name")121+ step = Step(name=_scalar(match.group(1)))122+123+ index = 1124+ while index < len(block):125+ line = block[index]126+ if not _is_content(line):127+ index += 1128+ continue129+ if _indent(line) != 8:130+ raise PolicyError(f"unexpected YAML structure in step {step.name!r}: {line}")131+ match = re.fullmatch(r" ([A-Za-z0-9_-]+):(?:\s*(.*))?", line)132+ if not match:133+ raise PolicyError(f"cannot parse field in step {step.name!r}: {line}")134+ key = match.group(1)135+ value = (match.group(2) or "").strip()136+ if key in step.field_names:137+ raise PolicyError(f"duplicate field {key!r} in step {step.name!r}")138+139+ if value == "|":140+ index += 1141+ body: list[str] = []142+ while index < len(block):143+ child = block[index]144+ if _is_content(child) and _indent(child) <= 8:145+ break146+ if child.strip():147+ if _indent(child) < 10:148+ raise PolicyError(149+ f"invalid block indentation in step {step.name!r}"150+ )151+ body.append(child[10:])152+ else:153+ body.append("")154+ index += 1155+ step.blocks[key] = "\n".join(body).rstrip()156+ continue157+158+ if not value:159+ index += 1160+ entries: dict[str, str] = {}161+ while index < len(block):162+ child = block[index]163+ if not _is_content(child):164+ index += 1165+ continue166+ if _indent(child) <= 8:167+ break168+ child_match = re.fullmatch(169+ r" ([A-Za-z0-9_-]+):\s*(.+?)\s*", child170+ )171+ if not child_match:172+ raise PolicyError(173+ f"cannot parse {key!r} mapping in step {step.name!r}: {child}"174+ )175+ child_key = child_match.group(1)176+ if child_key in entries:177+ raise PolicyError(178+ f"duplicate {key}.{child_key} in step {step.name!r}"179+ )180+ entries[child_key] = _scalar(child_match.group(2))181+ index += 1182+ step.mappings[key] = entries183+ continue184+185+ step.scalars[key] = _scalar(value)186+ index += 1187+188+ return step189+190+191+def _parse_steps(job: list[str]) -> list[Step]:192+ if any(193+ _mapping_key(line, 4) == "if"194+ for line in job195+ ):196+ raise PolicyError("jobs.demo-gate must not be conditionally disabled")197+198+ steps_indices = [199+ index200+ for index, line in enumerate(job)201+ if line == " steps:"202+ ]203+ if len(steps_indices) != 1:204+ raise PolicyError("jobs.demo-gate must contain exactly one steps list")205+206+ start = steps_indices[0] + 1207+ starts = [208+ index209+ for index in range(start, len(job))210+ if re.fullmatch(r" - name:\s*.+", job[index])211+ ]212+ if not starts:213+ raise PolicyError("jobs.demo-gate contains no named steps")214+215+ steps: list[Step] = []216+ for position, step_start in enumerate(starts):217+ step_end = starts[position + 1] if position + 1 < len(starts) else len(job)218+ steps.append(_parse_step(job[step_start:step_end]))219+ return steps220+221+222+def _named_step(steps: list[Step], name: str) -> tuple[int, Step]:223+ matches = [224+ (index, step)225+ for index, step in enumerate(steps)226+ if step.name == name227+ ]228+ if len(matches) != 1:229+ raise PolicyError(f"demo-gate step {name!r} must exist exactly once")230+ return matches[0]231+232+233+def verify(workflow: str) -> None:234+ steps = _parse_steps(_job_lines(workflow, "demo-gate"))235+236+ dependency_index, dependency = _named_step(237+ steps, "Install Linux build dependencies"238+ )239+ cache_index, cache = _named_step(steps, "Restore pinned cargo-zigbuild")240+ install_index, install = _named_step(steps, "Install pinned cross-build tools")241+ gate_index, gate = _named_step(steps, "Run complete demo gate")242+243+ dependency_run = dependency.blocks.get("run", "")244+ if " python3-venv \\" not in dependency_run.splitlines():245+ raise PolicyError(246+ "Linux dependency step must install python3-venv for the pinned Zig venv"247+ )248+ if "if" in dependency.field_names:249+ raise PolicyError("Linux dependency step must not be conditionally disabled")250+251+ if cache.field_names != {"name", "uses", "with"}:252+ raise PolicyError("cargo-zigbuild cache step has unexpected or missing fields")253+ if cache.scalars.get("uses") != CACHE_ACTION:254+ raise PolicyError("cargo-zigbuild cache action is not digest-pinned")255+ if cache.mappings.get("with") != {"path": CACHE_PATH, "key": CACHE_KEY}:256+ raise PolicyError("cargo-zigbuild cache contract changed")257+258+ if install.field_names != {"name", "run"}:259+ raise PolicyError("cross-build install step has unexpected or missing fields")260+ if install.blocks.get("run") != INSTALL_RUN:261+ raise PolicyError("cross-build install step body changed")262+263+ if gate.field_names != {"name", "env", "run"}:264+ raise PolicyError("complete demo gate step has unexpected or missing fields")265+ if gate.mappings.get("env") != {266+ "GOQ_VERIFY_IN_PROCESS_GSTREAMER": "1",267+ "GOQ_REQUIRE_LINUX_CROSS_BUILD": "1",268+ }:269+ raise PolicyError("complete demo gate does not require the Linux cross build")270+ if gate.scalars.get("run") != "./scripts/verify-demo-build.sh":271+ raise PolicyError("complete demo gate does not execute the repository gate")272+273+ if not dependency_index < cache_index < install_index < gate_index:274+ raise PolicyError(275+ "dependency, cache, cross-tool install, and demo-gate steps are misordered"276+ )277+278+279+def main() -> int:280+ parser = argparse.ArgumentParser()281+ parser.add_argument("workflow", type=Path)282+ args = parser.parse_args()283+ try:284+ verify(args.workflow.read_text(encoding="utf-8"))285+ except (OSError, PolicyError) as error:286+ print(f"CI cross-build policy failed: {error}", file=sys.stderr)287+ return 1288+ print("ci_cross_build_policy=ok")289+ return 0290+291+292+if __name__ == "__main__":293+ raise SystemExit(main())