From e051d5d3161f6aa25b4fcb4154447fbf29c5aa4c Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:45:28 +0900 Subject: [PATCH 01/11] release: set CMake product version to 1.0.0 (#214) --- firmware/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index aa5b630..1755b25 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -7,4 +7,4 @@ option( ) include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(m5authenticator VERSION 0.1.0) +project(m5authenticator VERSION 1.0.0) From dceed908125db41d9be8303a906c696a914dca1d Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:45:36 +0900 Subject: [PATCH 02/11] release: set firmware metadata version to 1.0.0 (#214) --- .../components/m5auth_core/include/m5auth/core/metadata.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp b/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp index 1feaaef..02e7d3b 100644 --- a/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp +++ b/firmware/components/m5auth_core/include/m5auth/core/metadata.hpp @@ -2,7 +2,7 @@ namespace m5auth::core { -inline constexpr char kFirmwareVersion[] = "0.1.0"; +inline constexpr char kFirmwareVersion[] = "1.0.0"; inline constexpr int kProtocolVersion = 2; inline constexpr int kStorageSchemaVersion = 2; inline constexpr int kVaultFormatVersion = 1; From d6bfe0123d1c954720c953e8ed625a740e9f2d83 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:45:39 +0900 Subject: [PATCH 03/11] release: set release profile version to 1.0.0 (#214) --- firmware/release-profile.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/release-profile.json b/firmware/release-profile.json index 6aeae96..833279f 100644 --- a/firmware/release-profile.json +++ b/firmware/release-profile.json @@ -3,7 +3,7 @@ "device": "M5StickS3", "chip_family": "ESP32-S3", "flash_size_bytes": 8388608, - "firmware_version": "0.1.0", + "firmware_version": "1.0.0", "protocol_version": 2, "storage_schema_version": 2, "vault_format_version": 1, From ef573a84bf9426cd35ae7debae5dd35bb8c28b86 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:46:08 +0900 Subject: [PATCH 04/11] release: validate canonical CMake product version (#214) --- scripts/validate_release.py | 42 ++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/validate_release.py b/scripts/validate_release.py index b709100..2d6de50 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -13,6 +13,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_PROFILE = REPO_ROOT / "firmware" / "release-profile.json" DEFAULT_METADATA = REPO_ROOT / "firmware" / "components" / "m5auth_core" / "include" / "m5auth" / "core" / "metadata.hpp" +DEFAULT_PROJECT_CMAKE = REPO_ROOT / "firmware" / "CMakeLists.txt" DEFAULT_PARTITIONS = REPO_ROOT / "firmware" / "partitions.csv" DEFAULT_BOOTSTRAP = REPO_ROOT / "firmware" / "main" / "app_main.cpp" DEFAULT_SDKCONFIG = REPO_ROOT / "firmware" / "sdkconfig.defaults" @@ -75,6 +76,23 @@ def parse_metadata(path: Path = DEFAULT_METADATA) -> dict[str, Any]: } +def parse_cmake_project_version(path: Path = DEFAULT_PROJECT_CMAKE) -> str: + source = _read_text(path, "firmware project CMake") + project = re.search( + r"project\s*\(\s*m5authenticator\b(?P[^)]*)\)", + source, + re.IGNORECASE | re.DOTALL, + ) + _require(project is not None, "m5authenticator CMake project declaration not found") + versions = re.findall( + r"\bVERSION\s+([0-9]+\.[0-9]+\.[0-9]+)\b", + project.group("body"), + re.IGNORECASE, + ) + _require(len(versions) == 1, "m5authenticator CMake project VERSION must be exactly X.Y.Z") + return versions[0] + + def parse_partitions(path: Path = DEFAULT_PARTITIONS) -> dict[str, dict[str, int | str]]: partitions: dict[str, dict[str, int | str]] = {} try: @@ -262,9 +280,11 @@ def validate_release( sdkconfig_path: Path = DEFAULT_SDKCONFIG, transport_header_path: Path = DEFAULT_TRANSPORT_HEADER, transport_cpp_path: Path = DEFAULT_TRANSPORT_CPP, + project_cmake_path: Path = DEFAULT_PROJECT_CMAKE, ) -> dict[str, Any]: profile = load_profile(profile_path) metadata = parse_metadata(metadata_path) + project_version = parse_cmake_project_version(project_cmake_path) partitions = parse_partitions(partitions_path) _require(profile.get("format") == 2, "unsupported release profile format") @@ -275,6 +295,15 @@ def validate_release( for key in ("firmware_version", "protocol_version", "storage_schema_version", "vault_format_version"): _require(profile.get(key) == metadata[key], f"{key} does not match firmware metadata") + _require( + project_version == metadata["firmware_version"], + "CMake project version does not match firmware metadata", + ) + _require( + project_version == profile.get("firmware_version"), + "CMake project version does not match release profile", + ) + _require(profile.get("protocol_version") == 2, "V1 production contract requires Protocol 2") _require(profile.get("storage_schema_version") == 2, "V1 production contract requires Storage Schema 2") _require(profile.get("vault_format_version") == 1, "V1 production contract requires Vault Format 1") @@ -334,6 +363,7 @@ def validate_release( return { "profile": profile, "metadata": metadata, + "project_version": project_version, "partitions": partitions, } @@ -342,17 +372,19 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE) parser.add_argument("--metadata", type=Path, default=DEFAULT_METADATA) + parser.add_argument("--project-cmake", type=Path, default=DEFAULT_PROJECT_CMAKE) parser.add_argument("--partitions", type=Path, default=DEFAULT_PARTITIONS) parser.add_argument("--bootstrap", type=Path, default=DEFAULT_BOOTSTRAP) parser.add_argument("--require-production", action="store_true") args = parser.parse_args() try: result = validate_release( - args.profile, - args.metadata, - args.partitions, - args.require_production, - args.bootstrap, + profile_path=args.profile, + metadata_path=args.metadata, + partitions_path=args.partitions, + require_production=args.require_production, + bootstrap_path=args.bootstrap, + project_cmake_path=args.project_cmake, ) except ReleaseValidationError as exc: print(f"release validation failed: {exc}") From a43331dcf3573bf9f5cba0adac9c3c6a3637019d Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:46:31 +0900 Subject: [PATCH 05/11] test(release): pin 1.0.0 version consistency and package identity (#214) --- tests/release_package_test.py | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 1ef1dd0..50c0ee5 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -53,6 +53,69 @@ def test_repository_release_profile_is_v1_contract_and_production_eligible(self) self.assertEqual(profile["post_update_state"], "locked") self.assertTrue(profile["production_release_allowed"]) + def test_product_version_sources_are_exactly_1_0_0(self) -> None: + result = validate_release.validate_release(require_production=True) + self.assertEqual(result["project_version"], "1.0.0") + self.assertEqual(result["metadata"]["firmware_version"], "1.0.0") + self.assertEqual(result["profile"]["firmware_version"], "1.0.0") + + def test_release_version_consistency_rejects_each_source_divergence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + cmake_path = root / "CMakeLists.txt" + cmake_path.write_text( + validate_release.DEFAULT_PROJECT_CMAKE.read_text(encoding="utf-8").replace( + "VERSION 1.0.0", + "VERSION 9.9.9", + ), + encoding="utf-8", + ) + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "CMake project version does not match firmware metadata", + ): + validate_release.validate_release(project_cmake_path=cmake_path) + + metadata_path = root / "metadata.hpp" + metadata_path.write_text( + validate_release.DEFAULT_METADATA.read_text(encoding="utf-8").replace( + 'kFirmwareVersion[] = "1.0.0"', + 'kFirmwareVersion[] = "9.9.9"', + ), + encoding="utf-8", + ) + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "firmware_version does not match firmware metadata", + ): + validate_release.validate_release(metadata_path=metadata_path) + + profile = validate_release.load_profile() + profile["firmware_version"] = "9.9.9" + profile_path = root / "profile.json" + profile_path.write_text(json.dumps(profile), encoding="utf-8") + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "firmware_version does not match firmware metadata", + ): + validate_release.validate_release(profile_path=profile_path) + + def test_cmake_product_version_parser_rejects_missing_or_malformed_version(self) -> None: + invalid_sources = ( + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(other VERSION 1.0.0)\n", + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, source in enumerate(invalid_sources): + with self.subTest(source=source): + cmake_path = root / f"CMakeLists-{index}.txt" + cmake_path.write_text(source, encoding="utf-8") + with self.assertRaises(validate_release.ReleaseValidationError): + validate_release.parse_cmake_project_version(cmake_path) + def test_production_validation_fails_closed_when_eligibility_is_disabled(self) -> None: with tempfile.TemporaryDirectory() as directory: profile = validate_release.load_profile() @@ -125,6 +188,10 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> self.assertIn("update-manifest-abcdef123456.json", names) self.assertIn("firmware-target.json", names) self.assertIn("SHA256SUMS", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-bootloader.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-partition-table.bin", names) + self.assertIn("m5authenticator-v1.0.0-abcdef123456-m5sticks3-update-ota0.bin", names) self.assertFalse(any(name.endswith("-m5burner.zip") for name in names)) factory = json.loads((root / "out" / "factory-manifest.json").read_text()) @@ -140,6 +207,9 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> self.assertEqual(factory["name"], "M5Authenticator") self.assertEqual(update["name"], "M5Authenticator") self.assertEqual(factory["version"], update["version"]) + self.assertEqual(factory["version"], "1.0.0") + self.assertEqual(update["version"], "1.0.0") + self.assertEqual(target["version"], "1.0.0") self.assertEqual(factory["build_commit"], "abcdef123456") self.assertEqual(update["build_commit"], "abcdef123456") self.assertFalse(factory["exact_release"]) @@ -171,6 +241,7 @@ def test_package_metadata_is_secret_free_and_update_is_partition_aware(self) -> metadata = json.loads((root / "out" / "release-metadata.json").read_text()) self.assertEqual(metadata["format"], 2) + self.assertEqual(metadata["firmware_version"], "1.0.0") self.assertEqual(metadata["protocol_version"], 2) self.assertEqual(metadata["storage_schema_version"], 2) self.assertEqual(metadata["vault_format_version"], 1) From 4eb7a84c0228865f1c09ac3b562b5fdd03272c66 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:48:56 +0900 Subject: [PATCH 06/11] test(release): classify remaining 0.1.0 literals (#214) --- tests/release_package_test.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 50c0ee5..626434b 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import subprocess import sys import tempfile import unittest @@ -59,6 +60,50 @@ def test_product_version_sources_are_exactly_1_0_0(self) -> None: self.assertEqual(result["metadata"]["firmware_version"], "1.0.0") self.assertEqual(result["profile"]["firmware_version"], "1.0.0") + def test_remaining_0_1_0_literals_are_only_noncanonical_metadata_or_smoke_fixtures(self) -> None: + allowed = { + "web/package.json": "private npm package metadata", + "web/package-lock.json": "private npm package-lock metadata", + "web/vite.config.ts": "test-only production-bundle smoke fixture", + } + completed = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + check=True, + stdout=subprocess.PIPE, + ) + occurrences: dict[str, list[str]] = {} + unexpected: list[str] = [] + for raw_path in completed.stdout.split(b"\0"): + if not raw_path: + continue + relative = raw_path.decode("utf-8") + path = REPO_ROOT / relative + try: + source = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for line_number, line in enumerate(source.splitlines(), start=1): + if "0.1.0" not in line: + continue + occurrences.setdefault(relative, []).append(f"{line_number}: {line.strip()}") + if relative not in allowed: + unexpected.append(f"{relative}:{line_number}: {line.strip()}") + + self.assertFalse( + unexpected, + "unexpected current 0.1.0 literal(s) outside classified noncanonical sources:\n" + + "\n".join(unexpected), + ) + self.assertEqual(set(occurrences), set(allowed)) + self.assertEqual(occurrences["web/package.json"], ['4: "version": "0.1.0",']) + self.assertEqual( + occurrences["web/package-lock.json"], + ['3: "version": "0.1.0",', '9: "version": "0.1.0",'], + ) + self.assertEqual(len(occurrences["web/vite.config.ts"]), 5) + self.assertTrue(all("SMOKE_BUILD_COMMIT" in line or 'version: "0.1.0"' in line for line in occurrences["web/vite.config.ts"])) + def test_release_version_consistency_rejects_each_source_divergence(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) From 91d89ff440fff3e1ebbe0099244710ccbd425711 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:51:11 +0900 Subject: [PATCH 07/11] test(release): classify all legacy version literals (#214) --- tests/release_package_test.py | 69 +++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 626434b..08e3d74 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -60,20 +60,30 @@ def test_product_version_sources_are_exactly_1_0_0(self) -> None: self.assertEqual(result["metadata"]["firmware_version"], "1.0.0") self.assertEqual(result["profile"]["firmware_version"], "1.0.0") - def test_remaining_0_1_0_literals_are_only_noncanonical_metadata_or_smoke_fixtures(self) -> None: - allowed = { - "web/package.json": "private npm package metadata", - "web/package-lock.json": "private npm package-lock metadata", - "web/vite.config.ts": "test-only production-bundle smoke fixture", + def test_remaining_0_1_0_literals_are_only_classified_noncanonical_values(self) -> None: + legacy_version = "0." + "1.0" + private_npm_metadata = { + "web/package.json", + "web/package-lock.json", } + historical_compatibility_references = { + "firmware/components/m5auth_vault/include/m5auth/vault.hpp", + "tests/vault_interop_test.cpp", + } + completed = subprocess.run( ["git", "ls-files", "-z"], cwd=REPO_ROOT, check=True, stdout=subprocess.PIPE, ) - occurrences: dict[str, list[str]] = {} + classified: dict[str, list[str]] = { + "private npm package metadata": [], + "test/smoke fixture": [], + "historical compatibility reference": [], + } unexpected: list[str] = [] + for raw_path in completed.stdout.split(b"\0"): if not raw_path: continue @@ -83,26 +93,47 @@ def test_remaining_0_1_0_literals_are_only_noncanonical_metadata_or_smoke_fixtur source = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue + for line_number, line in enumerate(source.splitlines(), start=1): - if "0.1.0" not in line: + if legacy_version not in line: + continue + evidence = f"{relative}:{line_number}: {line.strip()}" + + if relative in private_npm_metadata: + classified["private npm package metadata"].append(evidence) continue - occurrences.setdefault(relative, []).append(f"{line_number}: {line.strip()}") - if relative not in allowed: - unexpected.append(f"{relative}:{line_number}: {line.strip()}") + + if relative in historical_compatibility_references: + self.assertIn("v" + legacy_version, line) + self.assertTrue(line.lstrip().startswith("//")) + classified["historical compatibility reference"].append(evidence) + continue + + is_web_unit_fixture = relative.startswith("web/src/") and relative.endswith(".test.ts") + is_browser_smoke_fixture = relative.startswith("web/tests/browser/") and relative.endswith(".ts") + if relative == "web/vite.config.ts" or is_web_unit_fixture or is_browser_smoke_fixture: + classified["test/smoke fixture"].append(evidence) + continue + + unexpected.append(evidence) self.assertFalse( unexpected, - "unexpected current 0.1.0 literal(s) outside classified noncanonical sources:\n" + "unexpected current legacy version literal outside classified noncanonical sources:\n" + "\n".join(unexpected), ) - self.assertEqual(set(occurrences), set(allowed)) - self.assertEqual(occurrences["web/package.json"], ['4: "version": "0.1.0",']) - self.assertEqual( - occurrences["web/package-lock.json"], - ['3: "version": "0.1.0",', '9: "version": "0.1.0",'], - ) - self.assertEqual(len(occurrences["web/vite.config.ts"]), 5) - self.assertTrue(all("SMOKE_BUILD_COMMIT" in line or 'version: "0.1.0"' in line for line in occurrences["web/vite.config.ts"])) + self.assertTrue(classified["private npm package metadata"]) + self.assertTrue(classified["test/smoke fixture"]) + self.assertTrue(classified["historical compatibility reference"]) + + canonical_sources = { + "firmware/CMakeLists.txt", + "firmware/components/m5auth_core/include/m5auth/core/metadata.hpp", + "firmware/release-profile.json", + } + all_classified = "\n".join(item for values in classified.values() for item in values) + for canonical in canonical_sources: + self.assertNotIn(canonical + ":", all_classified) def test_release_version_consistency_rejects_each_source_divergence(self) -> None: with tempfile.TemporaryDirectory() as directory: From c6006bd6e1653609f4803a1bb820ed54b3a607d6 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:07:25 +0900 Subject: [PATCH 08/11] fix(release): reject non-X.Y.Z CMake version tokens (#214) --- scripts/validate_release.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 2d6de50..f5354e1 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -84,13 +84,20 @@ def parse_cmake_project_version(path: Path = DEFAULT_PROJECT_CMAKE) -> str: re.IGNORECASE | re.DOTALL, ) _require(project is not None, "m5authenticator CMake project declaration not found") - versions = re.findall( - r"\bVERSION\s+([0-9]+\.[0-9]+\.[0-9]+)\b", + + version_tokens = re.findall( + r"\bVERSION\s+([^\s)]+)", project.group("body"), re.IGNORECASE, ) - _require(len(versions) == 1, "m5authenticator CMake project VERSION must be exactly X.Y.Z") - return versions[0] + _require(len(version_tokens) == 1, "m5authenticator CMake project VERSION must be exactly X.Y.Z") + + version = version_tokens[0] + _require( + re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is not None, + "m5authenticator CMake project VERSION must be exactly X.Y.Z", + ) + return version def parse_partitions(path: Path = DEFAULT_PARTITIONS) -> dict[str, dict[str, int | str]]: From f50a6930cc4824c1ae382053438f0f06363b1873 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:07:28 +0900 Subject: [PATCH 09/11] test(release): cover full CMake version token boundary (#214) --- tests/release_package_test.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 08e3d74..1a111d0 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -181,6 +181,8 @@ def test_cmake_product_version_parser_rejects_missing_or_malformed_version(self) invalid_sources = ( "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator)\n", "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0.0.1)\n", + "cmake_minimum_required(VERSION 3.16)\nproject(m5authenticator VERSION 1.0.0-beta)\n", "cmake_minimum_required(VERSION 3.16)\nproject(other VERSION 1.0.0)\n", ) with tempfile.TemporaryDirectory() as directory: @@ -192,6 +194,28 @@ def test_cmake_product_version_parser_rejects_missing_or_malformed_version(self) with self.assertRaises(validate_release.ReleaseValidationError): validate_release.parse_cmake_project_version(cmake_path) + def test_non_x_y_z_cmake_version_fails_both_validation_paths(self) -> None: + invalid_versions = ("1.0.0.1", "1.0.0-beta") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for version in invalid_versions: + cmake_path = root / ("CMakeLists-" + version.replace(".", "_") + ".txt") + cmake_path.write_text( + f"cmake_minimum_required(VERSION 3.16)\n" + f"project(m5authenticator VERSION {version})\n", + encoding="utf-8", + ) + for require_production in (False, True): + with self.subTest(version=version, require_production=require_production): + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + "VERSION must be exactly X.Y.Z", + ): + validate_release.validate_release( + project_cmake_path=cmake_path, + require_production=require_production, + ) + def test_production_validation_fails_closed_when_eligibility_is_disabled(self) -> None: with tempfile.TemporaryDirectory() as directory: profile = validate_release.load_profile() From a5bfc9528f45ddf82ec75f6786b00db74c7babe1 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:42:36 +0900 Subject: [PATCH 10/11] fix(release): ignore inactive CMake project examples (#214) --- scripts/validate_release.py | 90 ++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/scripts/validate_release.py b/scripts/validate_release.py index f5354e1..3714665 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -76,14 +76,92 @@ def parse_metadata(path: Path = DEFAULT_METADATA) -> dict[str, Any]: } +def _cmake_bracket_delimiter(source: str, start: int) -> tuple[str, int] | None: + if start >= len(source) or source[start] != "[": + return None + index = start + 1 + while index < len(source) and source[index] == "=": + index += 1 + if index >= len(source) or source[index] != "[": + return None + equals = source[start + 1 : index] + return "]" + equals + "]", index + 1 + + +def _sanitize_cmake_inactive_text(source: str) -> str: + sanitized: list[str] = [] + index = 0 + + def append_inactive(text: str) -> None: + sanitized.extend("\n" if character == "\n" else " " for character in text) + + while index < len(source): + character = source[index] + + if character == "#": + bracket = _cmake_bracket_delimiter(source, index + 1) + if bracket is not None: + closing, content_start = bracket + close_index = source.find(closing, content_start) + _require(close_index >= 0, "unterminated CMake bracket comment") + end = close_index + len(closing) + append_inactive(source[index:end]) + index = end + continue + + line_end = source.find("\n", index) + if line_end < 0: + append_inactive(source[index:]) + break + append_inactive(source[index:line_end]) + index = line_end + continue + + if character == '"': + end = index + 1 + escaped = False + while end < len(source): + current = source[end] + if current == '"' and not escaped: + end += 1 + break + if current == "\\" and not escaped: + escaped = True + else: + escaped = False + end += 1 + _require(end <= len(source) and source[end - 1] == '"', "unterminated CMake quoted string") + append_inactive(source[index:end]) + index = end + continue + + bracket = _cmake_bracket_delimiter(source, index) + if bracket is not None: + closing, content_start = bracket + close_index = source.find(closing, content_start) + _require(close_index >= 0, "unterminated CMake bracket argument") + end = close_index + len(closing) + append_inactive(source[index:end]) + index = end + continue + + sanitized.append(character) + index += 1 + + return "".join(sanitized) + + def parse_cmake_project_version(path: Path = DEFAULT_PROJECT_CMAKE) -> str: - source = _read_text(path, "firmware project CMake") - project = re.search( - r"project\s*\(\s*m5authenticator\b(?P[^)]*)\)", - source, - re.IGNORECASE | re.DOTALL, + source = _sanitize_cmake_inactive_text(_read_text(path, "firmware project CMake")) + projects = list( + re.finditer( + r"^[ \t]*project\s*\(\s*m5authenticator\b(?P[^)]*)\)[ \t]*$", + source, + re.IGNORECASE | re.MULTILINE, + ) ) - _require(project is not None, "m5authenticator CMake project declaration not found") + _require(len(projects) == 1, "exactly one active m5authenticator CMake project declaration is required") + project = projects[0] version_tokens = re.findall( r"\bVERSION\s+([^\s)]+)", From 855679f5da8ef4379d8ef00ed56f0bb8b65ae971 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:42:59 +0900 Subject: [PATCH 11/11] test(release): cover inactive CMake project masking (#214) --- tests/release_package_test.py | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/release_package_test.py b/tests/release_package_test.py index 1a111d0..0125cd6 100644 --- a/tests/release_package_test.py +++ b/tests/release_package_test.py @@ -216,6 +216,55 @@ def test_non_x_y_z_cmake_version_fails_both_validation_paths(self) -> None: require_production=require_production, ) + def test_inactive_cmake_project_examples_cannot_mask_active_version(self) -> None: + cases = ( + ( + "# project(m5authenticator VERSION 1.0.0)\n" + "project(m5authenticator VERSION 1.0.0.1)\n", + "VERSION must be exactly X.Y.Z", + ), + ( + "# project(m5authenticator VERSION 1.0.0)\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + 'set(EXAMPLE "project(m5authenticator VERSION 1.0.0)")\n' + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + "#[[\n" + "project(m5authenticator VERSION 1.0.0)\n" + "]]\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ( + "set(EXAMPLE [[project(m5authenticator VERSION 1.0.0)]])\n" + "project(m5authenticator VERSION 9.9.9)\n", + "CMake project version does not match firmware metadata", + ), + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index, (source, expected_error) in enumerate(cases): + cmake_path = root / f"CMakeLists-mask-{index}.txt" + cmake_path.write_text(source, encoding="utf-8") + for require_production in (False, True): + with self.subTest( + source=source, + require_production=require_production, + ): + with self.assertRaisesRegex( + validate_release.ReleaseValidationError, + expected_error, + ): + validate_release.validate_release( + project_cmake_path=cmake_path, + require_production=require_production, + ) + def test_production_validation_fails_closed_when_eligibility_is_disabled(self) -> None: with tempfile.TemporaryDirectory() as directory: profile = validate_release.load_profile()