From 44e2e797cdf980d224d5e77fe8b2d588aa6c83bd Mon Sep 17 00:00:00 2001 From: Ed Date: Mon, 12 Oct 2026 22:54:24 +0100 Subject: [PATCH] fix: report release version after USB installs --- .github/workflows/ci.yml | 14 ++++++++++ micro/app_version.json | 3 ++ micro/ota_manager.py | 13 ++++++++- pyproject.toml | 2 ++ scripts/build_gui.py | 1 + scripts/push_micro.py | 3 ++ scripts/smoke_installed_package.py | 1 + src/bambutton/gui.py | 18 ++++++++++++ tests/test_gui.py | 12 ++++++-- tests/test_ota_manager.py | 45 ++++++++++++++++++++++++++++++ tests/test_package_smoke.py | 1 + tests/test_packaging.py | 15 +++++++++- tests/test_push_micro.py | 8 ++++-- 13 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 micro/app_version.json mode change 100755 => 100644 scripts/build_gui.py mode change 100755 => 100644 scripts/push_micro.py mode change 100755 => 100644 src/bambutton/gui.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57deacc..561becb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,20 @@ jobs: - name: Build and validate release OTA candidate run: | version="$(python -c 'import json; print(json.load(open("docs/release-validation.json"))["candidate_version"])')" + python - "$version" <<'PY' + import json + import sys + from pathlib import Path + + candidate_version = sys.argv[1] + metadata = json.loads(Path("micro/app_version.json").read_text()) + if metadata.get("version") != candidate_version: + raise SystemExit( + "USB application version {} does not match candidate {}".format( + metadata.get("version"), candidate_version + ) + ) + PY python scripts/build_ota_bundle.py \ --version "$version" \ --output candidate-assets/bambutton-ota.json diff --git a/micro/app_version.json b/micro/app_version.json new file mode 100644 index 0000000..1587a66 --- /dev/null +++ b/micro/app_version.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/micro/ota_manager.py b/micro/ota_manager.py index 7562e2e..0d6dc76 100644 --- a/micro/ota_manager.py +++ b/micro/ota_manager.py @@ -49,6 +49,7 @@ "wifi.py", ) REQUIRED_APP_FILES = APP_FILES +APP_VERSION_FILE = "app_version.json" UPDATE_ROOT = ".bambutton" BOOTSTRAP_ROOT = UPDATE_ROOT + "/bootstrap" BOOTSTRAP_INSTALL_MARKER = UPDATE_ROOT + "/bootstrap-install.marker" @@ -221,9 +222,19 @@ def migrate_legacy(self, source_root=None): ) return False + version_data = self._read_json(source_root + "/" + APP_VERSION_FILE) + application_version = ( + version_data.get("version") if isinstance(version_data, dict) else None + ) + if ( + not isinstance(application_version, str) + or not application_version.strip() + ): + application_version = "legacy" + self._write_json(self._slot_path("app_a.tmp", "manifest.json"), { "format": UPDATE_FORMAT, - "version": "legacy", + "version": application_version, "release_notes": "Migrated from the flat-root application", "minimum_bootloader": BOOTLOADER_VERSION, "files": manifest_files, diff --git a/pyproject.toml b/pyproject.toml index ecfec95..685cb64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ packages = ["src/bambutton"] "micro/wifi.py" = "bambutton/micro/wifi.py" "micro/web_config.py" = "bambutton/micro/web_config.py" "micro/config_example.json" = "bambutton/micro/config_example.json" +"micro/app_version.json" = "bambutton/micro/app_version.json" "firmware" = "bambutton/firmware" [tool.hatch.build.targets.sdist] @@ -76,4 +77,5 @@ include = [ "/micro/wifi.py", "/micro/web_config.py", "/micro/config_example.json", + "/micro/app_version.json", ] diff --git a/scripts/build_gui.py b/scripts/build_gui.py old mode 100755 new mode 100644 index b610105..c3d5323 --- a/scripts/build_gui.py +++ b/scripts/build_gui.py @@ -24,6 +24,7 @@ "wifi.py", "web_config.py", "config_example.json", + "app_version.json", ] diff --git a/scripts/push_micro.py b/scripts/push_micro.py old mode 100755 new mode 100644 index 750e226..3831ae6 --- a/scripts/push_micro.py +++ b/scripts/push_micro.py @@ -179,6 +179,9 @@ def main(): args = parser.parse_args() files = sorted(MICRO_DIR.glob("*.py")) + app_version_file = MICRO_DIR / "app_version.json" + if app_version_file.exists(): + files.append(app_version_file) if args.no_main: files = [path for path in files if path.name not in ("main.py", "boot.py")] diff --git a/scripts/smoke_installed_package.py b/scripts/smoke_installed_package.py index 4ebd89c..90bfb7c 100644 --- a/scripts/smoke_installed_package.py +++ b/scripts/smoke_installed_package.py @@ -12,6 +12,7 @@ def check_installed_package(): "micro/app_main.py", "micro/boot.py", "micro/config_example.json", + "micro/app_version.json", "micro/ota_manager.py", "micro/web_config.py", "firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin", diff --git a/src/bambutton/gui.py b/src/bambutton/gui.py old mode 100755 new mode 100644 index ea8ada4..8984a78 --- a/src/bambutton/gui.py +++ b/src/bambutton/gui.py @@ -213,12 +213,27 @@ def run_installation_self_test(): "web_config.py", "wifi.py", "config_example.json", + "app_version.json", ) missing = [name for name in required_files if not (MICRO_DIR / name).is_file()] if missing: raise RuntimeError( "Bambutton installation is missing: {}".format(", ".join(missing)) ) + try: + version_metadata = json.loads( + (MICRO_DIR / "app_version.json").read_text(encoding="utf-8") + ) + except (OSError, ValueError) as exc: + raise RuntimeError( + "Bambutton application version metadata is invalid: {}".format(exc) + ) + if ( + not isinstance(version_metadata, dict) + or not isinstance(version_metadata.get("version"), str) + or not version_metadata["version"].strip() + ): + raise RuntimeError("Bambutton application version metadata is invalid") validate_firmware(first_firmware_file()) @@ -626,6 +641,9 @@ def push_micro_files(config_path, clean=False): run_mpremote(mpremote_args("exec", CLEAN_BOARD_CODE)) files = sorted(MICRO_DIR.glob("*.py")) + app_version_file = MICRO_DIR / "app_version.json" + if app_version_file.is_file(): + files.append(app_version_file) run_mpremote(mpremote_args("exec", BOOTSTRAP_PREPARE_CODE)) for path in files: run_mpremote(mpremote_args( diff --git a/tests/test_gui.py b/tests/test_gui.py index f03f2ff..909e7ac 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -255,6 +255,7 @@ def test_installed_setup_self_test_checks_micro_files_and_firmware( "config_example.json", ): (micro_dir / name).write_text(name) + (micro_dir / "app_version.json").write_text('{"version":"1.0.0"}') (firmware_dir / "esp32.bin").write_bytes(b"firmware") monkeypatch.setattr(gui, "MICRO_DIR", micro_dir) monkeypatch.setattr(gui, "DEFAULT_FIRMWARE_DIR", firmware_dir) @@ -302,6 +303,7 @@ def test_push_micro_files_installs_selected_file_as_config_json(tmp_path, monkey micro_dir.mkdir() (micro_dir / "b_module.py").write_text("# b") (micro_dir / "a_module.py").write_text("# a") + (micro_dir / "app_version.json").write_text('{"version":"1.0.0"}') config_path = tmp_path / "saved-config.json" config_path.write_text(json.dumps({"wifi": {"ssid": "shop"}})) calls = [] @@ -315,9 +317,13 @@ def test_push_micro_files_installs_selected_file_as_config_json(tmp_path, monkey assert calls[1][0] == "exec" assert calls[2][1:] == [str(micro_dir / "a_module.py"), ":.bambutton/bootstrap/a_module.py"] assert calls[3][1:] == [str(micro_dir / "b_module.py"), ":.bambutton/bootstrap/b_module.py"] - assert calls[4][1:] == [str(config_path), ":.bambutton/bootstrap/config.json"] - assert calls[5][0] == "exec" - assert calls[6] == ["reset"] + assert calls[4][1:] == [ + str(micro_dir / "app_version.json"), + ":.bambutton/bootstrap/app_version.json", + ] + assert calls[5][1:] == [str(config_path), ":.bambutton/bootstrap/config.json"] + assert calls[6][0] == "exec" + assert calls[7] == ["reset"] def test_push_micro_files_omits_config_for_web_gui_setup(tmp_path, monkeypatch): diff --git a/tests/test_ota_manager.py b/tests/test_ota_manager.py index d806a88..612de37 100644 --- a/tests/test_ota_manager.py +++ b/tests/test_ota_manager.py @@ -53,17 +53,59 @@ def test_stage_and_commit_use_inactive_slot_and_preserve_config(tmp_path): def test_legacy_migration_copies_root_files_before_switching_pointer(tmp_path): for filename in ota_manager.APP_FILES: (tmp_path / filename).write_bytes((filename + "\n").encode()) + (tmp_path / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}') manager = ota_manager.OTAUpdateManager(root=str(tmp_path)) assert manager.migrate_legacy() is True assert manager.status()["active_slot"] == "app_a" assert manager.status()["pending"]["candidate"] == "app_a" + assert manager.status()["current_version"] == "1.0.0" for filename in ota_manager.APP_FILES: assert (tmp_path / ".bambutton/app_a" / filename).read_bytes() == ( filename + "\n" ).encode() +def test_genuine_legacy_migration_keeps_legacy_version_fallback(tmp_path): + for filename in ota_manager.REQUIRED_APP_FILES: + (tmp_path / filename).write_bytes((filename + "\n").encode()) + manager = ota_manager.OTAUpdateManager(root=str(tmp_path)) + + assert manager.migrate_legacy() is True + assert manager.status()["current_version"] == "legacy" + + +def test_existing_ota_slot_format_remains_valid(tmp_path): + old_bundle = bundle("0.9.0") + manager = ota_manager.OTAUpdateManager(root=str(tmp_path)) + + manager.stage_bundle(old_bundle) + manager.commit_staged() + manager._verify_slot("app_a") + + assert manager.status()["current_version"] == "0.9.0" + + +def test_usb_and_ota_installs_report_the_same_candidate_version(tmp_path): + usb_root = tmp_path / "usb" + usb_root.mkdir() + for filename in ota_manager.APP_FILES: + (usb_root / filename).write_bytes((filename + "\n").encode()) + (usb_root / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}') + usb_manager = ota_manager.OTAUpdateManager(root=str(usb_root)) + assert usb_manager.migrate_legacy() is True + + ota_root = tmp_path / "ota" + ota_root.mkdir() + ota_manager_for_update = ota_manager.OTAUpdateManager(root=str(ota_root)) + ota_manager_for_update.stage_bundle(bundle("1.0.0")) + ota_manager_for_update.commit_staged() + + assert usb_manager.status()["current_version"] == ( + ota_manager_for_update.status()["current_version"] + ) + + def test_confirmed_candidate_clears_pending_state(tmp_path): manager = ota_manager.OTAUpdateManager(root=str(tmp_path)) manager.stage_bundle(bundle()) @@ -120,6 +162,7 @@ def test_bootstrap_candidate_failure_returns_to_legacy_main(tmp_path, monkeypatc if filename == "app_main.py": content = b"raise RuntimeError('broken bootstrap')\n" (bootstrap / filename).write_bytes(content) + (bootstrap / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}') (bootstrap / "main.py").write_text("stable loader\n") (tmp_path / "main.py").write_text("LEGACY_BOOTED = True\n") @@ -182,6 +225,7 @@ def test_bootstrap_migration_uses_staged_files_and_cleans_up(tmp_path, monkeypat if filename == "app_main.py": content = b"BOOTSTRAPPED = True\n" (bootstrap / filename).write_bytes(content) + (bootstrap / ota_manager.APP_VERSION_FILE).write_text('{"version":"1.0.0"}') (tmp_path / ".bambutton/bootstrap-install.marker").write_text( "bambutton-bootstrap-v1\n" ) @@ -191,6 +235,7 @@ def test_bootstrap_migration_uses_staged_files_and_cleans_up(tmp_path, monkeypat manager.launch() assert manager.status()["active_slot"] == "app_a" + assert manager.status()["current_version"] == "1.0.0" assert not bootstrap.exists() assert not (tmp_path / ".bambutton/bootstrap-install.marker").exists() assert (tmp_path / ".bambutton/app_a/app_main.py").read_bytes() == ( diff --git a/tests/test_package_smoke.py b/tests/test_package_smoke.py index 323ac9d..aadf1bc 100644 --- a/tests/test_package_smoke.py +++ b/tests/test_package_smoke.py @@ -11,6 +11,7 @@ "micro/app_main.py", "micro/boot.py", "micro/config_example.json", + "micro/app_version.json", "micro/ota_manager.py", "micro/web_config.py", "firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin", diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 7018885..3d30c14 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,3 +1,4 @@ +import json import runpy from pathlib import Path import tomllib @@ -12,6 +13,7 @@ def test_release_gui_includes_all_micro_python_modules(): assert "app_main.py" in build_script["MICRO_FILES"] assert "ota_manager.py" in build_script["MICRO_FILES"] assert "config_example.json" in build_script["MICRO_FILES"] + assert "app_version.json" in build_script["MICRO_FILES"] def test_desktop_and_python_packages_include_all_runtime_files(): @@ -21,7 +23,7 @@ def test_desktop_and_python_packages_include_all_runtime_files(): runtime_files = { path.name for path in (project_root / "micro").iterdir() if path.suffix == ".py" - } | {"config_example.json"} + } | {"config_example.json", "app_version.json"} assert set(build_script["MICRO_FILES"]) == runtime_files @@ -38,3 +40,14 @@ def test_desktop_and_python_packages_include_all_runtime_files(): "include" ]) assert {"/micro/" + filename for filename in runtime_files} <= sdist_files + + +def test_usb_application_version_matches_release_candidate(): + project_root = Path(__file__).parents[1] + app_version = json.loads( + (project_root / "micro" / "app_version.json").read_text() + ) + candidate_version = json.loads( + (project_root / "docs" / "release-validation.json").read_text() + )["candidate_version"] + assert app_version["version"] == candidate_version diff --git a/tests/test_push_micro.py b/tests/test_push_micro.py index 9e51608..cf5b400 100644 --- a/tests/test_push_micro.py +++ b/tests/test_push_micro.py @@ -8,6 +8,7 @@ def test_push_micro_stages_runtime_before_installing_bootloader(tmp_path, monkey micro_dir.mkdir() for filename in ("app_main.py", "boot.py", "main.py", "ota_manager.py", "web_config.py"): (micro_dir / filename).write_text(filename) + (micro_dir / "app_version.json").write_text('{"version":"1.0.0"}') config = micro_dir / "config.json" config.write_text("{}") calls = [] @@ -21,17 +22,18 @@ def test_push_micro_stages_runtime_before_installing_bootloader(tmp_path, monkey assert calls[0][0:2] == ["mpremote", "exec"] assert calls[1][0:2] == ["mpremote", "exec"] - staged_paths = [call[-1] for call in calls[2:8]] + staged_paths = [call[-1] for call in calls[2:9]] assert staged_paths == [ ":.bambutton/bootstrap/app_main.py", ":.bambutton/bootstrap/boot.py", ":.bambutton/bootstrap/main.py", ":.bambutton/bootstrap/ota_manager.py", ":.bambutton/bootstrap/web_config.py", + ":.bambutton/bootstrap/app_version.json", ":.bambutton/bootstrap/config.json", ] - assert calls[8][0:2] == ["mpremote", "exec"] - assert calls[9] == ["mpremote", "reset"] + assert calls[9][0:2] == ["mpremote", "exec"] + assert calls[10] == ["mpremote", "reset"] def test_push_micro_no_main_keeps_development_path_without_boot(tmp_path, monkeypatch):