From 397c66673ba8cd2bf7665fde7bed1d801ef253f5 Mon Sep 17 00:00:00 2001 From: yeasy Date: Thu, 9 Jul 2026 23:38:12 -0700 Subject: [PATCH] fix(ci): fail closed on preview lookup errors --- .github/workflows/preview-pdf.yml | 48 +++++++- tests/test_workflow_security.py | 185 +++++++++++++++++++++++++++--- 2 files changed, 210 insertions(+), 23 deletions(-) diff --git a/.github/workflows/preview-pdf.yml b/.github/workflows/preview-pdf.yml index 06083ad..7d8f09a 100644 --- a/.github/workflows/preview-pdf.yml +++ b/.github/workflows/preview-pdf.yml @@ -90,6 +90,7 @@ jobs: runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} steps: - name: Download verified preview bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -113,33 +114,72 @@ jobs: EOF - name: Synchronize mutable preview tag + shell: bash run: | - if gh api --silent "repos/${GITHUB_REPOSITORY}/git/ref/tags/preview-pdf" 2>/dev/null; then + set -euo pipefail + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GITHUB_REPOSITORY: $GITHUB_REPOSITORY" >&2 + exit 1 + fi + if [[ ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid GITHUB_SHA" >&2 + exit 1 + fi + + probe_dir=$(mktemp -d) + trap 'rm -rf "$probe_dir"' EXIT + set +e + gh api --include --method GET \ + "repos/${GITHUB_REPOSITORY}/git/ref/tags/preview-pdf" \ + >"$probe_dir/response" 2>"$probe_dir/error" + probe_rc=$? + set -e + http_status=$(awk '$1 ~ /^HTTP\// && $2 ~ /^[0-9][0-9][0-9]$/ { status=$2 } END { print status }' "$probe_dir/response") + + if [[ $probe_rc -eq 0 && "$http_status" == "200" ]]; then gh api --silent --method PATCH \ "repos/${GITHUB_REPOSITORY}/git/refs/tags/preview-pdf" \ --raw-field sha="$GITHUB_SHA" \ --field force=true - else + elif [[ $probe_rc -ne 0 && "$http_status" == "404" ]]; then gh api --silent --method POST \ "repos/${GITHUB_REPOSITORY}/git/refs" \ --raw-field ref="refs/tags/preview-pdf" \ --raw-field sha="$GITHUB_SHA" + else + cat "$probe_dir/response" >&2 + cat "$probe_dir/error" >&2 + echo "Preview tag lookup failed (exit=$probe_rc, HTTP=${http_status:-unavailable}); refusing to mutate refs or releases." >&2 + exit 1 fi - name: Create or update preview release + shell: bash run: | - if gh release view preview-pdf >/dev/null 2>&1; then + set -euo pipefail + error_file=$(mktemp) + trap 'rm -f "$error_file"' EXIT + set +e + gh release view preview-pdf >/dev/null 2>"$error_file" + release_rc=$? + set -e + + if [[ $release_rc -eq 0 ]]; then gh release edit preview-pdf \ --title "Latest Preview PDF" \ --notes-file dist/release-notes.md \ --prerelease - else + elif [[ "$(tr -d '\r' < "$error_file")" == "release not found" ]]; then gh release create preview-pdf \ --title "Latest Preview PDF" \ --notes-file dist/release-notes.md \ --prerelease \ --latest=false \ --verify-tag + else + cat "$error_file" >&2 + echo "Preview release lookup failed (exit=$release_rc); refusing to create or edit the release." >&2 + exit 1 fi - name: Replace preview assets diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py index 40e19f0..5e842e9 100644 --- a/tests/test_workflow_security.py +++ b/tests/test_workflow_security.py @@ -1,5 +1,9 @@ import json +import os import re +import subprocess +import tempfile +import textwrap import unittest from pathlib import Path @@ -8,6 +12,69 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW_DIR = ROOT / ".github" / "workflows" FULL_ACTION_SHA = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") +FAKE_GH = r'''#!/usr/bin/env python3 +import json +import os +import sys + +args = sys.argv[1:] +with open(os.environ["GH_LOG"], "a", encoding="utf-8") as stream: + stream.write(json.dumps(args) + "\n") + +scenario = os.environ["GH_SCENARIO"] +reasons = { + "401": "Unauthorized", + "403": "Forbidden", + "404": "Not Found", + "429": "Too Many Requests", + "503": "Service Unavailable", +} + +def fail_http(code): + print(f"HTTP/2.0 {code} {reasons[code]}") + print(f"fake gh HTTP {code}", file=sys.stderr) + raise SystemExit(1) + +if args and args[0] == "api": + endpoint = next((arg for arg in args if arg.startswith("repos/")), "") + if "/git/ref/tags/preview-pdf" in endpoint: + if scenario.startswith("ref_network"): + print("fake gh network failure", file=sys.stderr) + raise SystemExit(1) + for code in reasons: + if scenario.startswith(f"ref_{code}"): + fail_http(code) + print("HTTP/2.0 200 OK") + print('Content-Type: application/json\n\n{"ref":"refs/tags/preview-pdf"}') + raise SystemExit(0) + +if args[:3] == ["release", "view", "preview-pdf"]: + if "release_missing" in scenario: + print("release not found", file=sys.stderr) + raise SystemExit(1) + if "release_network" in scenario: + print("fake release network failure", file=sys.stderr) + raise SystemExit(1) + for code in reasons: + if f"release_{code}" in scenario: + print(f"fake release HTTP {code}", file=sys.stderr) + raise SystemExit(1) + raise SystemExit(0) + +raise SystemExit(0) +''' + + +def workflow_step_script(workflow_text, step_name): + marker = f" - name: {step_name}\n" + start = workflow_text.index(marker) + len(marker) + run_marker = " run: |\n" + script_start = workflow_text.index(run_marker, start) + len(run_marker) + script_end = workflow_text.find("\n - name:", script_start) + if script_end < 0: + script_end = len(workflow_text) + return textwrap.dedent(workflow_text[script_start:script_end]) + class WorkflowSecurityTests(unittest.TestCase): @staticmethod @@ -62,27 +129,107 @@ class WorkflowSecurityTests(unittest.TestCase): name, ) - def test_mutable_preview_explicitly_moves_tag_before_updating_release(self): + def run_preview_scripts(self, scenario): preview = (WORKFLOW_DIR / "preview-pdf.yml").read_text(encoding="utf-8") - publish = preview.split("\n publish:\n", 1)[1] - - get_ref = 'gh api --silent "repos/${GITHUB_REPOSITORY}/git/ref/tags/preview-pdf"' - update_ref = '"repos/${GITHUB_REPOSITORY}/git/refs/tags/preview-pdf"' - create_ref = '"repos/${GITHUB_REPOSITORY}/git/refs"' - self.assertIn(get_ref, publish) - self.assertIn("--method PATCH", publish) - self.assertIn(update_ref, publish) - self.assertIn('--raw-field sha="$GITHUB_SHA"', publish) - self.assertIn("--field force=true", publish) - self.assertIn("--method POST", publish) - self.assertIn(create_ref, publish) - self.assertIn('--raw-field ref="refs/tags/preview-pdf"', publish) - self.assertIn("--verify-tag", publish) - self.assertLess(publish.index(get_ref), publish.index("gh release view preview-pdf")) - self.assertNotRegex( - publish, - r"(?ms)gh release edit preview-pdf.*?--target\s+\"?\$GITHUB_SHA", + scripts = ( + workflow_step_script(preview, "Synchronize mutable preview tag"), + workflow_step_script(preview, "Create or update preview release"), ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fake_gh = root / "gh" + fake_gh.write_text(FAKE_GH, encoding="utf-8") + fake_gh.chmod(0o755) + log = root / "commands.jsonl" + env = os.environ.copy() + env.update( + { + "PATH": f"{root}:{env.get('PATH', '')}", + "GH_LOG": str(log), + "GH_SCENARIO": scenario, + "GH_TOKEN": "test-token", + "GH_REPO": "owner/repo", + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_SHA": "a" * 40, + } + ) + result = None + for script in scripts: + result = subprocess.run( + ["/bin/bash", "-c", script], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + break + commands = [ + json.loads(line) + for line in log.read_text(encoding="utf-8").splitlines() + ] + return result, commands + + def test_mutable_preview_updates_existing_tag_and_release(self): + result, commands = self.run_preview_scripts("ref_200_release_exists") + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(any("PATCH" in command for command in commands), commands) + self.assertFalse(any("POST" in command for command in commands), commands) + self.assertIn(["release", "edit", "preview-pdf", "--title", "Latest Preview PDF", "--notes-file", "dist/release-notes.md", "--prerelease"], commands) + + def test_mutable_preview_creates_only_on_explicit_not_found(self): + result, commands = self.run_preview_scripts("ref_404_release_missing") + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(any("POST" in command for command in commands), commands) + self.assertFalse(any("PATCH" in command for command in commands), commands) + self.assertTrue( + any(command[:3] == ["release", "create", "preview-pdf"] for command in commands), + commands, + ) + + def test_preview_tag_lookup_fails_closed_on_non_404_errors(self): + for scenario in ("ref_401", "ref_403", "ref_429", "ref_503", "ref_network"): + with self.subTest(scenario=scenario): + result, commands = self.run_preview_scripts(scenario) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(commands), 1, commands) + self.assertEqual(commands[0][0], "api") + expected = "network failure" if scenario.endswith("network") else scenario.removeprefix("ref_") + self.assertIn(expected, result.stderr) + + def test_preview_release_lookup_fails_closed_except_exact_not_found(self): + scenarios = ( + "ref_200_release_401", + "ref_200_release_403", + "ref_200_release_404", + "ref_200_release_429", + "ref_200_release_503", + "ref_200_release_network", + ) + for scenario in scenarios: + with self.subTest(scenario=scenario): + result, commands = self.run_preview_scripts(scenario) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(any("PATCH" in command for command in commands), commands) + self.assertTrue( + any(command[:3] == ["release", "view", "preview-pdf"] for command in commands), + commands, + ) + self.assertFalse( + any(command[:2] in (["release", "create"], ["release", "edit"]) for command in commands), + commands, + ) + expected = "network failure" if scenario.endswith("network") else scenario.rsplit("release_", 1)[1] + self.assertIn(expected, result.stderr) + + def test_preview_publish_has_explicit_repo_context_only_in_write_job(self): + preview = (WORKFLOW_DIR / "preview-pdf.yml").read_text(encoding="utf-8") + build, publish = preview.split("\n publish:\n", 1) + + self.assertNotIn("GH_REPO", build) + self.assertIn("GH_REPO: ${{ github.repository }}", publish) def test_downloads_dependencies_and_link_checker_are_integrity_pinned(self): combined = "\n".join(path.read_text(encoding="utf-8") for path in self.workflows())