Run the helper in a canonical fail-fast prefix, require an unfiltered PR trigger, and pin the demo job's runner, timeout, name, and environment with adversarial coverage.
- commit
- e64a7bb336306d6480d3f6010b80c4dad7f12478
- parent
- fb928dc01f098e2f7b0212ba92c89dab67b17ba2
- author
- Dami <dami.etoile@gmail.com>
| M | scripts/ | 101 | ++++++++++++++++++- |
| M | scripts/ | 3 | +- |
| M | scripts/ | 167 | +++++++++++++++--------------- |
@@ -41,10 +41,49 @@ class CiCrossBuildPolicyTests(unittest.TestCase):
4141 "if false; then\n./scripts/run-linux-cross-build-gate.sh\nfi",4242 )4343 with self.assertRaisesRegex(44- PolicyError, "must not be nested in conditional or compound"44+ PolicyError, "executable prefix changed"4545 ):4646 verify_gate_script(mutated)4747 48+ def test_rejects_successful_early_exit_before_cross_build_helper(self) -> None:49+ mutated = replace_once(50+ GATE_SCRIPT,51+ "./scripts/run-linux-cross-build-gate.sh",52+ "if true; then\n exit 0\nfi\n"53+ "./scripts/run-linux-cross-build-gate.sh",54+ )55+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):56+ verify_gate_script(mutated)57+58+ def test_rejects_failure_control_changes_before_cross_build_helper(self) -> None:59+ prefixes = [60+ "exit 0",61+ "return 0",62+ "trap 'exit 0' EXIT",63+ "exec true",64+ "set +e",65+ ]66+ for prefix in prefixes:67+ with self.subTest(prefix=prefix):68+ mutated = replace_once(69+ GATE_SCRIPT,70+ "./scripts/run-linux-cross-build-gate.sh",71+ f"{prefix}\n./scripts/run-linux-cross-build-gate.sh",72+ )73+ with self.assertRaisesRegex(74+ PolicyError, "executable prefix changed"75+ ):76+ verify_gate_script(mutated)77+78+ def test_rejects_failure_masked_cross_build_helper(self) -> None:79+ mutated = replace_once(80+ GATE_SCRIPT,81+ "./scripts/run-linux-cross-build-gate.sh",82+ "./scripts/run-linux-cross-build-gate.sh || true",83+ )84+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):85+ verify_gate_script(mutated)86+4887 def test_rejects_demo_gate_run_bypass_even_with_decoy_comment(self) -> None:4988 mutated = replace_once(5089 WORKFLOW,@@ -99,10 +138,68 @@ class CiCrossBuildPolicyTests(unittest.TestCase):
99138 def test_rejects_missing_pull_request_trigger(self) -> None:100139 mutated = replace_once(WORKFLOW, " pull_request:\n", "")101140 with self.assertRaisesRegex(102- PolicyError, "unconditional pull_request trigger"141+ PolicyError, "empty pull_request trigger"103142 ):104143 verify(mutated)105144 145+ def test_rejects_filtered_pull_request_trigger(self) -> None:146+ filters = [147+ " branches: [main]",148+ " paths: ['crates/**']",149+ " types: [opened]",150+ ]151+ for filter_line in filters:152+ with self.subTest(filter_line=filter_line):153+ mutated = replace_once(154+ WORKFLOW,155+ " pull_request:\n",156+ f" pull_request:\n{filter_line}\n",157+ )158+ with self.assertRaisesRegex(159+ PolicyError, "exactly empty and unfiltered"160+ ):161+ verify(mutated)162+163+ def test_rejects_inline_pull_request_mapping(self) -> None:164+ mutated = replace_once(WORKFLOW, " pull_request:", " pull_request: {}")165+ with self.assertRaisesRegex(PolicyError, "must be exactly empty"):166+ verify(mutated)167+168+ def test_rejects_changed_demo_job_identity_and_execution_limits(self) -> None:169+ changes = [170+ (" name: Complete demo gate", " name: Optional demo gate", "required name"),171+ (172+ " demo-gate:\n name: Complete demo gate\n"173+ " runs-on: ubuntu-24.04",174+ " demo-gate:\n name: Complete demo gate\n"175+ " runs-on: ubuntu-latest",176+ "ubuntu-24.04",177+ ),178+ (" timeout-minutes: 45", " timeout-minutes: 5", "45 minutes"),179+ ]180+ for old, new, error in changes:181+ with self.subTest(field=old):182+ mutated = replace_once(WORKFLOW, old, new)183+ with self.assertRaisesRegex(PolicyError, error):184+ verify(mutated)185+186+ def test_rejects_changed_demo_job_environment(self) -> None:187+ changes = [188+ (' CARGO_TERM_COLOR: always', ' CARGO_TERM_COLOR: never'),189+ (' RUST_BACKTRACE: "1"\n', ""),190+ (191+ ' RUST_BACKTRACE: "1"',192+ ' RUST_BACKTRACE: "1"\n CI_FAILURES_OPTIONAL: "1"',193+ ),194+ ]195+ for old, new in changes:196+ with self.subTest(replacement=new):197+ mutated = replace_once(WORKFLOW, old, new)198+ with self.assertRaisesRegex(199+ PolicyError, "env changed from the CI policy contract"200+ ):201+ verify(mutated)202+106203 def test_rejects_failure_suppressing_workflow_shell_default(self) -> None:107204 mutated = replace_once(108205 WORKFLOW,@@ -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@@ -90,7 +92,6 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
9092 "$gstreamer_test" -- --ignored --nocapture9193 echo 'in_process_gstreamer_gate=ok'9294 fi93-./scripts/run-linux-cross-build-gate.sh9495 while IFS= read -r frontend_source; do9596 node --check "$frontend_source"9697 done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)@@ -15,6 +15,21 @@ CACHE_PATH = "~/.cargo/bin/cargo-zigbuild"
1515 CACHE_KEY = "cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0"1616 LINUX_CROSS_BUILD_GATE = "./scripts/run-linux-cross-build-gate.sh"1717 MAPPING_KEY = r"""(?:[A-Za-z0-9_-]+|"[A-Za-z0-9_-]+"|'[A-Za-z0-9_-]+')"""18+GATE_SCRIPT_PREFIX = """\19+#!/usr/bin/env bash20+set -euo pipefail21+22+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"23+repo_dir="$(cd -- "$script_dir/.." && pwd -P)"24+cd "$repo_dir"25+26+if [[ -f "$HOME/.cargo/env" ]]; then27+ # shellcheck source=/dev/null28+ source "$HOME/.cargo/env"29+fi30+31+./scripts/run-linux-cross-build-gate.sh32+"""1833 1934 INSTALL_RUN = """\2035 python3 -m venv "$RUNNER_TEMP/zig-venv"@@ -131,14 +146,31 @@ def _validate_workflow_layout(workflow: str) -> None:
131146 if _scalar(on_value):132147 raise PolicyError("workflow on field must be a trigger mapping")133148 on_end = _block_end(lines, on_start, 0)134- pull_request_entries = [135- _mapping_entry(line, 2)136- for line in lines[on_start + 1 : on_end]137- if _mapping_key(line, 2) == "pull_request"149+ pull_request_indices = [150+ index151+ for index in range(on_start + 1, on_end)152+ if _mapping_key(lines[index], 2) == "pull_request"138153 ]139- if len(pull_request_entries) != 1 or _scalar(pull_request_entries[0][1]):154+ if len(pull_request_indices) != 1:140155 raise PolicyError(141- "ordinary CI must contain exactly one unconditional pull_request trigger"156+ "ordinary CI must contain exactly one empty pull_request trigger"157+ )158+ pull_request_index = pull_request_indices[0]159+ pull_request_entry = _mapping_entry(lines[pull_request_index], 2)160+ if pull_request_entry is None or _scalar(pull_request_entry[1]):161+ raise PolicyError("ordinary CI pull_request trigger must be exactly empty")162+163+ pull_request_end = on_end164+ for index in range(pull_request_index + 1, on_end):165+ if _is_content(lines[index]) and _indent(lines[index]) <= 2:166+ pull_request_end = index167+ break168+ if any(169+ _is_content(line)170+ for line in lines[pull_request_index + 1 : pull_request_end]171+ ):172+ raise PolicyError(173+ "ordinary CI pull_request trigger must be exactly empty and unfiltered"142174 )143175 144176 @@ -279,6 +311,45 @@ def _parse_steps(job: list[str]) -> list[Step]:
279311 )280312 if job_fields != {"name", "runs-on", "timeout-minutes", "env", "steps"}:281313 raise PolicyError("jobs.demo-gate fields changed from the CI policy contract")314+ job_values = {key: value for key, value in job_entries}315+ if _scalar(job_values["name"]) != "Complete demo gate":316+ raise PolicyError("jobs.demo-gate must retain its required name")317+ if _scalar(job_values["runs-on"]) != "ubuntu-24.04":318+ raise PolicyError("jobs.demo-gate must run on ubuntu-24.04")319+ if _scalar(job_values["timeout-minutes"]) != "45":320+ raise PolicyError("jobs.demo-gate timeout must remain 45 minutes")321+ if _scalar(job_values["env"]):322+ raise PolicyError("jobs.demo-gate env field must be an exact mapping")323+ if _scalar(job_values["steps"]):324+ raise PolicyError("jobs.demo-gate steps field must be a list")325+326+ env_indices = [327+ index328+ for index, line in enumerate(job)329+ if _mapping_key(line, 4) == "env"330+ ]331+ if len(env_indices) != 1:332+ raise PolicyError("jobs.demo-gate must contain exactly one env mapping")333+ env_start = env_indices[0]334+ env_end = len(job)335+ for index in range(env_start + 1, len(job)):336+ if _is_content(job[index]) and _indent(job[index]) <= 4:337+ env_end = index338+ break339+ env_entries: list[tuple[str, str]] = []340+ for line in job[env_start + 1 : env_end]:341+ if not _is_content(line):342+ continue343+ entry = _mapping_entry(line, 6)344+ if entry is None:345+ raise PolicyError(f"cannot parse jobs.demo-gate env field: {line}")346+ env_entries.append(entry)347+ env_names = [key for key, _ in env_entries]348+ if len(env_names) != len(set(env_names)):349+ raise PolicyError("jobs.demo-gate env contains a duplicate field")350+ env = {key: _scalar(value) for key, value in env_entries}351+ if env != {"CARGO_TERM_COLOR": "always", "RUST_BACKTRACE": "1"}:352+ raise PolicyError("jobs.demo-gate env changed from the CI policy contract")282353 283354 steps_indices = [284355 index@@ -315,91 +386,15 @@ def _named_step(steps: list[Step], name: str) -> tuple[int, Step]:
315386 return matches[0]316387 317388 318-def _shell_compound_stack(lines: list[str], stop: int) -> list[str]:319- stack: list[str] = []320- closers = {321- "fi": "if",322- "done": "loop",323- "esac": "case",324- "}": "brace",325- ")": "subshell",326- }327-328- for number, line in enumerate(lines[:stop], start=1):329- stripped = line.strip()330- if not stripped or stripped.startswith("#"):331- continue332-333- if stripped in closers:334- expected = closers[stripped]335- if not stack or stack[-1] != expected:336- raise PolicyError(337- f"cannot prove shell control flow before cross-build gate at line {number}"338- )339- stack.pop()340- continue341-342- if re.match(343- r"^(?:function\s+)?[A-Za-z_][A-Za-z0-9_]*(?:\(\))?\s*\{\s*$",344- stripped,345- ):346- stack.append("brace")347- elif re.search(r"(?:^|\|\||&&)\s*\{\s*$", stripped):348- stack.append("brace")349- elif re.search(r"(?:^|\|\||&&)\s*\(\s*$", stripped):350- stack.append("subshell")351- elif re.match(r"^if\b", stripped):352- stack.append("if")353- elif re.match(r"^(?:for|while|until|select)\b", stripped):354- stack.append("loop")355- elif re.match(r"^case\b", stripped):356- stack.append("case")357-358- return stack359-360-361389 def verify_gate_script(gate_script: str) -> None:362- lines = gate_script.splitlines()363- calls = [364- index365- for index, line in enumerate(lines)366- if line.strip() == LINUX_CROSS_BUILD_GATE367- ]368- if len(calls) != 1:390+ if not gate_script.startswith(GATE_SCRIPT_PREFIX):369391 raise PolicyError(370- "complete repository gate must invoke the Linux cross-build gate exactly once"392+ "complete repository gate executable prefix changed before the required Linux cross build"371393 )372394 373- call = calls[0]374- if lines[call] != LINUX_CROSS_BUILD_GATE:375- raise PolicyError(376- "Linux cross-build gate must be an unindented top-level simple command"377- )378- if _shell_compound_stack(lines, call):395+ if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:379396 raise PolicyError(380- "Linux cross-build gate must not be nested in conditional or compound shell control flow"381- )382-383- content_before = [384- line.strip()385- for line in lines[:call]386- if _is_content(line)387- ]388- content_after = [389- line.strip()390- for line in lines[call + 1 :]391- if _is_content(line)392- ]393- if not content_before or content_before[-1] != "fi":394- raise PolicyError(395- "Linux cross-build gate placement changed before the required frontend gates"396- )397- if (398- not content_after399- or content_after[0] != "while IFS= read -r frontend_source; do"400- ):401- raise PolicyError(402- "Linux cross-build gate placement changed before the required frontend gates"397+ "complete repository gate must invoke the Linux cross-build gate exactly once"403398 )404399 405400