diff --git a/.github/workflows/foundation.yml b/.github/workflows/foundation.yml index 83793e7..237cb9e 100644 --- a/.github/workflows/foundation.yml +++ b/.github/workflows/foundation.yml @@ -10,8 +10,39 @@ permissions: contents: read jobs: + classify: + name: classify + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + web: ${{ steps.impact.outputs.web }} + firmware: ${{ steps.impact.outputs.firmware }} + pages: ${{ steps.impact.outputs.pages }} + security_release_shared: ${{ steps.impact.outputs.security_release_shared }} + snapshot_contract: ${{ steps.impact.outputs.snapshot_contract }} + snapshot_build: ${{ steps.impact.outputs.snapshot_build }} + process_docs_only: ${{ steps.impact.outputs.process_docs_only }} + uncertain: ${{ steps.impact.outputs.uncertain }} + steps: + - name: Checkout complete history for deterministic diff + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Classify changed paths fail-safe + id: impact + shell: bash + run: >- + python3 scripts/ci_change_impact.py + --event-file "$GITHUB_EVENT_PATH" + --event-name "$GITHUB_EVENT_NAME" + --github-output "$GITHUB_OUTPUT" + web: name: web + needs: classify + if: needs.classify.outputs.web == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -72,6 +103,57 @@ jobs: cat /tmp/qr-smoke-dom.html exit 1 fi + - name: Build production-equivalent Web smoke + working-directory: web + shell: bash + run: | + set -euo pipefail + version="$(python3 - <<'PY' + import json + from pathlib import Path + print(json.loads(Path("../firmware/release-profile.json").read_text(encoding="utf-8"))["firmware_version"]) + PY + )" + VITE_M5AUTH_WEB_VERSION="$version" \ + VITE_M5AUTH_WEB_BUILD_COMMIT="$GITHUB_SHA" \ + VITE_M5AUTH_WEB_EXACT_RELEASE=false \ + VITE_M5AUTH_FLASH_ENABLED=true \ + npx --no-install vite build --mode production-smoke + - name: Test production-equivalent routes and same-origin firmware assets on Chrome + working-directory: web + shell: bash + run: | + set -euo pipefail + smoke_url="http://127.0.0.1:4174/m5authenticator/tests/browser/production-site-smoke.html" + npx --no-install vite preview --mode production-smoke --host 127.0.0.1 --port 4174 --strictPort >/tmp/m5auth-production-smoke-vite.log 2>&1 & + vite_pid=$! + trap 'kill "$vite_pid" 2>/dev/null || true' EXIT + + for _ in {1..30}; do + if curl -fsS "$smoke_url" >/dev/null; then + break + fi + sleep 1 + done + curl -fsS "$smoke_url" >/dev/null + + google-chrome \ + --headless=new \ + --no-sandbox \ + --disable-gpu \ + --virtual-time-budget=120000 \ + --dump-dom \ + "$smoke_url" \ + >/tmp/production-site-smoke-dom.html + + if ! grep -q 'data-status="pass"' /tmp/production-site-smoke-dom.html \ + || ! grep -q 'data-stage="complete"' /tmp/production-site-smoke-dom.html \ + || ! grep -q 'PRODUCTION_SITE_SMOKE_PASS' /tmp/production-site-smoke-dom.html; then + cat /tmp/production-site-smoke-dom.html + cat /tmp/m5auth-production-smoke-vite.log + exit 1 + fi + - name: Build Web App working-directory: web run: npm run build @@ -80,6 +162,8 @@ jobs: web-qr-windows: name: web QR Windows Chrome + needs: classify + if: needs.classify.outputs.web == 'true' runs-on: windows-latest timeout-minutes: 10 steps: @@ -154,6 +238,8 @@ jobs: firmware: name: firmware + needs: classify + if: needs.classify.outputs.firmware == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.github/workflows/issue117-screen-snapshot.yml b/.github/workflows/issue117-screen-snapshot.yml index 4a4f019..a32f0ce 100644 --- a/.github/workflows/issue117-screen-snapshot.yml +++ b/.github/workflows/issue117-screen-snapshot.yml @@ -13,21 +13,47 @@ on: - "tests/screen_snapshot_*" - "tests/esp_idf_image_pin_contract_test.py" - "scripts/esp_idf_build_image.py" + - "scripts/ci_change_impact.py" - "tools/diagnostics/screen_snapshot.py" - "scripts/windows/**" - "docs/testing/screen-snapshot-diagnostics.md" + - "docs/testing/screen-snapshot-usb-tx-investigation.md" - "AGENTS.md" - ".github/workflows/issue117-screen-snapshot.yml" - - ".github/workflows/security.yml" permissions: contents: read jobs: - test-screen-snapshot-profile: - name: test-only screen snapshot profile + classify: + name: classify snapshot impact runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 + outputs: + snapshot_contract: ${{ steps.impact.outputs.snapshot_contract }} + snapshot_build: ${{ steps.impact.outputs.snapshot_build }} + uncertain: ${{ steps.impact.outputs.uncertain }} + steps: + - name: Checkout complete history for deterministic diff + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Classify snapshot impact fail-safe + id: impact + run: >- + python3 scripts/ci_change_impact.py + --event-file "$GITHUB_EVENT_PATH" + --event-name "$GITHUB_EVENT_NAME" + --github-output "$GITHUB_OUTPUT" + + snapshot-contract: + name: snapshot contract + needs: classify + if: needs.classify.outputs.snapshot_contract == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -48,6 +74,18 @@ jobs: tests/screen_snapshot_tx_atomicity_test.py tests/screen_snapshot_usage_contract_test.py tests/screen_snapshot_windows_scripts_contract_test.py + + snapshot-build: + name: test-only screen snapshot profile + needs: classify + if: needs.classify.outputs.snapshot_build == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Verify immutable ESP-IDF build image contract run: python3 -m unittest tests/esp_idf_image_pin_contract_test.py - name: Build diagnostics-ON profile with ESP-IDF 5.5.5 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 76dda4b..cf3709f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,11 +2,18 @@ name: Pages on: push: - branches: - - main tags: - 'v*.*.*' workflow_dispatch: + inputs: + candidate_sha: + description: "PRE-RELEASE CANDIDATE: exact 40-character current main SHA to deploy" + required: true + type: string + candidate_ack: + description: "I understand this pre-release candidate overwrites the public Pages site" + required: true + type: boolean permissions: contents: read @@ -28,6 +35,48 @@ jobs: with: persist-credentials: false fetch-depth: 0 + - name: Authorize exact-main manual candidate deployment + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + CANDIDATE_ACK: ${{ inputs.candidate_ack }} + run: | + set -euo pipefail + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Manual Pages candidate must be invoked from refs/heads/main." + exit 1 + fi + if [ "$CANDIDATE_ACK" != "true" ]; then + echo "Manual Pages candidate requires explicit pre-release acknowledgement." + exit 1 + fi + if ! [[ "$CANDIDATE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "candidate_sha must be an exact 40-character commit SHA." + exit 1 + fi + + git fetch --force --no-tags origin main + MAIN_SHA="$(git rev-parse refs/remotes/origin/main)" + if [ "$GITHUB_SHA" != "$MAIN_SHA" ]; then + echo "Workflow source SHA $GITHUB_SHA is not fresh current main $MAIN_SHA." + exit 1 + fi + if [ "${CANDIDATE_SHA,,}" != "${MAIN_SHA,,}" ]; then + echo "candidate_sha $CANDIDATE_SHA does not equal fresh current main $MAIN_SHA." + 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" + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -64,7 +113,8 @@ jobs: exit 1 fi EXACT_RELEASE=false - if git tag --points-at "$GITHUB_SHA" --list "$EXPECTED_TAG" | grep -Fxq "$EXPECTED_TAG"; then + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] \ + && git tag --points-at "$GITHUB_SHA" --list "$EXPECTED_TAG" | grep -Fxq "$EXPECTED_TAG"; then EXACT_RELEASE=true fi { @@ -73,6 +123,16 @@ jobs: echo "VITE_M5AUTH_WEB_EXACT_RELEASE=$EXACT_RELEASE" echo "M5AUTH_EXACT_RELEASE=$EXACT_RELEASE" } >> "$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" + fi - name: Install exact Web dependencies working-directory: web run: npm ci --ignore-scripts --no-audit --no-fund diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6372ed3..733ac5f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -25,6 +25,8 @@ jobs: run: python3 -m unittest tests/esp_idf_image_pin_contract_test.py - 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 - 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/docs/CI.md b/docs/CI.md new file mode 100644 index 0000000..a7b0933 --- /dev/null +++ b/docs/CI.md @@ -0,0 +1,136 @@ +# CI and deployment routing + +This document describes the repository-owned CI impact model used by GitHub Actions. + +## Required security invariant + +The protected `main` branch requires the GitHub Actions check context: + +`security:scan` + +`.github/workflows/security.yml` runs for every pull request and every push to `main`. It has no path filter and the `security:scan` job is not conditionally skipped. The workflow keeps the full security, release authorization, attestation, supply-chain, and repository-security contract suite. + +The required security check also runs the CI-routing regression tests so a routing change cannot silently remove this invariant. + +## Change-impact categories + +`scripts/ci_change_impact.py` is the canonical classifier. It emits: + +- `web` +- `firmware` +- `pages` +- `security_release_shared` +- `snapshot_contract` +- `snapshot_build` +- `process_docs_only` +- `uncertain` + +The classifier consumes the complete Git changed-path set. + +For pull requests it uses the pull request base SHA through head SHA. For pushes to `main` it uses the event `before` through `after` SHA. Renames include both the old and new paths, and deletes are included. + +### Fail-safe behavior + +Unknown or unclassified paths are treated as shared impact and run the normal heavy Web and Firmware validation. + +The classifier also fails safe when it cannot establish a trustworthy diff, including: + +- missing or unavailable base/head commits; +- an all-zero push base; +- malformed event SHAs; +- insufficient checkout history; +- Git diff failure; +- an empty/indeterminate changed-path set. + +In those cases `uncertain=true` and all heavy categories are enabled. CI uncertainty must increase validation, never skip it. + +## Foundation routing + +`.github/workflows/foundation.yml` still starts on every pull request and every push to `main`. + +Its lightweight `classify` job always runs and performs the complete-history diff. Heavy jobs are conditional only after classification: + +| Impact | Linux Web | Windows Chrome QR/Argon2 | Firmware | +| --- | --- | --- | --- | +| Process/docs only | skip | skip | skip | +| Web | run | run | skip | +| Firmware | skip | skip | run | +| Shared/unknown | run | run | run | + +Changes to Foundation/Security/Pages/release workflow routing, release/package/supply-chain inputs, or the classifier itself are Shared and therefore exercise all heavy jobs. + +## Local production-equivalent Web smoke + +Web and Shared changes run a production-equivalent browser smoke in Foundation without requiring an ESP-IDF build. + +The smoke: + +- performs a real Vite production build with base `/m5authenticator/`; +- keeps the real Provisioner, Firmware Flash, and Help HTML/CSP; +- enables the normal Firmware Flash surface; +- emits only synthetic, non-secret firmware manifests and tiny fixture binaries into the dedicated `production-smoke` output; +- serves that output from a local static Vite preview server; +- loads the real Provisioner, Firmware Flash, and Help routes in Chrome; +- verifies the Firmware page can load and validate the same-origin target/manifests; +- fetches every synthetic firmware part same-origin. + +The synthetic fixture plugin is enabled only for test build modes. A normal production Pages build does not emit those fixture assets. + +The existing QR and Argon2 production-bundle coverage remains independent and still runs for Web/Shared changes. + +## Pages cadence + +`.github/workflows/pages.yml` does not deploy on ordinary `main` pushes. + +Automatic production deployment occurs only for protected SemVer tag pushes matching `v*.*.*`. + +A manual `workflow_dispatch` path exists only for the explicit pre-release candidate gate. It requires: + +- invocation from `refs/heads/main`; +- explicit candidate acknowledgement; +- an exact 40-character `candidate_sha`; +- freshly fetched `origin/main` equal to the workflow source SHA; +- `candidate_sha` equal to that same current-main SHA. + +A candidate run writes the exact source/build identity to the run summary and forces `exact_release=false`. A manual candidate is a mutable public Pages deployment, not an immutable GitHub Release. + +Tag and manual candidate runs preserve the production build/deployment path: + +- release-profile validation; +- immutable ESP-IDF image identity; +- isolated firmware build; +- firmware image/package validation; +- same-origin firmware assets; +- production Web build; +- one Pages staging artifact with one-day retention; +- exact artifact-ID deletion after deployment. + +Because the manual candidate overwrites the public Pages site, it is used only when an approved release gate explicitly calls for hosted production validation. + +## Screen snapshot diagnostics + +The Issue117 workflow separates lightweight contract validation from diagnostics firmware build work. + +Changes to snapshot procedure documentation, `AGENTS.md`, Windows diagnostic scripts, the host diagnostics helper, or snapshot contract tests run the lightweight snapshot contract job only. + +The ESP-IDF diagnostics build runs only for true diagnostics firmware/build dependencies such as the relevant firmware profile, CMake/sdkconfig/device/time/session/vault-runtime inputs, ESP-IDF image pinning, or the snapshot workflow build semantics. + +Docs-only or AGENTS-only changes therefore do not build diagnostics firmware. + +## Authorized Release artifact exception + +`.github/workflows/release-authorized.yml` remains outside the routine change-impact optimization boundary. + +Its bounded artifact handoffs are intentional release-security controls and remain unchanged: + +- raw build artifact; +- independently verified package artifact; +- exact artifact IDs; +- checksum verification; +- attestation before publish; +- one-day retention; +- exact-ID cleanup; +- separated publisher permissions; +- retired legacy Release tombstone. + +Do not add Pages or OIDC deployment authority to the Authorized Release publisher. diff --git a/scripts/ci_change_impact.py b/scripts/ci_change_impact.py new file mode 100644 index 0000000..1829724 --- /dev/null +++ b/scripts/ci_change_impact.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Deterministic, fail-safe CI change-impact classification.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from typing import Iterable + +ZERO_SHA = "0" * 40 +OUTPUT_KEYS = ( + "web", + "firmware", + "pages", + "security_release_shared", + "snapshot_contract", + "snapshot_build", + "process_docs_only", + "uncertain", +) + +PROCESS_DOC_EXACT = { + "README.md", + "README.ja.md", + "AGENTS.md", +} +PROCESS_DOC_PREFIXES = ( + "docs/", + ".agent/", + ".agents/", + "agent/", +) + +SHARED_EXACT = { + ".github/workflows/foundation.yml", + ".github/workflows/security.yml", + ".github/workflows/pages.yml", + ".github/workflows/release-authorized.yml", + ".github/workflows/release.yml", + "firmware/release-profile.json", + "scripts/ci_change_impact.py", + "tests/ci_change_impact_test.py", +} + +SHARED_SCRIPT_EXACT = { + "scripts/package_firmware.py", + "scripts/validate_release.py", + "scripts/release_attestation.py", + "scripts/release_authorization.py", + "scripts/security_scan.py", + "scripts/ci_firmware_handoff.py", + "scripts/ci_esp_idf_isolated_build.py", + "scripts/esp_idf_build_image.py", + "scripts/verify_firmware_image.py", +} + +SNAPSHOT_CONTRACT_EXACT = { + "AGENTS.md", + "tools/diagnostics/screen_snapshot.py", +} +SNAPSHOT_CONTRACT_PREFIXES = ( + "scripts/windows/", + "docs/testing/screen-snapshot", + "tests/screen_snapshot_", +) +SNAPSHOT_BUILD_EXACT = { + "scripts/ci_change_impact.py", + "firmware/CMakeLists.txt", + "firmware/sdkconfig.defaults", + "scripts/esp_idf_build_image.py", + "tests/esp_idf_image_pin_contract_test.py", + ".github/workflows/issue117-screen-snapshot.yml", +} +SNAPSHOT_BUILD_PREFIXES = ( + "firmware/main/", + "firmware/components/m5auth_device_sticks3/", + "firmware/components/m5auth_time/", + "firmware/components/m5auth_session/", + "firmware/components/m5auth_vault_runtime/", +) + +VERSIONED_SHA = re.compile(r"^[0-9a-fA-F]{40}$") + + +class ImpactError(RuntimeError): + """Raised when the changed-path set cannot be established safely.""" + + +def _normalize_path(path: str) -> str: + normalized = path.strip().replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + +def _is_process_doc(path: str) -> bool: + return path in PROCESS_DOC_EXACT or path.startswith(PROCESS_DOC_PREFIXES) + + +def _is_snapshot_contract(path: str) -> bool: + return path in SNAPSHOT_CONTRACT_EXACT or path.startswith(SNAPSHOT_CONTRACT_PREFIXES) + + +def _is_snapshot_build(path: str) -> bool: + return path in SNAPSHOT_BUILD_EXACT or path.startswith(SNAPSHOT_BUILD_PREFIXES) + + +def _new_result() -> dict[str, bool]: + return {key: False for key in OUTPUT_KEYS} + + +def all_heavy_result(*, uncertain: bool) -> dict[str, bool]: + result = _new_result() + result.update( + { + "web": True, + "firmware": True, + "pages": True, + "security_release_shared": True, + "snapshot_contract": True, + "snapshot_build": True, + "process_docs_only": False, + "uncertain": uncertain, + } + ) + return result + + +def classify_paths(paths: Iterable[str]) -> dict[str, bool]: + normalized = sorted({_normalize_path(path) for path in paths if _normalize_path(path)}) + if not normalized: + return all_heavy_result(uncertain=True) + + result = _new_result() + result["process_docs_only"] = all(_is_process_doc(path) for path in normalized) + + for path in normalized: + snapshot_contract = _is_snapshot_contract(path) + snapshot_build = _is_snapshot_build(path) + if snapshot_contract: + result["snapshot_contract"] = True + if snapshot_build: + result["snapshot_contract"] = True + result["snapshot_build"] = True + + if path == ".github/workflows/issue117-screen-snapshot.yml": + result["security_release_shared"] = True + result["web"] = True + result["firmware"] = True + result["pages"] = True + continue + + if path in SHARED_EXACT or path in SHARED_SCRIPT_EXACT: + result["security_release_shared"] = True + result["web"] = True + result["firmware"] = True + result["pages"] = True + continue + + if _is_process_doc(path): + continue + + if path.startswith("web/"): + result["web"] = True + result["pages"] = True + continue + + if path.startswith("firmware/"): + result["firmware"] = True + continue + + if path == "tools/diagnostics/screen_snapshot.py": + continue + + if path.startswith("tests/"): + if path.endswith((".cpp", ".cc", ".cxx")): + result["firmware"] = True + continue + if snapshot_build: + result["firmware"] = True + continue + if snapshot_contract: + continue + result["security_release_shared"] = True + result["web"] = True + result["firmware"] = True + result["pages"] = True + continue + + if path.startswith("scripts/"): + if snapshot_contract: + continue + result["security_release_shared"] = True + result["web"] = True + result["firmware"] = True + result["pages"] = True + continue + + # Unknown/unclassified path is deliberately fail-safe. + result["security_release_shared"] = True + result["web"] = True + result["firmware"] = True + result["pages"] = True + + return result + + +def resolve_event_range(event_name: str, event: dict[str, object]) -> tuple[str, str]: + if event_name == "pull_request": + pull_request = event.get("pull_request") + if not isinstance(pull_request, dict): + raise ImpactError("pull_request payload is missing") + base = pull_request.get("base") + head = pull_request.get("head") + if not isinstance(base, dict) or not isinstance(head, dict): + raise ImpactError("pull_request base/head payload is missing") + base_sha = base.get("sha") + head_sha = head.get("sha") + elif event_name == "push": + base_sha = event.get("before") + head_sha = event.get("after") + else: + raise ImpactError(f"unsupported CI event: {event_name!r}") + + if not isinstance(base_sha, str) or not isinstance(head_sha, str): + raise ImpactError("event base/head SHA is unavailable") + if base_sha == ZERO_SHA: + raise ImpactError("all-zero push base is not safe to classify") + if not VERSIONED_SHA.fullmatch(base_sha) or not VERSIONED_SHA.fullmatch(head_sha): + raise ImpactError("event base/head SHA is malformed") + return base_sha.lower(), head_sha.lower() + + +def _parse_name_status_z(payload: bytes) -> list[str]: + fields = payload.decode("utf-8", errors="strict").split("\0") + paths: list[str] = [] + index = 0 + while index < len(fields): + token = fields[index] + index += 1 + if not token: + continue + + if "\t" in token: + status, first_path = token.split("\t", 1) + else: + status = token + if index >= len(fields): + raise ImpactError("truncated git diff name-status output") + first_path = fields[index] + index += 1 + + if not status or status[0] not in "ACDMRTUXB": + raise ImpactError(f"unexpected git diff status: {status!r}") + paths.append(first_path) + + if status[0] in "RC": + if index >= len(fields): + raise ImpactError("truncated rename/copy path in git diff output") + paths.append(fields[index]) + index += 1 + + return [_normalize_path(path) for path in paths if _normalize_path(path)] + + +def changed_paths(base_sha: str, head_sha: str, *, repo_root: Path = Path(".")) -> list[str]: + for sha in (base_sha, head_sha): + probe = subprocess.run( + ["git", "cat-file", "-e", f"{sha}^{{commit}}"], + cwd=repo_root, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if probe.returncode != 0: + raise ImpactError(f"required commit {sha} is unavailable in checkout history") + + completed = subprocess.run( + [ + "git", + "diff", + "--name-status", + "-z", + "--find-renames", + base_sha, + head_sha, + "--", + ], + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + raise ImpactError("git diff failed while computing changed paths") + + paths = _parse_name_status_z(completed.stdout) + if not paths: + raise ImpactError("changed-path set is empty") + return paths + + +def classify_event( + event_name: str, + event: dict[str, object], + *, + repo_root: Path = Path("."), +) -> tuple[dict[str, bool], list[str], str | None]: + try: + base_sha, head_sha = resolve_event_range(event_name, event) + paths = changed_paths(base_sha, head_sha, repo_root=repo_root) + return classify_paths(paths), paths, None + except (ImpactError, OSError, UnicodeError, json.JSONDecodeError) as error: + return all_heavy_result(uncertain=True), [], str(error) + + +def write_github_output(path: Path, result: dict[str, bool], paths: list[str], reason: str | None) -> None: + with path.open("a", encoding="utf-8") as output: + for key in OUTPUT_KEYS: + output.write(f"{key}={'true' if result[key] else 'false'}\n") + output.write(f"changed_paths_json={json.dumps(paths, separators=(',', ':'))}\n") + output.write(f"uncertainty_reason_json={json.dumps(reason or '', separators=(',', ':'))}\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--event-file", type=Path) + parser.add_argument("--event-name") + parser.add_argument("--github-output", type=Path) + parser.add_argument("--path", action="append", default=[]) + args = parser.parse_args(argv) + + if args.path: + paths = [_normalize_path(path) for path in args.path] + result = classify_paths(paths) + reason = None + else: + event_path = args.event_file or (Path(os.environ["GITHUB_EVENT_PATH"]) if os.environ.get("GITHUB_EVENT_PATH") else None) + event_name = args.event_name or os.environ.get("GITHUB_EVENT_NAME") + if event_path is None or not event_name: + result = all_heavy_result(uncertain=True) + paths = [] + reason = "GitHub event metadata is unavailable" + else: + try: + event = json.loads(event_path.read_text(encoding="utf-8")) + if not isinstance(event, dict): + raise ImpactError("GitHub event payload is not an object") + result, paths, reason = classify_event(event_name, event) + except (OSError, UnicodeError, json.JSONDecodeError, ImpactError) as error: + result = all_heavy_result(uncertain=True) + paths = [] + reason = str(error) + + output_path = args.github_output or (Path(os.environ["GITHUB_OUTPUT"]) if os.environ.get("GITHUB_OUTPUT") else None) + if output_path is not None: + write_github_output(output_path, result, paths, reason) + + print(json.dumps({"impact": result, "paths": paths, "uncertainty": reason}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/ci_change_impact_test.py b/tests/ci_change_impact_test.py new file mode 100644 index 0000000..e337b67 --- /dev/null +++ b/tests/ci_change_impact_test.py @@ -0,0 +1,225 @@ +import json +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_change_impact + + +class ChangeImpactClassificationTests(unittest.TestCase): + def assert_heavy(self, result, *, web, firmware): + self.assertEqual(result["web"], web) + self.assertEqual(result["firmware"], firmware) + self.assertEqual(result["uncertain"], False) + + def test_regression_matrix(self) -> None: + cases = ( + (["README.md"], False, False, True), + (["README.md", "docs/assets/hero.jpg"], False, False, True), + ([".agent/BOOTSTRAP.md", "agent/WORK-TRACKING.md"], False, False, True), + (["docs/ARCHITECTURE.md"], False, False, True), + (["web/src/main.ts"], True, False, False), + (["firmware/main/main.cpp"], False, True, False), + (["scripts/package_firmware.py"], True, True, False), + ([".github/workflows/foundation.yml"], True, True, False), + (["brand-new-top-level.file"], True, True, False), + ) + for paths, web, firmware, process_docs_only in cases: + with self.subTest(paths=paths): + result = ci_change_impact.classify_paths(paths) + self.assert_heavy(result, web=web, firmware=firmware) + self.assertEqual(result["process_docs_only"], process_docs_only) + + def test_web_and_shared_paths_drive_pages_semantics(self) -> None: + web = ci_change_impact.classify_paths(["web/src/main.ts"]) + self.assertTrue(web["web"]) + self.assertTrue(web["pages"]) + self.assertFalse(web["firmware"]) + + shared = ci_change_impact.classify_paths(["scripts/validate_release.py"]) + self.assertTrue(shared["web"]) + self.assertTrue(shared["firmware"]) + self.assertTrue(shared["pages"]) + self.assertTrue(shared["security_release_shared"]) + + def test_snapshot_docs_and_agents_are_lightweight_only(self) -> None: + for path in ( + "AGENTS.md", + "docs/testing/screen-snapshot-diagnostics.md", + "scripts/windows/build-screen-snapshot.cmd", + "tools/diagnostics/screen_snapshot.py", + "tests/screen_snapshot_usage_contract_test.py", + ): + with self.subTest(path=path): + result = ci_change_impact.classify_paths([path]) + self.assertTrue(result["snapshot_contract"]) + self.assertFalse(result["snapshot_build"]) + self.assertFalse(result["web"]) + self.assertFalse(result["firmware"]) + + def test_snapshot_firmware_inputs_request_contract_and_build(self) -> None: + for path in ( + "firmware/CMakeLists.txt", + "firmware/sdkconfig.defaults", + "firmware/main/main.cpp", + "firmware/components/m5auth_device_sticks3/ui_model.cpp", + "firmware/components/m5auth_time/trusted_time.cpp", + "firmware/components/m5auth_session/session_crypto.cpp", + "firmware/components/m5auth_vault_runtime/runtime.cpp", + "scripts/esp_idf_build_image.py", + "tests/esp_idf_image_pin_contract_test.py", + ".github/workflows/issue117-screen-snapshot.yml", + ): + with self.subTest(path=path): + result = ci_change_impact.classify_paths([path]) + self.assertTrue(result["snapshot_contract"]) + self.assertTrue(result["snapshot_build"]) + + def test_classifier_change_exercises_snapshot_build_semantics(self) -> None: + result = ci_change_impact.classify_paths(["scripts/ci_change_impact.py"]) + self.assertTrue(result["snapshot_contract"]) + self.assertTrue(result["snapshot_build"]) + self.assertTrue(result["web"]) + self.assertTrue(result["firmware"]) + self.assertTrue(result["security_release_shared"]) + + def test_unknown_and_empty_inputs_fail_safe(self) -> None: + unknown = ci_change_impact.classify_paths(["unknown/new-surface.xyz"]) + self.assertTrue(unknown["web"]) + self.assertTrue(unknown["firmware"]) + self.assertTrue(unknown["security_release_shared"]) + + empty = ci_change_impact.classify_paths([]) + self.assertTrue(empty["uncertain"]) + self.assertTrue(empty["web"]) + self.assertTrue(empty["firmware"]) + self.assertTrue(empty["snapshot_build"]) + + def test_event_range_uses_pr_base_head_and_push_before_after(self) -> None: + base = "a" * 40 + head = "b" * 40 + self.assertEqual( + ci_change_impact.resolve_event_range( + "pull_request", + {"pull_request": {"base": {"sha": base}, "head": {"sha": head}}}, + ), + (base, head), + ) + self.assertEqual( + ci_change_impact.resolve_event_range("push", {"before": base, "after": head}), + (base, head), + ) + + def test_all_zero_push_base_fails_safe(self) -> None: + with self.assertRaises(ci_change_impact.ImpactError): + ci_change_impact.resolve_event_range( + "push", + {"before": ci_change_impact.ZERO_SHA, "after": "b" * 40}, + ) + + def test_rename_and_delete_changed_paths_include_old_and_new_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "ci@example.invalid"], cwd=root, check=True) + subprocess.run(["git", "config", "user.name", "CI"], cwd=root, check=True) + (root / "firmware").mkdir() + (root / "firmware" / "old.cpp").write_text("old\n", encoding="utf-8") + (root / "README.md").write_text("docs\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run(["git", "commit", "-qm", "base"], cwd=root, check=True) + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + + (root / "web").mkdir() + subprocess.run(["git", "mv", "firmware/old.cpp", "web/new.ts"], cwd=root, check=True) + (root / "README.md").unlink() + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + subprocess.run(["git", "commit", "-qm", "change"], cwd=root, check=True) + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + + paths = ci_change_impact.changed_paths(base, head, repo_root=root) + self.assertIn("firmware/old.cpp", paths) + self.assertIn("web/new.ts", paths) + self.assertIn("README.md", paths) + + def test_unavailable_diff_is_all_heavy_uncertain(self) -> None: + result, paths, reason = ci_change_impact.classify_event( + "push", + {"before": "a" * 40, "after": "b" * 40}, + repo_root=Path("/definitely/missing/repository"), + ) + self.assertTrue(result["uncertain"]) + self.assertTrue(result["web"]) + self.assertTrue(result["firmware"]) + self.assertTrue(result["snapshot_build"]) + self.assertEqual(paths, []) + self.assertIsInstance(reason, str) + + +class ChangeImpactWorkflowContractTests(unittest.TestCase): + FOUNDATION = REPO_ROOT / ".github" / "workflows" / "foundation.yml" + SECURITY = REPO_ROOT / ".github" / "workflows" / "security.yml" + PAGES = REPO_ROOT / ".github" / "workflows" / "pages.yml" + SNAPSHOT = REPO_ROOT / ".github" / "workflows" / "issue117-screen-snapshot.yml" + RELEASE = REPO_ROOT / ".github" / "workflows" / "release-authorized.yml" + + def test_required_security_context_remains_unfiltered(self) -> None: + text = self.SECURITY.read_text(encoding="utf-8") + header = text[: text.index("permissions:")] + self.assertIn("pull_request:", header) + self.assertIn("branches:\n - main", header) + self.assertNotIn("paths:", header) + self.assertIn("name: security:scan", text) + security_job = text[text.index("security-scan:") :] + self.assertNotIn("\n if:", security_job) + + def test_foundation_uses_always_run_classifier_and_conditions_heavy_jobs(self) -> None: + text = self.FOUNDATION.read_text(encoding="utf-8") + self.assertIn("classify:", text) + self.assertIn("scripts/ci_change_impact.py", text) + self.assertIn("fetch-depth: 0", text) + self.assertIn("needs: classify", text) + self.assertIn("needs.classify.outputs.web == 'true'", text) + self.assertIn("needs.classify.outputs.firmware == 'true'", text) + + def test_pages_has_no_main_push_and_retains_tag_manual_cleanup(self) -> None: + text = self.PAGES.read_text(encoding="utf-8") + header = text[: text.index("permissions:")] + self.assertIn("tags:", header) + self.assertIn("'v*.*.*'", header) + self.assertNotIn("branches:", header) + self.assertIn("workflow_dispatch:", header) + self.assertIn("candidate_sha:", header) + self.assertIn("candidate_ack:", header) + self.assertIn("refs/heads/main", text) + self.assertIn("retention-days: 1", text) + self.assertIn("actions/artifacts/$PAGES_ARTIFACT_ID", text) + self.assertIn("GITHUB_STEP_SUMMARY", text) + + def test_release_authorized_artifact_boundary_is_unchanged(self) -> None: + text = self.RELEASE.read_text(encoding="utf-8") + self.assertIn("repository_dispatch:", text) + self.assertIn("publish_semver_release", text) + self.assertIn("retention-days: 1", text) + self.assertIn("actions: write", text) + self.assertNotIn("pages: write", text) + + def test_snapshot_workflow_splits_contract_and_build_jobs(self) -> None: + text = self.SNAPSHOT.read_text(encoding="utf-8") + header = text[: text.index("permissions:")] + self.assertIn(' - "scripts/ci_change_impact.py"', header) + self.assertIn("snapshot-contract:", text) + self.assertIn("snapshot-build:", text) + self.assertIn("needs.classify.outputs.snapshot_contract == 'true'", text) + self.assertIn("needs.classify.outputs.snapshot_build == 'true'", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/tests/browser/production-site-smoke.html b/web/tests/browser/production-site-smoke.html new file mode 100644 index 0000000..6f17b55 --- /dev/null +++ b/web/tests/browser/production-site-smoke.html @@ -0,0 +1,15 @@ + + +
+ + + +