diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index cf3709f..0bbbb46 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -67,15 +67,12 @@ jobs: exit 1 fi - { - echo "## PRE-RELEASE Pages candidate" - echo "" - echo "- source ref: `$GITHUB_REF`" - echo "- source SHA: `$GITHUB_SHA`" - echo "- candidate SHA: `$CANDIDATE_SHA`" - echo "- current main: `$MAIN_SHA`" - echo "- this deployment is a mutable public candidate, not an immutable GitHub Release" - } >> "$GITHUB_STEP_SUMMARY" + python scripts/ci_pages_summary.py candidate-authorization \ + --summary-path "$GITHUB_STEP_SUMMARY" \ + --source-ref "$GITHUB_REF" \ + --source-sha "$GITHUB_SHA" \ + --candidate-sha "$CANDIDATE_SHA" \ + --current-main "$MAIN_SHA" - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -125,13 +122,11 @@ jobs: } >> "$GITHUB_ENV" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - { - echo "" - echo "### Candidate build identity" - echo "- Web/Firmware version: `v$VERSION`" - echo "- build commit: `$GITHUB_SHA`" - echo "- exact release: `false`" - } >> "$GITHUB_STEP_SUMMARY" + python scripts/ci_pages_summary.py candidate-build-identity \ + --summary-path "$GITHUB_STEP_SUMMARY" \ + --version "$VERSION" \ + --build-commit "$GITHUB_SHA" \ + --exact-release "$EXACT_RELEASE" fi - name: Install exact Web dependencies working-directory: web diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 733ac5f..060386f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,10 @@ jobs: - name: Verify CI supply-chain privilege and workspace boundaries run: python3 -m unittest tests/ci_supply_chain_boundary_test.py - name: Verify CI change-impact routing and Pages cadence - run: python3 -m unittest tests/ci_change_impact_test.py + run: >- + python3 -m unittest + tests/ci_change_impact_test.py + tests/ci_pages_summary_test.py - name: Verify protected-main release authorization run: python3 -m unittest tests/release_authorization_test.py - name: Verify signed release attestation provenance contract diff --git a/scripts/ci_pages_summary.py b/scripts/ci_pages_summary.py new file mode 100644 index 0000000..7befc76 --- /dev/null +++ b/scripts/ci_pages_summary.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Render GitHub Pages candidate summaries without shell interpolation.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def _inline_code(value: str) -> str: + return "`" + value.replace("`", "\\`") + "`" + + +def append_candidate_authorization_summary( + summary_path: Path, + *, + source_ref: str, + source_sha: str, + candidate_sha: str, + current_main: str, +) -> None: + lines = [ + "## PRE-RELEASE Pages candidate", + "", + f"- source ref: {_inline_code(source_ref)}", + f"- source SHA: {_inline_code(source_sha)}", + f"- candidate SHA: {_inline_code(candidate_sha)}", + f"- current main: {_inline_code(current_main)}", + "- this deployment is a mutable public candidate, not an immutable GitHub Release", + "", + ] + with summary_path.open("a", encoding="utf-8") as output: + output.write("\n".join(lines)) + + +def append_candidate_build_identity( + summary_path: Path, + *, + version: str, + build_commit: str, + exact_release: str, +) -> None: + if exact_release not in {"true", "false"}: + raise ValueError("exact_release must be true or false") + lines = [ + "", + "### Candidate build identity", + f"- Web/Firmware version: {_inline_code('v' + version)}", + f"- build commit: {_inline_code(build_commit)}", + f"- exact release: {_inline_code(exact_release)}", + "", + ] + with summary_path.open("a", encoding="utf-8") as output: + output.write("\n".join(lines)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + auth = subparsers.add_parser("candidate-authorization") + auth.add_argument("--summary-path", type=Path, required=True) + auth.add_argument("--source-ref", required=True) + auth.add_argument("--source-sha", required=True) + auth.add_argument("--candidate-sha", required=True) + auth.add_argument("--current-main", required=True) + + identity = subparsers.add_parser("candidate-build-identity") + identity.add_argument("--summary-path", type=Path, required=True) + identity.add_argument("--version", required=True) + identity.add_argument("--build-commit", required=True) + identity.add_argument("--exact-release", choices=("true", "false"), required=True) + + args = parser.parse_args(argv) + + if args.command == "candidate-authorization": + append_candidate_authorization_summary( + args.summary_path, + source_ref=args.source_ref, + source_sha=args.source_sha, + candidate_sha=args.candidate_sha, + current_main=args.current_main, + ) + else: + append_candidate_build_identity( + args.summary_path, + version=args.version, + build_commit=args.build_commit, + exact_release=args.exact_release, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/ci_change_impact_test.py b/tests/ci_change_impact_test.py index e337b67..5a275b2 100644 --- a/tests/ci_change_impact_test.py +++ b/tests/ci_change_impact_test.py @@ -202,6 +202,9 @@ def test_pages_has_no_main_push_and_retains_tag_manual_cleanup(self) -> None: self.assertIn("retention-days: 1", text) self.assertIn("actions/artifacts/$PAGES_ARTIFACT_ID", text) self.assertIn("GITHUB_STEP_SUMMARY", text) + self.assertIn("python scripts/ci_pages_summary.py candidate-authorization", text) + self.assertIn("python scripts/ci_pages_summary.py candidate-build-identity", text) + self.assertNotRegex(text, r'echo\s+"[^"\n]*`') def test_release_authorized_artifact_boundary_is_unchanged(self) -> None: text = self.RELEASE.read_text(encoding="utf-8") diff --git a/tests/ci_pages_summary_test.py b/tests/ci_pages_summary_test.py new file mode 100644 index 0000000..c34801b --- /dev/null +++ b/tests/ci_pages_summary_test.py @@ -0,0 +1,96 @@ +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = REPO_ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import ci_pages_summary + + +class PagesCandidateSummaryTests(unittest.TestCase): + def test_candidate_authorization_summary_records_exact_identity(self) -> None: + with tempfile.TemporaryDirectory() as directory: + summary = Path(directory) / "summary.md" + ci_pages_summary.append_candidate_authorization_summary( + summary, + source_ref="refs/heads/main", + source_sha="a" * 40, + candidate_sha="a" * 40, + current_main="a" * 40, + ) + self.assertEqual( + summary.read_text(encoding="utf-8"), + "## PRE-RELEASE Pages candidate\n" + "\n" + "- source ref: `refs/heads/main`\n" + f"- source SHA: `{'a' * 40}`\n" + f"- candidate SHA: `{'a' * 40}`\n" + f"- current main: `{'a' * 40}`\n" + "- this deployment is a mutable public candidate, not an immutable GitHub Release\n", + ) + + def test_candidate_build_identity_records_non_exact_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + summary = Path(directory) / "summary.md" + ci_pages_summary.append_candidate_build_identity( + summary, + version="1.0.0", + build_commit="b" * 40, + exact_release="false", + ) + self.assertEqual( + summary.read_text(encoding="utf-8"), + "\n" + "### Candidate build identity\n" + "- Web/Firmware version: `v1.0.0`\n" + f"- build commit: `{'b' * 40}`\n" + "- exact release: `false`\n", + ) + + def test_cli_preserves_shell_metacharacters_as_literal_text(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + summary = root / "summary.md" + backtick_sentinel = root / "backtick-executed" + dollar_sentinel = root / "dollar-executed" + source_ref = ( + "refs/heads/main" + f"`touch {backtick_sentinel}`" + f"$(touch {dollar_sentinel})" + ) + + subprocess.run( + [ + sys.executable, + str(SCRIPTS / "ci_pages_summary.py"), + "candidate-authorization", + "--summary-path", + str(summary), + "--source-ref", + source_ref, + "--source-sha", + "c" * 40, + "--candidate-sha", + "c" * 40, + "--current-main", + "c" * 40, + ], + cwd=REPO_ROOT, + check=True, + ) + + rendered = summary.read_text(encoding="utf-8") + self.assertIn("refs/heads/main", rendered) + self.assertIn("$(touch ", rendered) + self.assertIn("\\`touch ", rendered) + self.assertFalse(backtick_sentinel.exists()) + self.assertFalse(dollar_sentinel.exists()) + + +if __name__ == "__main__": + unittest.main()