Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions scripts/ci_pages_summary.py
Original file line number Diff line number Diff line change
@@ -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())
3 changes: 3 additions & 0 deletions tests/ci_change_impact_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
96 changes: 96 additions & 0 deletions tests/ci_pages_summary_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading