diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ed2fa7e0a7..cfdf870bd5 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -119,6 +119,19 @@ jobs: echo "triage_helper=false" >> "$GITHUB_OUTPUT" exit 0 fi + # The isolated issue parser is not application source. Its complete + # proposed-source tests run in triage-helper instead of queuing the + # unrelated frontend, backend and installer suites. The selector + # consumes a complete NUL-delimited diff with an exact allowlist; + # mixed changes and changes to this admission still get runtime CI. + if [ "$(python3 scripts/ci_triage_only.py --base "$base" --head "${{ github.sha }}")" = true ]; then + echo "Issue-triage-only tree diff; dedicated triage checks apply, application checks are not applicable." + echo "code=false" >> "$GITHUB_OUTPUT" + echo "frontend_deps=false" >> "$GITHUB_OUTPUT" + echo "go_compile=false" >> "$GITHUB_OUTPUT" + echo "triage_helper=true" >> "$GITHUB_OUTPUT" + exit 0 + fi changed=$(git diff --name-only "$base" "${{ github.sha }}") printf 'Changed files:\n%s\n' "$changed" # The issue-event job tests before minting its App token, but that @@ -126,7 +139,7 @@ jobs: # policy on the proposed source too, including documentation-only # guidance and every workflow consumed by the policy tests. triage_helper=false - if printf '%s\n' "$changed" | grep -E '^\.github/scripts/(issue-version-triage(\.test)?\.cjs|triage-comment-policy\.test\.cjs|package(-lock)?\.json)$|^\.github/ISSUE_TEMPLATE/|^\.github/workflows/|^docs/ISSUE_TRIAGE\.md$|^scripts/tests/test_ci_browser_proof_only\.py$' >/dev/null; then + if printf '%s\n' "$changed" | grep -E '^\.github/scripts/(issue-version-triage(\.test)?\.cjs|triage-comment-policy\.test\.cjs|package(-lock)?\.json)$|^\.github/ISSUE_TEMPLATE/|^\.github/workflows/|^docs/ISSUE_TRIAGE\.md$|^scripts/(ci_triage_only\.py|tests/test_ci_browser_proof_only\.py)$' >/dev/null; then triage_helper=true fi echo "triage_helper=$triage_helper" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci_triage_only.py b/scripts/ci_triage_only.py new file mode 100644 index 0000000000..09e81f2675 --- /dev/null +++ b/scripts/ci_triage_only.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Select the dedicated issue-triage check for an isolated triage tree change. + +These inputs run only in the issue-event workflow, not the Pulse application. +Their locked parser and silent-mutation tests still run on the proposed source, +as do the independent secret scan and governance. Mixed, empty or unavailable +diffs retain the ordinary application checks. Changes to this selector or its +workflow/tests cannot opt themselves out of those checks. +""" + +import argparse +import re +import subprocess +import sys + + +COMMIT = re.compile(r"[0-9a-f]{40}") +TRIAGE_INPUTS = frozenset({ + b".github/scripts/issue-version-triage.cjs", + b".github/scripts/issue-version-triage.test.cjs", + b".github/scripts/triage-comment-policy.test.cjs", + b".github/scripts/package.json", + b".github/scripts/package-lock.json", + b"docs/ISSUE_TRIAGE.md", +}) + + +def triage_only_paths(paths): + return bool(paths) and all(path in TRIAGE_INPUTS for path in paths) + + +def triage_only_diff(base, head): + if not COMMIT.fullmatch(base) or not COMMIT.fullmatch(head): + return False + try: + # Inspect the entire range, including deleted names. Neither a rename + # into this allowlist nor Git's quoted display of a filename may hide + # a product change. A missing/truncated diff always keeps runtime CI. + result = subprocess.run( + ["git", "diff", "--no-ext-diff", "--no-renames", "--name-only", "-z", + base, head, "--"], + capture_output=True, timeout=30, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + print("Triage tree diff unavailable; retaining application CI.", file=sys.stderr) + return False + if result.returncode != 0 or not result.stdout.endswith(b"\0"): + return False + return triage_only_paths(result.stdout[:-1].split(b"\0")) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + args = parser.parse_args() + print("true" if triage_only_diff(args.base, args.head) else "false") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_ci_browser_proof_only.py b/scripts/tests/test_ci_browser_proof_only.py index 482499b09b..40b15d9bf1 100644 --- a/scripts/tests/test_ci_browser_proof_only.py +++ b/scripts/tests/test_ci_browser_proof_only.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Execute receipt-only diff admission and the workflows' actual verdict paths.""" +"""Execute receipt/triage-only admission and the workflows' actual verdict paths.""" import importlib.util import os @@ -19,6 +19,9 @@ E2E = ROOT / ".github/workflows/test-e2e.yml" SPEC = importlib.util.spec_from_file_location("ci_browser_proof_only", SCRIPT) SCOPE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(SCOPE) +TRIAGE_SPEC = importlib.util.spec_from_file_location("ci_triage_only", ROOT / "scripts/ci_triage_only.py") +TRIAGE = importlib.util.module_from_spec(TRIAGE_SPEC) +TRIAGE_SPEC.loader.exec_module(TRIAGE) RECEIPT = "frontend-modern/browser-verification/2026-10-14-proof.json" JOBS = ("tier-selection", "test-images", "e2e", "agent-registration") @@ -84,13 +87,61 @@ class ReceiptScopeTest(unittest.TestCase): ]) +class TriageScopeTest(unittest.TestCase): + def test_only_complete_exact_triage_inputs_are_eligible(self): + self.assertTrue(TRIAGE.triage_only_paths(list(TRIAGE.TRIAGE_INPUTS))) + self.assertFalse(TRIAGE.triage_only_paths([])) + for path in TRIAGE.TRIAGE_INPUTS: + with self.subTest(path=path): + self.assertTrue(TRIAGE.triage_only_paths([path])) + for suffix in (b".extra", b"/extra", b"\ninternal/api/server.go"): + self.assertFalse(TRIAGE.triage_only_paths([path + suffix])) + + def test_product_tests_controls_templates_workflows_and_unknown_files_keep_runtime(self): + for path in ( + b"frontend-modern/src/App.tsx", b"frontend-modern/package-lock.json", + b"internal/api/server.go", b"internal/api/server_test.go", b"go.mod", b"go.sum", + b"install.sh", b"scripts/tests/run.sh", b"tests/integration/playwright.config.ts", + b".github/workflows/build-and-test.yml", b".github/workflows/issue-version-label-sync.yml", + b".github/ISSUE_TEMPLATE/bug_report.yml", b".github/scripts/other.cjs", b"docs/other.md", + b"scripts/ci_triage_only.py", b"scripts/tests/test_ci_browser_proof_only.py", + RECEIPT.encode(), b"other/.github/scripts/package.json", + ): + with self.subTest(path=path): + self.assertFalse(TRIAGE.triage_only_paths([*TRIAGE.TRIAGE_INPUTS, path])) + + def test_unknown_failed_incomplete_or_missing_diff_keeps_runtime(self): + for base, head in (("", "a" * 40), ("HEAD^", "a" * 40), ("a" * 40, "main")): + with patch.object(TRIAGE.subprocess, "run") as run: + self.assertFalse(TRIAGE.triage_only_diff(base, head)) + run.assert_not_called() + path = b".github/scripts/issue-version-triage.cjs" + for output, code in ((b"", 0), (path, 0), (path + b"\0", 1)): + with patch.object(TRIAGE.subprocess, "run", return_value=subprocess.CompletedProcess([], code, output)): + self.assertFalse(TRIAGE.triage_only_diff("a" * 40, "b" * 40)) + for error in (OSError("fixture unavailable"), subprocess.TimeoutExpired("git", 30)): + with patch.object(TRIAGE.subprocess, "run", side_effect=error): + self.assertFalse(TRIAGE.triage_only_diff("a" * 40, "b" * 40)) + + def test_complete_null_delimited_diff_disables_rename_and_external_diff(self): + path = b".github/scripts/issue-version-triage.cjs\0" + for output, expected in ((path, True), (path * 4000 + b"internal/api/server.go\0", False)): + with patch.object(TRIAGE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, output)) as run: + self.assertEqual(TRIAGE.triage_only_diff("a" * 40, "b" * 40), expected) + self.assertEqual(run.call_args.args[0], [ + "git", "diff", "--no-ext-diff", "--no-renames", "--name-only", "-z", + "a" * 40, "b" * 40, "--", + ]) + + class GitTreeScopeTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.repo = Path(self.temp.name) self.git("init", "-q", "--initial-branch=main") - for name in ("scripts/ci_browser_proof_only.py", "scripts/go-test-compile-required.sh"): + for name in ("scripts/ci_browser_proof_only.py", "scripts/ci_triage_only.py", + "scripts/go-test-compile-required.sh"): path = self.repo / name path.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(ROOT / name, path) @@ -160,13 +211,15 @@ class GitTreeScopeTest(unittest.TestCase): "github.event_name": "pull_request", "github.event.pull_request.base.sha": base, "github.event.before": base, "github.sha": head, }) - output = self.repo / "outputs" - if output.exists(): - output.unlink() - result = subprocess.run(["bash", "-c", script], cwd=self.repo, capture_output=True, - text=True, timeout=10, env={**os.environ, "GITHUB_OUTPUT": str(output)}) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - return dict(line.split("=", 1) for line in output.read_text().splitlines()) + # Workflow output is runner evidence, not a source change. Keep it + # outside the fixture repository so the next commit cannot stage it + # and accidentally turn an isolated change into a mixed tree diff. + with tempfile.TemporaryDirectory() as output_dir: + output = Path(output_dir) / "outputs" + result = subprocess.run(["bash", "-c", script], cwd=self.repo, capture_output=True, + text=True, timeout=10, env={**os.environ, "GITHUB_OUTPUT": str(output)}) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return dict(line.split("=", 1) for line in output.read_text().splitlines()) def test_actual_build_classifier_omits_only_receipt_runtime_checks(self): self.write(RECEIPT, "{}\n") @@ -201,7 +254,7 @@ class GitTreeScopeTest(unittest.TestCase): ".github/ISSUE_TEMPLATE/bug_report.yml", ".github/ISSUE_TEMPLATE/config.yml", ".github/workflows/issue-version-label-sync.yml", ".github/workflows/build-and-test.yml", ".github/workflows/policy-consumer.yaml", - "docs/ISSUE_TRIAGE.md", "scripts/tests/test_ci_browser_proof_only.py", + "docs/ISSUE_TRIAGE.md", "scripts/ci_triage_only.py", "scripts/tests/test_ci_browser_proof_only.py", ): with self.subTest(path=path): before = self.git("rev-parse", "HEAD") @@ -209,7 +262,7 @@ class GitTreeScopeTest(unittest.TestCase): head = self.commit() self.assertEqual(self.classify(before, head)["triage_helper"], "true") - def test_triage_admission_uses_complete_range_and_does_not_expand_runtime_skip(self): + def test_triage_admission_uses_complete_range_and_keeps_mixed_runtime_checks(self): self.write("docs/ISSUE_TRIAGE.md", "Changed operator guidance\n") guidance = self.commit() self.assertEqual(self.classify(self.base, guidance), { @@ -223,7 +276,7 @@ class GitTreeScopeTest(unittest.TestCase): self.write(".github/scripts/issue-version-triage.cjs", "// changed parser\n") parser = self.commit() self.assertEqual(self.classify(proof, parser), { - "code": "true", "frontend_deps": "false", "go_compile": "false", + "code": "false", "frontend_deps": "false", "go_compile": "false", "triage_helper": "true", }) self.write("frontend-modern/package-lock.json", "{}\n") @@ -233,6 +286,49 @@ class GitTreeScopeTest(unittest.TestCase): self.assertEqual(combined["code"], "true") self.assertEqual(combined["triage_helper"], "true") + def test_isolated_triage_inputs_use_the_dedicated_check(self): + for path in sorted(TRIAGE.TRIAGE_INPUTS): + with self.subTest(path=path): + before = self.git("rev-parse", "HEAD") + self.write(path.decode(), "triage fixture\n") + head = self.commit() + self.assertEqual(self.classify(before, head), { + "code": "false", "frontend_deps": "false", "go_compile": "false", + "triage_helper": "true", + }) + + def test_triage_skip_cannot_hide_source_renames_or_quoted_paths(self): + self.write("internal/api/fixture.go", "package fixture\nvar Changed = true\n") + product = self.commit() + self.write(".github/scripts/issue-version-triage.cjs", "// parser fixture\n") + parser = self.commit() + self.assertEqual(self.classify(product, parser)["code"], "false") + self.assertEqual(self.classify(self.base, parser)["code"], "true") + (self.repo / "internal/api/fixture.go").rename(self.repo / ".github/scripts/issue-version-triage.test.cjs") + renamed = self.commit() + self.assertEqual(self.classify(parser, renamed)["code"], "true") + self.write(".github/scripts/issue-version-triage.cjs\ninternal/api/fixture.go", "// quoted filename\n") + quoted = self.commit() + self.assertEqual(self.classify(renamed, quoted)["code"], "true") + + def test_selector_failure_cannot_be_mistaken_for_a_narrow_change(self): + self.write(".github/scripts/issue-version-triage.cjs", "// parser fixture\n") + self.write("scripts/ci_triage_only.py", "raise SystemExit(42)\n") + head = self.commit() + self.assertEqual(self.classify(self.base, head)["code"], "true") + + def test_controls_and_workflow_edits_cannot_skip_their_own_runtime_tests(self): + for path in ("scripts/ci_triage_only.py", "scripts/tests/test_ci_browser_proof_only.py", + ".github/workflows/build-and-test.yml"): + with self.subTest(path=path): + before = self.git("rev-parse", "HEAD") + (self.repo / path).parent.mkdir(parents=True, exist_ok=True) + with (self.repo / path).open("a") as control: + control.write("\n# Changed CI control\n") + head = self.commit() + self.assertEqual(self.classify(before, head)["code"], "true") + self.assertEqual(self.classify(before, head)["triage_helper"], "true") + def test_triage_path_lookalikes_and_unrelated_code_do_not_select_it(self): for path in ( ".github/scripts/issue-version-triage.cjs.extra",