From 3ef6ff147b25fce23343400600bb8cadad0a4672 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 14 Oct 2026 12:53:00 +0900 Subject: [PATCH] fix(ci): backport pinned gh first-use bootstrap to package stack Carry main's merged #2645 four-file delta onto #2573 without modifying its branch. Preserve digest/version-verified job-local gh admission before CodeQL and Strix first use. CI-mode suite: 5380 passed, 4 skipped, 40 subtests; focused: 83 passed. Staged delta independently reviewed. --- .github/workflows/codeql-pr.yml | 296 ++++++++++++++++++ .github/workflows/strix.yml | 142 +++++++++ tests/test_central_gh_first_use_contract.py | 319 ++++++++++++++++++++ tests/test_docs_only_pr_runner_admission.py | 14 +- 4 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 tests/test_central_gh_first_use_contract.py diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 1be7ae0449..d8957ad247 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -181,6 +181,121 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} steps: + - name: Set up Python for pinned GitHub CLI provisioning + if: needs.detect-languages.outputs.code == 'true' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Provision pinned job-local GitHub CLI + if: needs.detect-languages.outputs.code == 'true' + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import os + from pathlib import Path + import platform + import shutil + import subprocess + import sys + import tarfile + import tempfile + import urllib.request + + version = "2.102.0" + archive_sha256 = "bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386" + binary_sha256 = "7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b" + url = "https://github.com/cli/cli/releases/download/v2.102.0/gh_2.102.0_linux_amd64.tar.gz" + if sys.platform != "linux" or platform.machine() not in ("x86_64", "amd64"): + raise RuntimeError("pinned GitHub CLI requires Linux x64") + parent = Path(os.environ["RUNNER_TEMP"]) + if not parent.is_absolute(): + raise RuntimeError("RUNNER_TEMP must be absolute") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("runner temporary ancestry must be real directories") + root = Path(tempfile.mkdtemp(prefix="cwl-gh-", dir=parent)) + os.chmod(root, 0o700) + try: + archive = root / "gh.tar.gz" + with urllib.request.urlopen(url, timeout=30) as response, archive.open("xb") as output: + total = 0 + digest = hashlib.sha256() + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > 20 * 1024 * 1024: + raise RuntimeError("GitHub CLI archive exceeds bound") + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != archive_sha256: + raise RuntimeError("GitHub CLI archive checksum mismatch") + expected_member = f"gh_{version}_linux_amd64/bin/gh" + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + selected = [member for member in members if member.name == expected_member] + if len(selected) != 1 or not selected[0].isfile(): + raise RuntimeError("GitHub CLI archive must contain one regular binary") + member = selected[0] + if not 0 < member.size <= 64 * 1024 * 1024: + raise RuntimeError("GitHub CLI binary size exceeds bound") + stream = source.extractfile(member) + if stream is None: + raise RuntimeError("GitHub CLI binary could not be read") + with stream: + content = stream.read(member.size + 1) + if len(content) != member.size or hashlib.sha256(content).hexdigest() != binary_sha256: + raise RuntimeError("GitHub CLI binary checksum mismatch") + # Copy this single pinned regular member; never extract archive paths or links. + bindir = root / "bin" + bindir.mkdir(mode=0o700) + executable = bindir / "gh" + with executable.open("xb") as output: + output.write(content) + executable.chmod(0o700) + result = subprocess.run([str(executable), "--version"], check=True, + capture_output=True, text=True, timeout=10) + if not result.stdout.splitlines() or not result.stdout.splitlines()[0].startswith(f"gh version {version} "): + raise RuntimeError("GitHub CLI version mismatch") + archive.unlink() + with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output: + output.write(str(bindir) + "\n") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: + output.write("CWL_GH_BIN=" + str(executable) + "\n") + info = root.stat() + output.write("CWL_GH_ROOT=" + str(root) + "\n") + output.write("CWL_GH_ROOT_DEVICE=" + str(info.st_dev) + "\n") + output.write("CWL_GH_ROOT_INODE=" + str(info.st_ino) + "\n") + print("Installed checksum-verified job-local GitHub CLI " + version) + except BaseException: + shutil.rmtree(root) + raise + PY + + - name: Verify pinned job-local GitHub CLI selection + if: needs.detect-languages.outputs.code == 'true' + shell: bash + run: | + set -euo pipefail + if [ ! -f "$CWL_GH_BIN" ] || [ -L "$CWL_GH_BIN" ] || [ ! -x "$CWL_GH_BIN" ]; then + echo "::error::Job-local GitHub CLI executable is missing or linked." + exit 1 + fi + if [ "$(command -v gh)" != "$CWL_GH_BIN" ]; then + echo "::error::GitHub CLI PATH selection differs from the pinned job-local binary." + exit 1 + fi + printf '%s %s\n' '7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b' "$CWL_GH_BIN" | sha256sum --check --status + version_output="$(gh --version)" + case "$version_output" in + 'gh version 2.102.0 '*) printf '%s\n' "GitHub CLI 2.102.0 path and checksum verified." ;; + *) echo "::error::GitHub CLI selected an unsupported version."; exit 1 ;; + esac + - name: Read current-head CodeQL dispatch verdict # Shards never dispatch. They re-check the live head, consume an # authenticated base/run/source-bound CodeQL verdict when one exists, @@ -362,6 +477,41 @@ jobs: ;; esac + - name: Clean up this job's pinned GitHub CLI + if: always() && env.CWL_GH_ROOT != '' + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import os + from pathlib import Path + import re + import shutil + import stat + + root = Path(os.environ["CWL_GH_ROOT"]) + parent = Path(os.environ["RUNNER_TEMP"]) + if (not root.is_absolute() or root.parent != parent + or not re.fullmatch(r"cwl-gh-[A-Za-z0-9_-]+", root.name) + or any(ord(c) < 32 for c in str(root))): + raise RuntimeError("refusing unrelated GitHub CLI cleanup path") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("refusing changed GitHub CLI temporary ancestry") + try: + info = root.lstat() + except FileNotFoundError: + print("Owned GitHub CLI directory is already absent.") + else: + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() + or info.st_dev != int(os.environ["CWL_GH_ROOT_DEVICE"]) + or info.st_ino != int(os.environ["CWL_GH_ROOT_INODE"])): + raise RuntimeError("refusing changed GitHub CLI directory identity") + # No glob or shared-temp sweep: delete only this job's original directory. + shutil.rmtree(root) + print("Removed this job's checksum-verified GitHub CLI directory.") + PY + dispatch-current-head: name: Dispatch current-head CodeQL scan needs: [detect-languages, analyze-head] @@ -382,6 +532,118 @@ jobs: pull-requests: read statuses: read steps: + - name: Set up Python for pinned GitHub CLI provisioning + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Provision pinned job-local GitHub CLI + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import os + from pathlib import Path + import platform + import shutil + import subprocess + import sys + import tarfile + import tempfile + import urllib.request + + version = "2.102.0" + archive_sha256 = "bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386" + binary_sha256 = "7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b" + url = "https://github.com/cli/cli/releases/download/v2.102.0/gh_2.102.0_linux_amd64.tar.gz" + if sys.platform != "linux" or platform.machine() not in ("x86_64", "amd64"): + raise RuntimeError("pinned GitHub CLI requires Linux x64") + parent = Path(os.environ["RUNNER_TEMP"]) + if not parent.is_absolute(): + raise RuntimeError("RUNNER_TEMP must be absolute") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("runner temporary ancestry must be real directories") + root = Path(tempfile.mkdtemp(prefix="cwl-gh-", dir=parent)) + os.chmod(root, 0o700) + try: + archive = root / "gh.tar.gz" + with urllib.request.urlopen(url, timeout=30) as response, archive.open("xb") as output: + total = 0 + digest = hashlib.sha256() + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > 20 * 1024 * 1024: + raise RuntimeError("GitHub CLI archive exceeds bound") + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != archive_sha256: + raise RuntimeError("GitHub CLI archive checksum mismatch") + expected_member = f"gh_{version}_linux_amd64/bin/gh" + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + selected = [member for member in members if member.name == expected_member] + if len(selected) != 1 or not selected[0].isfile(): + raise RuntimeError("GitHub CLI archive must contain one regular binary") + member = selected[0] + if not 0 < member.size <= 64 * 1024 * 1024: + raise RuntimeError("GitHub CLI binary size exceeds bound") + stream = source.extractfile(member) + if stream is None: + raise RuntimeError("GitHub CLI binary could not be read") + with stream: + content = stream.read(member.size + 1) + if len(content) != member.size or hashlib.sha256(content).hexdigest() != binary_sha256: + raise RuntimeError("GitHub CLI binary checksum mismatch") + # Copy this single pinned regular member; never extract archive paths or links. + bindir = root / "bin" + bindir.mkdir(mode=0o700) + executable = bindir / "gh" + with executable.open("xb") as output: + output.write(content) + executable.chmod(0o700) + result = subprocess.run([str(executable), "--version"], check=True, + capture_output=True, text=True, timeout=10) + if not result.stdout.splitlines() or not result.stdout.splitlines()[0].startswith(f"gh version {version} "): + raise RuntimeError("GitHub CLI version mismatch") + archive.unlink() + with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output: + output.write(str(bindir) + "\n") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: + output.write("CWL_GH_BIN=" + str(executable) + "\n") + info = root.stat() + output.write("CWL_GH_ROOT=" + str(root) + "\n") + output.write("CWL_GH_ROOT_DEVICE=" + str(info.st_dev) + "\n") + output.write("CWL_GH_ROOT_INODE=" + str(info.st_ino) + "\n") + print("Installed checksum-verified job-local GitHub CLI " + version) + except BaseException: + shutil.rmtree(root) + raise + PY + + - name: Verify pinned job-local GitHub CLI selection + shell: bash + run: | + set -euo pipefail + if [ ! -f "$CWL_GH_BIN" ] || [ -L "$CWL_GH_BIN" ] || [ ! -x "$CWL_GH_BIN" ]; then + echo "::error::Job-local GitHub CLI executable is missing or linked." + exit 1 + fi + if [ "$(command -v gh)" != "$CWL_GH_BIN" ]; then + echo "::error::GitHub CLI PATH selection differs from the pinned job-local binary." + exit 1 + fi + printf '%s %s\n' '7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b' "$CWL_GH_BIN" | sha256sum --check --status + version_output="$(gh --version)" + case "$version_output" in + 'gh version 2.102.0 '*) printf '%s\n' "GitHub CLI 2.102.0 path and checksum verified." ;; + *) echo "::error::GitHub CLI selected an unsupported version."; exit 1 ;; + esac + - name: Dispatch current-head CodeQL scan env: GH_TOKEN: ${{ github.token }} @@ -531,3 +793,37 @@ jobs: --arg producer_source_sha "$live_merge" \ '{event_type:"codeql-scan-v2",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head:{schema:"1",ref:$pr_head_ref,sha:$pr_head_sha},producer_source_sha:$producer_source_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' | GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - + - name: Clean up this job's pinned GitHub CLI + if: always() && env.CWL_GH_ROOT != '' + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import os + from pathlib import Path + import re + import shutil + import stat + + root = Path(os.environ["CWL_GH_ROOT"]) + parent = Path(os.environ["RUNNER_TEMP"]) + if (not root.is_absolute() or root.parent != parent + or not re.fullmatch(r"cwl-gh-[A-Za-z0-9_-]+", root.name) + or any(ord(c) < 32 for c in str(root))): + raise RuntimeError("refusing unrelated GitHub CLI cleanup path") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("refusing changed GitHub CLI temporary ancestry") + try: + info = root.lstat() + except FileNotFoundError: + print("Owned GitHub CLI directory is already absent.") + else: + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() + or info.st_dev != int(os.environ["CWL_GH_ROOT_DEVICE"]) + or info.st_ino != int(os.environ["CWL_GH_ROOT_INODE"])): + raise RuntimeError("refusing changed GitHub CLI directory identity") + # No glob or shared-temp sweep: delete only this job's original directory. + shutil.rmtree(root) + print("Removed this job's checksum-verified GitHub CLI directory.") + PY diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index abdc8af49e..539f069122 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -486,6 +486,113 @@ jobs: with: python-version: "3.13" + - name: Provision pinned job-local GitHub CLI + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import os + from pathlib import Path + import platform + import shutil + import subprocess + import sys + import tarfile + import tempfile + import urllib.request + + version = "2.102.0" + archive_sha256 = "bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386" + binary_sha256 = "7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b" + url = "https://github.com/cli/cli/releases/download/v2.102.0/gh_2.102.0_linux_amd64.tar.gz" + if sys.platform != "linux" or platform.machine() not in ("x86_64", "amd64"): + raise RuntimeError("pinned GitHub CLI requires Linux x64") + parent = Path(os.environ["RUNNER_TEMP"]) + if not parent.is_absolute(): + raise RuntimeError("RUNNER_TEMP must be absolute") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("runner temporary ancestry must be real directories") + root = Path(tempfile.mkdtemp(prefix="cwl-gh-", dir=parent)) + os.chmod(root, 0o700) + try: + archive = root / "gh.tar.gz" + with urllib.request.urlopen(url) as response, archive.open("xb") as output: + total = 0 + digest = hashlib.sha256() + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > 20 * 1024 * 1024: + raise RuntimeError("GitHub CLI archive exceeds bound") + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != archive_sha256: + raise RuntimeError("GitHub CLI archive checksum mismatch") + expected_member = f"gh_{version}_linux_amd64/bin/gh" + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + selected = [member for member in members if member.name == expected_member] + if len(selected) != 1 or not selected[0].isfile(): + raise RuntimeError("GitHub CLI archive must contain one regular binary") + member = selected[0] + if not 0 < member.size <= 64 * 1024 * 1024: + raise RuntimeError("GitHub CLI binary size exceeds bound") + stream = source.extractfile(member) + if stream is None: + raise RuntimeError("GitHub CLI binary could not be read") + with stream: + content = stream.read(member.size + 1) + if len(content) != member.size or hashlib.sha256(content).hexdigest() != binary_sha256: + raise RuntimeError("GitHub CLI binary checksum mismatch") + # Copy this single pinned regular member; never extract archive paths or links. + bindir = root / "bin" + bindir.mkdir(mode=0o700) + executable = bindir / "gh" + with executable.open("xb") as output: + output.write(content) + executable.chmod(0o700) + result = subprocess.run([str(executable), "--version"], check=True, + capture_output=True, text=True, timeout=10) + if not result.stdout.splitlines() or not result.stdout.splitlines()[0].startswith(f"gh version {version} "): + raise RuntimeError("GitHub CLI version mismatch") + archive.unlink() + with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output: + output.write(str(bindir) + "\n") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: + output.write("CWL_GH_BIN=" + str(executable) + "\n") + info = root.stat() + output.write("CWL_GH_ROOT=" + str(root) + "\n") + output.write("CWL_GH_ROOT_DEVICE=" + str(info.st_dev) + "\n") + output.write("CWL_GH_ROOT_INODE=" + str(info.st_ino) + "\n") + print("Installed checksum-verified job-local GitHub CLI " + version) + except BaseException: + shutil.rmtree(root) + raise + PY + + - name: Verify pinned job-local GitHub CLI selection + shell: bash + run: | + set -euo pipefail + if [ ! -f "$CWL_GH_BIN" ] || [ -L "$CWL_GH_BIN" ] || [ ! -x "$CWL_GH_BIN" ]; then + echo "::error::Job-local GitHub CLI executable is missing or linked." + exit 1 + fi + if [ "$(command -v gh)" != "$CWL_GH_BIN" ]; then + echo "::error::GitHub CLI PATH selection differs from the pinned job-local binary." + exit 1 + fi + printf '%s %s\n' '7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b' "$CWL_GH_BIN" | sha256sum --check --status + version_output="$(gh --version)" + case "$version_output" in + 'gh version 2.102.0 '*) printf '%s\n' "GitHub CLI 2.102.0 path and checksum verified." ;; + *) echo "::error::GitHub CLI selected an unsupported version."; exit 1 ;; + esac + - name: Resolve trusted Strix source ref id: trusted_source env: @@ -1293,6 +1400,41 @@ jobs: fi echo "::warning::Could not publish manual Strix status from scan job; keeping scan evidence result authoritative in the workflow run." + - name: Clean up this job's pinned GitHub CLI + if: always() && env.CWL_GH_ROOT != '' + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import os + from pathlib import Path + import re + import shutil + import stat + + root = Path(os.environ["CWL_GH_ROOT"]) + parent = Path(os.environ["RUNNER_TEMP"]) + if (not root.is_absolute() or root.parent != parent + or not re.fullmatch(r"cwl-gh-[A-Za-z0-9_-]+", root.name) + or any(ord(c) < 32 for c in str(root))): + raise RuntimeError("refusing unrelated GitHub CLI cleanup path") + for ancestor in (parent, *parent.parents): + if ancestor.is_symlink() or not ancestor.is_dir(): + raise RuntimeError("refusing changed GitHub CLI temporary ancestry") + try: + info = root.lstat() + except FileNotFoundError: + print("Owned GitHub CLI directory is already absent.") + else: + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() + or info.st_dev != int(os.environ["CWL_GH_ROOT_DEVICE"]) + or info.st_ino != int(os.environ["CWL_GH_ROOT_INODE"])): + raise RuntimeError("refusing changed GitHub CLI directory identity") + # No glob or shared-temp sweep: delete only this job's original directory. + shutil.rmtree(root) + print("Removed this job's checksum-verified GitHub CLI directory.") + PY + publish-manual-pr-evidence-status: name: publish-manual-pr-evidence-status needs: strix diff --git a/tests/test_central_gh_first_use_contract.py b/tests/test_central_gh_first_use_contract.py new file mode 100644 index 0000000000..6a9ad2c17d --- /dev/null +++ b/tests/test_central_gh_first_use_contract.py @@ -0,0 +1,319 @@ +"""Require job-local GitHub CLI provisioning before the affected host calls.""" +from __future__ import annotations + +import copy +from pathlib import Path +import re + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +TARGETS = ( + ("strix.yml", "strix"), + ("codeql-pr.yml", "analyze-head"), + ("codeql-pr.yml", "dispatch-current-head"), +) +INSTALL = "Provision pinned job-local GitHub CLI" +VERIFY = "Verify pinned job-local GitHub CLI selection" + + +def _steps(filename: str, job: str) -> list[dict]: + """Read the actual executable steps without interpreting the event key.""" + return yaml.safe_load((ROOT / ".github/workflows" / filename).read_text())["jobs"][job]["steps"] + + +def _assert_order(steps: list[dict]) -> None: + """Require unconditional setup and selection before the first literal call.""" + installs = [i for i, step in enumerate(steps) if step.get("name") == INSTALL] + verifies = [i for i, step in enumerate(steps) if step.get("name") == VERIFY] + assert len(installs) == len(verifies) == 1, "missing gh provisioning/selection" + install, verify = installs[0], verifies[0] + calls = [i for i, step in enumerate(steps) if i not in (install, verify) + and re.search(r"(?:^|[\s;|&($])(?:/[^\s]+/)?gh\s+(?:api|auth|pr|run)\b", step.get("run", ""))] + assert calls and install < verify < min(calls), "gh provisioning is after first use" + for i in (install, verify): + # Conditional readiness must exactly follow every affected consumer. + consumer_guards = [steps[index].get("if") for index in calls] + expected = consumer_guards[0] if len(set(consumer_guards)) == 1 else None + assert steps[i].get("if") == expected + assert "continue-on-error" not in steps[i] + assert steps[i].get("shell") == "bash" + assert "sha256" in steps[install]["run"] + assert 'with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8")' in steps[install]["run"] + assert "command -v gh" in steps[verify]["run"] + assert "gh --version" in steps[verify]["run"] + + +@pytest.mark.parametrize("filename,job", TARGETS) +def test_affected_job_provisions_gh_before_first_host_use(filename: str, job: str) -> None: + """Catch the original missing-prerequisite failure in each separate job.""" + _assert_order(_steps(filename, job)) + + +@pytest.mark.parametrize("mutation", ["delete", "late", "conditional", "tolerated", "comment"]) +def test_provisioning_order_rejects_mutated_executable_steps(mutation: str) -> None: + """A name/comment or optional/late setup cannot satisfy executable ordering.""" + steps = copy.deepcopy(_steps(*TARGETS[0])) + index = next(i for i, step in enumerate(steps) if step.get("name") == INSTALL) + if mutation == "delete": + del steps[index] + elif mutation == "late": + steps.append(steps.pop(index)) + elif mutation == "conditional": + steps[index]["if"] = "false" + elif mutation == "tolerated": + steps[index]["continue-on-error"] = True + else: + steps[index]["run"] = "# sha256 and $GITHUB_PATH are documentation only" + with pytest.raises(AssertionError): + _assert_order(steps) + + +def test_docs_only_shard_does_not_admit_network_provisioning() -> None: + """Preserve the docs-only check without downloading an unused host tool.""" + steps = _steps("codeql-pr.yml", "analyze-head") + guard = "needs.detect-languages.outputs.code == 'true'" + selected = [step for step in steps if step.get("name") in ( + "Set up Python for pinned GitHub CLI provisioning", INSTALL, VERIFY)] + assert len(selected) == 3 + assert all(step.get("if") == guard for step in selected) + consumer = next(step for step in steps if step.get("name") == "Read current-head CodeQL dispatch verdict") + assert consumer.get("if") == guard + + +def test_strix_bootstrap_does_not_introduce_fixed_network_deadline() -> None: + """Honor the existing Strix bootstrap DNS/TLS readiness policy.""" + import ast + + run = next(step["run"] for step in _steps("strix.yml", "strix") if step.get("name") == INSTALL) + payload = run.split("python3 - <<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + calls = [node for node in ast.walk(ast.parse(payload)) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "urlopen"] + assert len(calls) == 1 + assert not calls[0].keywords, "fixed network deadline introduced before Strix review" + + +@pytest.mark.parametrize("filename,job", TARGETS) +def test_owned_installation_is_cleaned_after_last_consumer(filename: str, job: str) -> None: + """Do not accumulate successful CLI copies on persistent runner temp storage.""" + steps = _steps(filename, job) + cleanup = steps[-1] + assert cleanup.get("name") == "Clean up this job's pinned GitHub CLI" + assert cleanup.get("if") == "always() && env.CWL_GH_ROOT != ''" + assert cleanup.get("shell") == "bash" + assert "CWL_GH_ROOT_INODE" in cleanup.get("run", "") + assert "shutil.rmtree(root)" in cleanup.get("run", "") + + +@pytest.mark.parametrize("mutation", ["owned", "identity-drift", "foreign-prefix", "symlink"]) +def test_actual_cleanup_preserves_other_job_directories(monkeypatch, tmp_path, mutation) -> None: + """Execute the exact job-end cleaner with real private directory identities.""" + parent = tmp_path / "runner" + parent.mkdir() + root = parent / ("foreign-job" if mutation == "foreign-prefix" else "cwl-gh-owned") + root.mkdir() + (root / "binary").write_bytes(b"owned synthetic content") + unrelated = parent / "cwl-gh-unrelated" + unrelated.mkdir() + (unrelated / "sentinel").write_bytes(b"untouched") + info = root.stat() + monkeypatch.setenv("RUNNER_TEMP", str(parent)) + monkeypatch.setenv("CWL_GH_ROOT", str(root)) + monkeypatch.setenv("CWL_GH_ROOT_DEVICE", str(info.st_dev)) + monkeypatch.setenv("CWL_GH_ROOT_INODE", str(info.st_ino + (mutation == "identity-drift"))) + if mutation == "symlink": + import shutil + shutil.rmtree(root) + root.symlink_to(unrelated, target_is_directory=True) + run = _steps(*TARGETS[0])[-1]["run"] + payload = run.split("python3 - <<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + if mutation == "owned": + exec(compile(payload, "", "exec"), {}) + assert not root.exists() + else: + with pytest.raises(RuntimeError, match="refusing"): + exec(compile(payload, "", "exec"), {}) + assert root.exists() + assert (unrelated / "sentinel").read_bytes() == b"untouched" + + +def _installer_tree(): + """Compile only the Python payload actually embedded in executable YAML.""" + import ast + + run = next(step["run"] for step in _steps(*TARGETS[0]) if step.get("name") == INSTALL) + payload = run.split("python3 - <<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + return ast.parse(payload) + + +def _fixture_archive(binary: bytes, kind: str = "regular") -> bytes: + """Make explicit synthetic supplier bytes for behavioral boundary controls.""" + import io + import tarfile + + result = io.BytesIO() + with tarfile.open(fileobj=result, mode="w:gz") as archive: + member = tarfile.TarInfo("gh_2.102.0_linux_amd64/bin/gh") + if kind == "linked": + member.type = tarfile.SYMTYPE + member.linkname = "/outside/gh" + archive.addfile(member) + else: + member.size = len(binary) + archive.addfile(member, io.BytesIO(binary)) + if kind == "duplicate": + archive.addfile(member, io.BytesIO(binary)) + return result.getvalue() + + +def _run_installer_fixture(monkeypatch, tmp_path, *, kind="regular", mismatch=None, fail_download=False): + """Execute real embedded installer with synthetic archive pins and no network.""" + import ast + import hashlib + import io + import platform + import sys + import urllib.request + + binary = b'#!/bin/sh\nprintf "gh version 2.102.0 (synthetic fixture)\\n"\n' + archive = _fixture_archive(binary, kind) + tree = _installer_tree() + pins = {"archive_sha256": hashlib.sha256(archive).hexdigest(), + "binary_sha256": hashlib.sha256(binary).hexdigest()} + if mismatch: + pins[mismatch] = "0" * 64 + for node in tree.body: + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name): + key = node.targets[0].id + if key in pins: + node.value = ast.Constant(pins[key]) + ast.fix_missing_locations(tree) + runner = tmp_path / "runner" + runner.mkdir() + envfile = tmp_path / "env" + pathfile = tmp_path / "path" + monkeypatch.setenv("RUNNER_TEMP", str(runner)) + monkeypatch.setenv("GITHUB_ENV", str(envfile)) + monkeypatch.setenv("GITHUB_PATH", str(pathfile)) + # Platform patching admits only this synthetic, portable shell fixture. + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(platform, "machine", lambda: "x86_64") + + def download(url, timeout=None): + """Disallow external requests while reproducing a bounded response.""" + assert url == "https://github.com/cli/cli/releases/download/v2.102.0/gh_2.102.0_linux_amd64.tar.gz" + assert timeout is None + if fail_download: + raise OSError("synthetic download refusal") + return io.BytesIO(archive) + + monkeypatch.setattr(urllib.request, "urlopen", download) + try: + exec(compile(tree, "", "exec"), {}) + except BaseException: + assert not list(runner.iterdir()), "failed install must clean its exclusive directory" + assert not envfile.exists() and not pathfile.exists() + raise + installed = dict(line.split("=", 1) for line in envfile.read_text().splitlines()) + executable = Path(installed["CWL_GH_BIN"]) + assert installed["CWL_GH_ROOT"] == str(executable.parent.parent) + assert installed["CWL_GH_ROOT_DEVICE"] == str(executable.parent.parent.stat().st_dev) + assert installed["CWL_GH_ROOT_INODE"] == str(executable.parent.parent.stat().st_ino) + assert executable.read_bytes() == binary + assert pathfile.read_text() == str(executable.parent) + "\n" + assert not executable.is_symlink() + return executable + + +def test_embedded_installer_runs_synthetic_verified_supplier(monkeypatch, tmp_path) -> None: + """Exercise download/hash/copy/execution/path handoff, not real gh inference.""" + _run_installer_fixture(monkeypatch, tmp_path) + + +@pytest.mark.parametrize("mismatch", ["archive_sha256", "binary_sha256"]) +def test_embedded_installer_refuses_changed_supplier_bytes(monkeypatch, tmp_path, mismatch) -> None: + """Neither changed archive nor changed binary can reach publication.""" + with pytest.raises(RuntimeError, match="checksum mismatch"): + _run_installer_fixture(monkeypatch, tmp_path, mismatch=mismatch) + + +@pytest.mark.parametrize("kind", ["linked", "duplicate"]) +def test_embedded_installer_refuses_nonregular_or_duplicate_binary(monkeypatch, tmp_path, kind) -> None: + """Never follow archive links or choose ambiguously duplicated members.""" + with pytest.raises(RuntimeError, match="one regular binary"): + _run_installer_fixture(monkeypatch, tmp_path, kind=kind) + + +def test_embedded_installer_download_failure_cleans_exclusive_directory(monkeypatch, tmp_path) -> None: + """A transport failure propagates before path/environment publication.""" + with pytest.raises(OSError, match="synthetic download refusal"): + _run_installer_fixture(monkeypatch, tmp_path, fail_download=True) + + +@pytest.mark.parametrize("osname,architecture", [("darwin", "x86_64"), ("linux", "aarch64")]) +def test_embedded_installer_refuses_unapproved_platform_before_download(monkeypatch, osname, architecture) -> None: + """Do not execute the pinned x64 release on a different OS/architecture.""" + import platform + import sys + import urllib.request + + def forbidden(*args, **kwargs): + """Any downloader invocation in this negative control is a defect.""" + pytest.fail("download attempted before unsupported-platform refusal") + + monkeypatch.setattr(sys, "platform", osname) + monkeypatch.setattr(platform, "machine", lambda: architecture) + monkeypatch.setattr(urllib.request, "urlopen", forbidden) + with pytest.raises(RuntimeError, match="requires Linux x64"): + exec(compile(_installer_tree(), "", "exec"), {}) + + +def test_all_three_jobs_share_identical_supplier_and_verifier() -> None: + """Inline bootstraps must retain one exact distribution and selection contract.""" + for name in (INSTALL, VERIFY): + payloads = [next(step["run"] for step in _steps(*target) if step.get("name") == name) + for target in TARGETS] + # Only the documented Strix DNS/TLS no-timeout policy differs. + if name == INSTALL: + assert "urlopen(url)" in payloads[0] + assert all("urlopen(url, timeout=30)" in item for item in payloads[1:]) + payloads = [item.replace("urlopen(url, timeout=30)", "urlopen(url)") for item in payloads] + assert len(set(payloads)) == 1 + assert "7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b" in payloads[0] + + +@pytest.mark.parametrize("path_selection", ["approved-first", "forged-first", "changed-binary"]) +def test_actual_shell_verifier_checks_path_selection_and_bytes(tmp_path, path_selection) -> None: + """Run the real Bash verifier with explicitly pinned synthetic shell bytes.""" + import hashlib + import os + import shutil + import subprocess + + bindir = tmp_path / "approved" + forged = tmp_path / "ambient" + bindir.mkdir() + forged.mkdir() + executable = bindir / "gh" + binary = b'#!/bin/sh\nprintf "gh version 2.102.0 (synthetic fixture)\\n"\n' + executable.write_bytes(binary) + executable.chmod(0o700) + (forged / "gh").write_text('#!/bin/sh\nexit 99\n') + (forged / "gh").chmod(0o700) + verifier = next(step["run"] for step in _steps(*TARGETS[0]) if step.get("name") == VERIFY) + verifier = verifier.replace("7469124f706944133d6a169691dd1c6c3511b12e85878d255e044e2948df4c9b", hashlib.sha256(binary).hexdigest()) + if path_selection == "changed-binary": + executable.write_bytes(binary + b"# changed\n") + # sha256sum is a real host utility; no fake checksum executable is supplied. + checksum = shutil.which("gsha256sum") or shutil.which("sha256sum") + assert checksum, "fixture requires the actual checksum executable" + chosen = [forged, bindir] if path_selection == "forged-first" else [bindir, forged] + # macOS /sbin/sha256sum is BSD-compatible and lacks GNU --status; use GNU. + checksum_dir = Path(checksum).parent + environment = {"PATH": ":".join(map(str, chosen)) + ":" + str(checksum_dir) + ":/usr/bin:/bin", + "CWL_GH_BIN": str(executable), "HOME": str(tmp_path)} + verifier = verifier.replace("sha256sum --check --status", str(checksum) + " --check --status") + result = subprocess.run(["/bin/bash", "-e", "-c", verifier], env=environment, + capture_output=True, text=True, timeout=10) + assert (result.returncode == 0) == (path_selection == "approved-first"), result.stderr diff --git a/tests/test_docs_only_pr_runner_admission.py b/tests/test_docs_only_pr_runner_admission.py index a0f691e2a2..f26b2a0490 100644 --- a/tests/test_docs_only_pr_runner_admission.py +++ b/tests/test_docs_only_pr_runner_admission.py @@ -197,9 +197,9 @@ def test_codeql_pr_gates_analyze_head_at_step_level_not_job_level(): job run (~20s), succeed, and publish the correctly expanded names. Since the dispatch+exact-job-wake rewrite (docs/adr/0025-codeql-required-workflow-dispatch-architecture.md), - `analyze-head` has two steps: the verdict-read step and the runner-release - step. Both still gate at step level on `code == 'true'`, never at job - level. The one-shot coordinator that POSTs the remaining language matrix + `analyze-head` gates the verdict-read and runner-release steps, together + with their three pinned-CLI prerequisites, at step level on `code == 'true'`, + never at job level. Docs-only shards do not provision an unused CLI. The one-shot coordinator that POSTs the remaining language matrix is a separate job. `analyze-merge` no longer exists: it was required nowhere (PR #1766) and was dropped, not migrated. """ @@ -210,7 +210,13 @@ def test_codeql_pr_gates_analyze_head_at_step_level_not_job_level(): analyze_head = _top_level_job_block(workflow, "analyze-head") assert not re.search(r"(?m)^ if:", analyze_head) - assert analyze_head.count("needs.detect-languages.outputs.code == 'true'") == 2 + # Keep both substantive consumer guards and every new prerequisite guard. + guard = "needs.detect-languages.outputs.code == 'true'" + guarded = re.findall(r"(?m)^ if: (.+)$", analyze_head) + assert len(guarded) == 6 + assert guarded.count(guard) == 4 + assert guarded.count("always() && " + guard) == 1 + assert guarded.count("always() && env.CWL_GH_ROOT != ''") == 1 assert "analyze-merge:" not in workflow