From 89e5dfcae8bdd1dc7414b3e161bdce33d61fca53 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:30:22 +0900 Subject: [PATCH 01/14] ci(infra): add fail-safe change impact classifier (#216) --- scripts/ci_change_impact.py | 362 ++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 scripts/ci_change_impact.py diff --git a/scripts/ci_change_impact.py b/scripts/ci_change_impact.py new file mode 100644 index 0000000..3df7901 --- /dev/null +++ b/scripts/ci_change_impact.py @@ -0,0 +1,362 @@ +#!/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 = { + "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.startswith("tests/"): + if path.endswith((".cpp", ".cc", ".cxx")): + 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()) From 9a560ce219834ea4b839cad4ed4cc119063a9499 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:30:24 +0900 Subject: [PATCH 02/14] test(infra): pin CI impact routing contract (#216) --- tests/ci_change_impact_test.py | 215 +++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/ci_change_impact_test.py diff --git a/tests/ci_change_impact_test.py b/tests/ci_change_impact_test.py new file mode 100644 index 0000000..570a099 --- /dev/null +++ b/tests/ci_change_impact_test.py @@ -0,0 +1,215 @@ +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_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") + 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() From a7afc18273a1098b9a0c6499d39fb3c7a635ed04 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:31:00 +0900 Subject: [PATCH 03/14] test(web): add production route smoke page (#216) --- web/tests/browser/production-site-smoke.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 web/tests/browser/production-site-smoke.html 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 @@ + + + + + + + M5Authenticator — Production Site Smoke + + + + + From 4fe38482a69807bd22b4114a5ee8f8197c0b1f85 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:31:03 +0900 Subject: [PATCH 04/14] test(web): validate production routes and firmware assets (#216) --- web/tests/browser/production-site-smoke.ts | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 web/tests/browser/production-site-smoke.ts diff --git a/web/tests/browser/production-site-smoke.ts b/web/tests/browser/production-site-smoke.ts new file mode 100644 index 0000000..94ad3e5 --- /dev/null +++ b/web/tests/browser/production-site-smoke.ts @@ -0,0 +1,147 @@ +interface FirmwareTargetFixture { + readonly name: string; + readonly version: string; + readonly build_commit: string; + readonly exact_release: boolean; + readonly factory_manifest: string; + readonly update_manifest: string; +} + +interface FirmwareManifestFixture { + readonly name: string; + readonly version: string; + readonly build_commit: string; + readonly exact_release: boolean; + readonly builds: readonly { + readonly chipFamily: string; + readonly parts: readonly { readonly path: string; readonly offset: number }[]; + }[]; +} + +const base = import.meta.env.BASE_URL; +const expectedOrigin = window.location.origin; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function waitFor( + predicate: () => boolean, + message: string, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message); +} + +async function loadFrame(path: string, id: string): Promise { + const frame = document.createElement("iframe"); + frame.id = id; + frame.src = new URL(path, window.location.href).toString(); + frame.hidden = true; + document.body.append(frame); + + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error(`Timed out loading ${path}`)), 15_000); + frame.addEventListener( + "load", + () => { + window.clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + assert(frame.contentWindow?.location.origin === expectedOrigin, `${path} did not load same-origin`); + assert(frame.contentDocument, `${path} document is unavailable`); + return frame; +} + +function assertProductionCsp(frame: HTMLIFrameElement, route: string): void { + const meta = frame.contentDocument?.querySelector( + 'meta[http-equiv="Content-Security-Policy"]', + ); + assert(meta, `${route} is missing production CSP`); + const csp = meta.content; + assert(csp.includes("default-src 'self'"), `${route} CSP default-src is not production-safe`); + assert(csp.includes("object-src 'none'"), `${route} CSP object-src is not production-safe`); + assert(csp.includes("base-uri 'self'"), `${route} CSP base-uri is not production-safe`); +} + +async function loadJson(url: URL): Promise { + assert(url.origin === expectedOrigin, `${url.pathname} escaped same origin`); + const response = await fetch(url, { cache: "no-store" }); + assert(response.ok, `${url.pathname} returned ${response.status}`); + return await response.json() as T; +} + +async function verifyFirmwareFixture(): Promise { + const targetUrl = new URL(`${base}firmware/firmware-target.json`, window.location.href); + const target = await loadJson(targetUrl); + assert(target.name === "M5Authenticator", "firmware target name mismatch"); + assert(target.exact_release === false, "production smoke fixture must remain non-exact"); + + for (const manifestName of [target.factory_manifest, target.update_manifest]) { + const manifestUrl = new URL(manifestName, targetUrl); + const manifest = await loadJson(manifestUrl); + assert(manifest.build_commit === target.build_commit, "firmware manifest commit mismatch"); + assert(manifest.version === target.version, "firmware manifest version mismatch"); + + for (const build of manifest.builds) { + assert(build.chipFamily === "ESP32-S3", "unexpected firmware chip family"); + for (const part of build.parts) { + const partUrl = new URL(part.path, manifestUrl); + assert(partUrl.origin === expectedOrigin, "firmware part escaped same origin"); + const response = await fetch(partUrl, { cache: "no-store" }); + assert(response.ok, `${partUrl.pathname} returned ${response.status}`); + assert((await response.arrayBuffer()).byteLength > 0, "synthetic firmware part is empty"); + } + } + } +} + +async function run(): Promise { + document.body.dataset.stage = "routes"; + + const provisioner = await loadFrame(base, "production-provisioner"); + const firmware = await loadFrame(`${base}flash.html`, "production-firmware"); + const help = await loadFrame(`${base}help.html`, "production-help"); + + assertProductionCsp(provisioner, "Provisioner"); + assertProductionCsp(firmware, "Firmware"); + assertProductionCsp(help, "Help"); + + await waitFor( + () => Boolean(provisioner.contentDocument?.querySelector("#app > .shell")), + "Provisioner production route did not initialize", + ); + await waitFor( + () => Boolean(help.contentDocument?.querySelector("#help-app")), + "Help production route did not initialize", + ); + await waitFor( + () => (firmware.contentDocument?.querySelectorAll("#flash-status button").length ?? 0) >= 2, + "Firmware production route did not expose enabled flash actions", + ); + + const firmwareText = firmware.contentDocument?.querySelector("#flash-status")?.textContent ?? ""; + assert(!firmwareText.includes("Production flashing is not enabled yet"), "Firmware Flash surface is disabled"); + assert(!firmwareText.includes("Firmware target unavailable"), "Firmware target fixture did not validate"); + + document.body.dataset.stage = "firmware-assets"; + await verifyFirmwareFixture(); + + document.body.dataset.status = "pass"; + document.body.dataset.stage = "complete"; + document.body.textContent = "PRODUCTION_SITE_SMOKE_PASS"; +} + +void run().catch((error) => { + document.body.dataset.status = "fail"; + document.body.dataset.stage = "failed"; + document.body.textContent = error instanceof Error ? `PRODUCTION_SITE_SMOKE_FAIL: ${error.message}` : "PRODUCTION_SITE_SMOKE_FAIL"; +}); From abd5b8b4945efcd51ec8c9a599f0507026ac117b Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:31:37 +0900 Subject: [PATCH 05/14] test(web): add isolated production-site smoke build mode (#216) --- web/vite.config.ts | 84 +++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index 5227102..b7f8d69 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,20 +1,41 @@ import { defineConfig, type Plugin } from "vite"; const SMOKE_BUILD_COMMIT = "fedcba9876543210"; +const PRODUCTION_SMOKE_FALLBACK_VERSION = "1.0.0"; +const SYNTHETIC_FIRMWARE_BYTES = "M5AUTHENTICATOR_SYNTHETIC_NON_SECRET_FIRMWARE_FIXTURE\n"; -function firmwareLayoutSmokeFixture(): Plugin { +function firmwareLayoutSmokeFixture( + version = "0.1.0", + buildCommit = SMOKE_BUILD_COMMIT, + emitFirmwareParts = false, +): Plugin { const identity = { name: "M5Authenticator", - version: "0.1.0", - build_commit: SMOKE_BUILD_COMMIT, + version, + build_commit: buildCommit, exact_release: false, } as const; + const factoryPart = `m5authenticator-v${version}-${buildCommit}-m5sticks3.bin`; + const updateParts = [ + { + path: `m5authenticator-v${version}-${buildCommit}-m5sticks3-update-bootloader.bin`, + offset: 0x000000, + }, + { + path: `m5authenticator-v${version}-${buildCommit}-m5sticks3-update-partition-table.bin`, + offset: 0x008000, + }, + { + path: `m5authenticator-v${version}-${buildCommit}-m5sticks3-update-ota0.bin`, + offset: 0x030000, + }, + ] as const; const factoryManifest = { ...identity, builds: [{ chipFamily: "ESP32-S3", parts: [{ - path: `m5authenticator-v0.1.0-${SMOKE_BUILD_COMMIT}-m5sticks3.bin`, + path: factoryPart, offset: 0, }], }], @@ -23,26 +44,13 @@ function firmwareLayoutSmokeFixture(): Plugin { ...identity, builds: [{ chipFamily: "ESP32-S3", - parts: [ - { - path: `m5authenticator-v0.1.0-${SMOKE_BUILD_COMMIT}-m5sticks3-update-bootloader.bin`, - offset: 0x000000, - }, - { - path: `m5authenticator-v0.1.0-${SMOKE_BUILD_COMMIT}-m5sticks3-update-partition-table.bin`, - offset: 0x008000, - }, - { - path: `m5authenticator-v0.1.0-${SMOKE_BUILD_COMMIT}-m5sticks3-update-ota0.bin`, - offset: 0x030000, - }, - ], + parts: updateParts, }], }; const target = { ...identity, - factory_manifest: `factory-manifest-${SMOKE_BUILD_COMMIT}.json`, - update_manifest: `update-manifest-${SMOKE_BUILD_COMMIT}.json`, + factory_manifest: `factory-manifest-${buildCommit}.json`, + update_manifest: `update-manifest-${buildCommit}.json`, }; return { @@ -56,26 +64,54 @@ function firmwareLayoutSmokeFixture(): Plugin { }); }; emitJson("firmware/firmware-target.json", target); - emitJson(`firmware/factory-manifest-${SMOKE_BUILD_COMMIT}.json`, factoryManifest); - emitJson(`firmware/update-manifest-${SMOKE_BUILD_COMMIT}.json`, updateManifest); + emitJson(`firmware/factory-manifest-${buildCommit}.json`, factoryManifest); + emitJson(`firmware/update-manifest-${buildCommit}.json`, updateManifest); + + if (emitFirmwareParts) { + for (const fileName of [factoryPart, ...updateParts.map((part) => part.path)]) { + this.emitFile({ + type: "asset", + fileName: `firmware/${fileName}`, + source: SYNTHETIC_FIRMWARE_BYTES, + }); + } + } }, }; } export default defineConfig(({ mode }) => { const qrSmoke = mode === "qr-smoke"; + const productionSmoke = mode === "production-smoke"; + const productionSmokeVersion = + process.env.VITE_M5AUTH_WEB_VERSION ?? PRODUCTION_SMOKE_FALLBACK_VERSION; + const productionSmokeCommit = + process.env.VITE_M5AUTH_WEB_BUILD_COMMIT ?? SMOKE_BUILD_COMMIT; + + const smokePlugin = qrSmoke + ? firmwareLayoutSmokeFixture() + : productionSmoke + ? firmwareLayoutSmokeFixture(productionSmokeVersion, productionSmokeCommit, true) + : undefined; return { base: "/m5authenticator/", - plugins: qrSmoke ? [firmwareLayoutSmokeFixture()] : [], + plugins: smokePlugin ? [smokePlugin] : [], build: { - outDir: qrSmoke ? "dist-smoke" : "dist", + outDir: qrSmoke + ? "dist-smoke" + : productionSmoke + ? "dist-production-smoke" + : "dist", rollupOptions: { input: { provisioner: "index.html", flasher: "flash.html", help: "help.html", ...(qrSmoke ? { qrSmoke: "tests/browser/qr-smoke.html" } : {}), + ...(productionSmoke + ? { productionSiteSmoke: "tests/browser/production-site-smoke.html" } + : {}), }, }, }, From 9eb1d4554f323557763094dd61744cfde5c04f19 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:32:10 +0900 Subject: [PATCH 06/14] ci(infra): route Foundation heavy jobs by change impact (#216) --- .github/workflows/foundation.yml | 86 ++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) 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: From d321c6df458eefcff32ea04cd3418715f99bd3f9 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:32:33 +0900 Subject: [PATCH 07/14] ci(infra): make Pages release-oriented and exact-main manual (#216) --- .github/workflows/pages.yml | 66 +++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) 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 From 93c5c193a117d98ad3baf96dd4b3e561c3c3cc6b Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:32:53 +0900 Subject: [PATCH 08/14] ci(infra): split snapshot contracts from diagnostics build (#216) --- .../workflows/issue117-screen-snapshot.yml | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue117-screen-snapshot.yml b/.github/workflows/issue117-screen-snapshot.yml index 4a4f019..961c748 100644 --- a/.github/workflows/issue117-screen-snapshot.yml +++ b/.github/workflows/issue117-screen-snapshot.yml @@ -16,18 +16,43 @@ on: - "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 +73,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 From 78fab8139d278bf04353c3bd9c7d5c84ae1829ec Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:34:00 +0900 Subject: [PATCH 09/14] fix(ci): keep snapshot helper light and ESP-IDF pin firmware-bound (#216) --- scripts/ci_change_impact.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/ci_change_impact.py b/scripts/ci_change_impact.py index 3df7901..53ee5c2 100644 --- a/scripts/ci_change_impact.py +++ b/scripts/ci_change_impact.py @@ -173,10 +173,16 @@ def classify_paths(paths: Iterable[str]) -> dict[str, bool]: 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 From 340b32b7fb684d481cab01c1c32edea07782233a Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:34:28 +0900 Subject: [PATCH 10/14] ci(infra): enforce routing contracts in required security check (#216) --- .github/workflows/security.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From c64b5ff836bf7ce4ca6c89f740dc4fdd777cb3e4 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:34:31 +0900 Subject: [PATCH 11/14] docs(infra): document CI impact and Pages routing (#216) --- docs/CI.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/CI.md 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. From 281e69c316af89ccffcdd96d0f490ab93ddd91fe Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:55:50 +0900 Subject: [PATCH 12/14] ci: classify impact router as snapshot build dependency --- scripts/ci_change_impact.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci_change_impact.py b/scripts/ci_change_impact.py index 53ee5c2..1829724 100644 --- a/scripts/ci_change_impact.py +++ b/scripts/ci_change_impact.py @@ -69,6 +69,7 @@ "tests/screen_snapshot_", ) SNAPSHOT_BUILD_EXACT = { + "scripts/ci_change_impact.py", "firmware/CMakeLists.txt", "firmware/sdkconfig.defaults", "scripts/esp_idf_build_image.py", From 5d89bb61e0f96278c22892e3bd8bef30beccbe36 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:55:53 +0900 Subject: [PATCH 13/14] ci: trigger snapshot workflow on classifier changes --- .github/workflows/issue117-screen-snapshot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/issue117-screen-snapshot.yml b/.github/workflows/issue117-screen-snapshot.yml index 961c748..a32f0ce 100644 --- a/.github/workflows/issue117-screen-snapshot.yml +++ b/.github/workflows/issue117-screen-snapshot.yml @@ -13,6 +13,7 @@ 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" From ee1b38d004265d7b325e2e0cd53e20a81d98d82f Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:55:55 +0900 Subject: [PATCH 14/14] test: pin classifier snapshot build routing --- tests/ci_change_impact_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ci_change_impact_test.py b/tests/ci_change_impact_test.py index 570a099..e337b67 100644 --- a/tests/ci_change_impact_test.py +++ b/tests/ci_change_impact_test.py @@ -82,6 +82,14 @@ def test_snapshot_firmware_inputs_request_contract_and_build(self) -> None: 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"]) @@ -205,6 +213,8 @@ def test_release_authorized_artifact_boundary_is_unchanged(self) -> None: 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)