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)