diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56d4517..3cb6928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,20 @@ jobs: npm run manifest - name: Run verification suite run: npm run check + - name: Verify Pages deployment portability + shell: bash + run: | + set -euo pipefail + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + python3 scripts/build_pages.py \ + --source site \ + --output "$TMP_DIR/current" + diff -qr site "$TMP_DIR/current" + python3 scripts/build_pages.py \ + --source site \ + --output "$TMP_DIR/portable" \ + --public-url "https://portable.example/frontier-decision-engine/" - name: Run required CodeQL analysis uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b27e573..443c4d7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -57,10 +57,19 @@ jobs: exit 1 fi echo "Released site payload unchanged for $TAG." + - name: Configure Pages + id: pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Build deployment artifact + run: | + python3 scripts/build_pages.py \ + --source site \ + --output _site \ + --public-url "${{ steps.pages.outputs.base_url }}" - name: Upload static site uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: - path: site + path: _site deploy: needs: build @@ -76,8 +85,6 @@ jobs: outputs: page_url: ${{ steps.deployment.outputs.page_url }} steps: - - name: Configure Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy id: deployment uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/scripts/build_pages.py b/scripts/build_pages.py new file mode 100644 index 0000000..480aea3 --- /dev/null +++ b/scripts/build_pages.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +from pathlib import Path +from urllib.parse import urlparse + +MANIFEST_NAME = "manifest.sha256" +OG_URL = re.compile(r'()') +CANONICAL = re.compile(r'()') +ALLOWED_CHANGED_FILES = {"index.html", "start.html", MANIFEST_NAME} + + +def normalize_public_url(value: str) -> str: + candidate = str(value).strip() + parsed = urlparse(candidate) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError( + "public URL must be an absolute HTTPS URL without credentials, query, or fragment" + ) + return candidate.rstrip("/") + "/" + + +def extract_one(text: str, pattern: re.Pattern[str], label: str) -> str: + matches = list(pattern.finditer(text)) + if len(matches) != 1: + raise RuntimeError( + f"{label} field count changed: expected 1, observed {len(matches)}" + ) + return normalize_public_url(matches[0].group(2)) + + +def replace_one(text: str, pattern: re.Pattern[str], public_url: str, label: str) -> str: + matches = list(pattern.finditer(text)) + if len(matches) != 1: + raise RuntimeError( + f"{label} field count changed: expected 1, observed {len(matches)}" + ) + return pattern.sub( + lambda match: f"{match.group(1)}{public_url}{match.group(3)}", + text, + count=1, + ) + + +def manifest_text(root: Path) -> str: + rows = [] + for path in sorted( + p for p in root.rglob("*") if p.is_file() and p.name != MANIFEST_NAME + ): + relative = path.relative_to(root).as_posix() + rows.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {relative}") + return "\n".join(rows) + "\n" + + +def file_map(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in sorted(p for p in root.rglob("*") if p.is_file()) + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, default=Path("site")) + parser.add_argument("--output", type=Path, default=Path("_site")) + parser.add_argument("--public-url", default=None) + args = parser.parse_args() + + source = args.source.resolve() + output = args.output.resolve() + if not source.is_dir(): + raise SystemExit(f"STOP - source directory not found: {source}") + if source == output or source in output.parents or output in source.parents: + raise SystemExit("STOP - source and output directories must not contain each other") + + source_index = (source / "index.html").read_text(encoding="utf-8") + source_start = (source / "start.html").read_text(encoding="utf-8") + source_urls = [ + extract_one(source_index, OG_URL, "index og:url"), + extract_one(source_index, CANONICAL, "index canonical"), + extract_one(source_start, CANONICAL, "start canonical"), + ] + if len(set(source_urls)) != 1: + raise SystemExit("STOP - source self URL fields disagree") + + source_public_url = source_urls[0] + try: + target_public_url = normalize_public_url(args.public_url or source_public_url) + except ValueError as exc: + raise SystemExit(f"STOP - {exc}") from exc + + source_files = file_map(source) + + if output.exists(): + shutil.rmtree(output) + shutil.copytree(source, output) + + output_index = (output / "index.html").read_text(encoding="utf-8") + output_index = replace_one( + output_index, OG_URL, target_public_url, "index og:url" + ) + output_index = replace_one( + output_index, CANONICAL, target_public_url, "index canonical" + ) + (output / "index.html").write_text( + output_index, encoding="utf-8", newline="\n" + ) + + output_start = (output / "start.html").read_text(encoding="utf-8") + output_start = replace_one( + output_start, CANONICAL, target_public_url, "start canonical" + ) + (output / "start.html").write_text( + output_start, encoding="utf-8", newline="\n" + ) + + observed_urls = [ + extract_one(output_index, OG_URL, "output index og:url"), + extract_one(output_index, CANONICAL, "output index canonical"), + extract_one(output_start, CANONICAL, "output start canonical"), + ] + if set(observed_urls) != {target_public_url}: + raise SystemExit("STOP - generated deployment self URLs disagree") + + (output / MANIFEST_NAME).write_text( + manifest_text(output), encoding="utf-8", newline="\n" + ) + + output_files = file_map(output) + if set(source_files) != set(output_files): + raise SystemExit("STOP - deployment artifact file set differs from source") + + changed = { + path for path in source_files if source_files[path] != output_files[path] + } + expected_changed = ( + set() + if target_public_url == source_public_url + else ALLOWED_CHANGED_FILES + ) + if changed != expected_changed: + raise SystemExit( + "STOP - deployment artifact changed outside the envelope allowlist: " + + json.dumps( + { + "expected": sorted(expected_changed), + "observed": sorted(changed), + } + ) + ) + + print( + json.dumps( + { + "source_public_url": source_public_url, + "target_public_url": target_public_url, + "changed_files": sorted(changed), + "status": "PASS", + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())