From ce00ac32500817a0342c62467a31f9219bb4d84b Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:26:25 +0700 Subject: [PATCH 1/8] [docs] Bind the runtime agent version to the release lock Signed-off-by: Viwat Vchirawongkwin --- docs/specifications/firmware/specs.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/specifications/firmware/specs.md b/docs/specifications/firmware/specs.md index d5cf139..c634da7 100644 --- a/docs/specifications/firmware/specs.md +++ b/docs/specifications/firmware/specs.md @@ -544,7 +544,7 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety ## 8. Build, versioning & distribution (BLD) > **FROZEN v1.0 (amended) for the initial ESP32 v1 port (G0 · 2026-07-01; -> browser-release amendments 2026-07-29 and 2026-07-30 · `[docs]`).** +> browser-release amendments 2026-07-29 through 2026-07-31 · `[docs]`).** > BLD-1…22 are the build/versioning contract build-smith implements. The > 2026-07-29 amendments tighten BLD-5…8/13/14 and add BLD-17…22 before > X-10/X-11 code; the 2026-07-30 amendment freezes the two-profile pre-v1 @@ -619,7 +619,12 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety [`versions.lock`](../../../firmware/versions.lock); verify: build; story: X-03)* - **BLD-11** — ESP-IDF MUST be installed from the pin into a **gitignored** directory (not an outer submodule); MicroPython `lib/` deps come from the standard port build. — *(source: PRD §10.9, §17.1; verify: build; story: X-03)* -- **BLD-12** — The firmware agent MUST follow **SemVer** (`MAJOR.MINOR.PATCH`); a backward-incompatible change bumps MAJOR. — *(source: PRD §18.1; verify: build; story: X-11)* +- **BLD-12** — The firmware agent MUST follow **SemVer** + (`MAJOR.MINOR.PATCH`); a backward-incompatible change bumps MAJOR. + `firmware/versions.lock` `[pyble].agent_version` is the canonical agent + version for a source commit, and the importable `pyble.__version__` used by + `DEVICE_INFO`/HELLO MUST equal it exactly. — *(source: PRD §18.1; verify: + build; story: X-11)* - **BLD-13** — A release MUST make the firmware-agent version, PBLE/1 version, upstream MicroPython/ESP-IDF versions and commits, PyBLE source commit, image profile, and artifact hashes recoverable from `DEVICE_INFO`/HELLO, From e761da9cc76daeb7fe64a4fff93af0a5a914b634 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:26:55 +0700 Subject: [PATCH 2/8] [red] Require the public firmware baseline version Signed-off-by: Viwat Vchirawongkwin --- .../host/test_agent_version_lock.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/firmware_tests/host/test_agent_version_lock.py diff --git a/tests/firmware_tests/host/test_agent_version_lock.py b/tests/firmware_tests/host/test_agent_version_lock.py new file mode 100644 index 0000000..e6f9fbd --- /dev/null +++ b/tests/firmware_tests/host/test_agent_version_lock.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Repository-level firmware agent version contract (BLD-12/13).""" + +from __future__ import annotations + +import ast +import pathlib +import tomllib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +LOCK_PATH = ROOT / "firmware" / "versions.lock" +PACKAGE_PATH = ROOT / "firmware" / "pyble" / "__init__.py" +BASELINE_VERSION = "0.4.2" + + +def _package_version() -> str: + tree = ast.parse(PACKAGE_PATH.read_text(encoding="utf-8"), filename=str(PACKAGE_PATH)) + values = [] + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if any(isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets): + if not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str): + raise AssertionError("pyble.__version__ must be one literal string") + values.append(node.value.value) + if len(values) != 1: + raise AssertionError("firmware/pyble/__init__.py must assign __version__ exactly once") + return values[0] + + +class AgentVersionLockTests(unittest.TestCase): + def test_runtime_version_equals_the_canonical_release_lock(self) -> None: + lock = tomllib.loads(LOCK_PATH.read_text(encoding="utf-8")) + self.assertEqual(_package_version(), lock["pyble"]["agent_version"]) + + def test_public_baseline_selects_v042(self) -> None: + lock = tomllib.loads(LOCK_PATH.read_text(encoding="utf-8")) + self.assertEqual(lock["pyble"]["agent_version"], BASELINE_VERSION) + + +if __name__ == "__main__": + unittest.main() From 85c081a4b3e5f58433c9daa6105974af7ff5866f Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:27:58 +0700 Subject: [PATCH 3/8] [green] Select firmware v0.4.2 for public qualification Signed-off-by: Viwat Vchirawongkwin --- CHANGELOG.md | 2 ++ firmware/pyble/__init__.py | 2 +- firmware/versions.lock | 13 +++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d5f51..232f50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ are released independently from this monorepo. - Established `PyBLE-dev/PyBLE` as the canonical public monorepo. - Added public contributor, security, architecture, protocol, and validation documentation. +- Selected firmware agent `0.4.2` for fresh reproducible builds and + two-profile qualification from the canonical public history. ## App 0.1.0-beta — 2026-07-30 diff --git a/firmware/pyble/__init__.py b/firmware/pyble/__init__.py index 4b49ee3..9535b11 100644 --- a/firmware/pyble/__init__.py +++ b/firmware/pyble/__init__.py @@ -10,4 +10,4 @@ # S2 onward by their owning engineers — see TDD §10.5. Do NOT add agent logic # here (that is out of scope for build-smith). -__version__ = "0.4.1" # mirrors versions.lock [pyble] agent_version (SemVer, BLD-12) +__version__ = "0.4.2" # mirrors versions.lock [pyble] agent_version (SemVer, BLD-12) diff --git a/firmware/versions.lock b/firmware/versions.lock index ff47e39..32565ab 100644 --- a/firmware/versions.lock +++ b/firmware/versions.lock @@ -7,11 +7,12 @@ # pins. See docs/specifications/firmware.md §6 (Build & distribution) and the # sibling note in firmware/upstream/README.md. # -# CANDIDATE-FROZEN for firmware v0.4.1. These exact bytes select the reviewed -# MicroPython and ESP-IDF inputs before reproducible builds, license audit, and -# HIL. Promotion cannot change them; a pin change starts a new candidate. The -# commit SHAs are authoritative, and the build gate verifies both upstream -# checkouts against this file. +# BASELINE-SELECTED for firmware v0.4.2. These exact bytes select the public +# source baseline's MicroPython and ESP-IDF inputs before reproducible builds +# and OI-1 measurement. The subsequent evidence/policy commit candidate-freezes +# them before release builds, license audit, and HIL. The commit SHAs are +# authoritative, and the build gate verifies both upstream checkouts against +# this file. # # Update only via the controlled upgrade workflow # (firmware/scripts/upgrade_micropython.sh) — never by hand during a build. @@ -44,5 +45,5 @@ mpy_cross = "rebuilt-from-pinned-micropython" # Deliberately no board/board_profile/routing fields: PyBLE targets generic # ESP32 boards and exposes hardware to user code via standard MicroPython # (machine), not via the agent. -agent_version = "0.4.1" +agent_version = "0.4.2" protocol_version = "PBLE/1" From 0b31465414f6e13ab965655b382b8e4cd3107610 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:16:03 +0700 Subject: [PATCH 4/8] [docs] Specify mechanical release evidence assembly Signed-off-by: Viwat Vchirawongkwin (cherry picked from commit 70d8189143b065638eccdcd30ab9ae19b237508a) --- docs/specifications/firmware/TDD.md | 16 ++++-- .../firmware/browser-flashing.md | 56 +++++++++++++++++-- tests/firmware_tests/hil/README.md | 46 +++++++++++++-- 3 files changed, 103 insertions(+), 15 deletions(-) diff --git a/docs/specifications/firmware/TDD.md b/docs/specifications/firmware/TDD.md index 872dc97..c002112 100644 --- a/docs/specifications/firmware/TDD.md +++ b/docs/specifications/firmware/TDD.md @@ -613,11 +613,13 @@ negative tests land while release qualification remains pending. The maintainer then: 1. runs the engineering baseline on the two exact owned profiles; -2. commits the canonical, redacted raw evidence under - `docs/validation/firmware/oi1/`; -3. derives every profile threshold mechanically with the frozen formulas; -4. commits the populated policy and its evidence SHA-256; and -5. builds the final tagged candidate and reruns verify-mode HIL on both exact +2. runs `assemble-oi1-baseline` against the immutable staged inputs and the + two bench fragments so the tool creates the canonical, redacted evidence + under `docs/validation/firmware/oi1/`, derives every threshold with the + frozen formulas, and atomically updates the policy with its evidence + SHA-256; +3. reviews and commits those mechanically assembled files; and +4. builds the final tagged candidate and reruns verify-mode HIL on both exact profiles. A policy has exactly two threshold-bearing profile entries and one deferred @@ -632,6 +634,10 @@ matching baseline-evidence digest, and immutable build measurements in `PYBLE_HIL_RECORDS_V2`. Its runtime observation is pending. Finalization may fill observations, operator fields, and derived checks only; it must prove the policy and build portions remain byte/semantically equal to the candidate. +The `assemble-hil-report` helper accepts only bounded per-profile mutable +evidence, copies candidate-frozen fields from the pending report, derives the +footprint/reliability pass from validated observations, and emits the completed +V2 report atomically before finalization. The validator recomputes image/headroom arithmetic, sample counts, heap minima, latency maximum, goodput from recorded durations, threshold diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index e9386e0..9084a99 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -1,9 +1,10 @@ # PyBLE ESP32-Family Browser Flashing and Release Bundle -Status: **FROZEN v1.26** · Owner: project maintainer · Frozen: -2026-07-30 (`[docs]`; pre-v1 two-profile release eligibility and explicit +Status: **FROZEN v1.27** · Owner: project maintainer · Frozen: +2026-07-31 (`[docs]`; pre-v1 two-profile release eligibility and explicit C3 deferral; evidence-derived resource policy and exact HIL V2 records; -pre-policy two-root baseline-input staging; +pre-policy two-root baseline-input staging and mechanical baseline/policy +assembly; exact-profile manifest, C3 silicon-revision, license-audit safety, candidate-finalization, and candidate pin-state amendments, plus the real-tool license-evidence and exact license-catalog @@ -18,7 +19,8 @@ markers, generated-header inputs, and direct-object reconciliation, plus component-owned linked outputs, lexical exact-path validation, nested-build logical paths, shell-free compiler/linker command receipts, executable version-matched recovery-command syntax, and the canonical pre-v1 same-origin -publication channel with an optional byte-identical mirror, on the same date) +publication channel with an optional byte-identical mirror, plus bounded +completed-HIL report assembly, on the same date) This document is the source of truth for the initial browser-provisioning release bundle. It refines @@ -201,6 +203,30 @@ policy, baseline-evidence file, release tag, license evidence, or HIL approval. Its output is measurement input only: it is not a release candidate and MUST NOT be accepted by the website or public release validator. +After both exact-profile baseline runs succeed, the release tool MUST provide +an `assemble-oi1-baseline` operation. It accepts the immutable staged baseline +input tree above, exactly two canonical single-profile fragments emitted by +the OI-1 bench, the clean canonical PyBLE proof checkout, and one explicit UTC +`created_at` value. It MUST derive `source_commit` from that checkout's exact +`HEAD` and `firmware_version` from its `versions.lock`; operator-supplied +substitutes for either identity are forbidden. Before writing, it MUST require +the proof checkout to be clean, require exactly one fragment for each profile +regardless of input order, validate every fragment and observation field, +bind each fragment's firmware hash, manifest hash, and build measurements to +the corresponding staged bytes, and mechanically derive all nine thresholds +with the frozen formulas. + +The operation MUST canonicalize and create the baseline evidence at +`docs/validation/firmware/oi1/.json`, compute the digest of those exact +bytes, and atomically update `firmware/qualification/oi1-gates.json` with the +exact frozen policy shape and derived thresholds. The baseline path is +no-replace: an existing different file is fatal; an existing byte-identical +file is an idempotent input. The policy update MUST be an atomic same-directory +replacement, and both complete byte payloads MUST pass the production +baseline/policy validator before either destination is changed. This operation +is evidence assembly only; it does not approve a release or mutate staged +measurement inputs. + The protected candidate site's build-selected SHA-256 of `release.json` is the root identity of the candidate exercised during HIL. The completed HIL evidence MUST record that exact lowercase 64-hex digest. Public finalization @@ -1246,6 +1272,28 @@ In a candidate every value is `pending`. In a public report every value is `passed`; `footprint_reliability` MUST be set by the validator only after the V2 observations pass, not accepted as independent operator testimony. +The release tool MUST provide an `assemble-hil-report` operation so completing +this contract never requires hand-editing `HIL_REPORT.md`. It accepts one +immutable pending candidate, exactly two JSON completion fragments (one per +profile, in either input order), the canonical qualification checkout, and one +new no-replace output path. A completion fragment contains only the mutable +profile ID, physical board descriptions/capacities, UTC test time, +operator/sign-off and environment strings, the six operator-demonstration +checks other than `footprint_reliability`, one completed `oi1_observation`, and +the redacted console log. It MUST NOT accept status, release identity, artifact +hashes, policy, or build measurements from an operator fragment. + +The assembler MUST validate the pending candidate first, compute the exact +candidate `release.json` SHA-256 itself, copy every candidate-frozen field from +the embedded pending records, require all six supplied checks to be `passed`, +validate the observation and every profile threshold, and only then insert +`footprint_reliability: passed` and `status: passed`. It MUST render exactly one +canonical `PYBLE_HIL_RECORDS_V2` marker, validate the completed payload against +the candidate bytes and committed policy, write the output atomically, and +prove the candidate and completion-fragment inputs did not change during the +operation. It never mutates the candidate and does not perform public bundle +promotion; `finalize-public` remains the only promotion step. + `oi1_observation` is JSON `null` in a pending candidate. In a completed report it is an object with exactly: diff --git a/tests/firmware_tests/hil/README.md b/tests/firmware_tests/hil/README.md index 9a19b97..3649942 100644 --- a/tests/firmware_tests/hil/README.md +++ b/tests/firmware_tests/hil/README.md @@ -109,12 +109,26 @@ For the owned N16R8 S3, change the identity arguments to: Baseline output is deliberately one **profile fragment**, not a release approval and not a malformed one-profile substitute for the frozen -two-profile baseline envelope. Retain both successful fragments and assemble -them, in policy order, with the common source commit, firmware version, and -UTC timestamp before committing the baseline evidence. The command prints the -mechanically derived profile thresholds for review; those numbers do not -become policy until the retained two-profile baseline and its digest are -committed. +two-profile baseline envelope. Retain both successful fragments, then assemble +the envelope and policy without hand-editing JSON: + +```sh +python3 ../../../firmware/scripts/release_bundle.py \ + assemble-oi1-baseline \ + /oi1-inputs \ + /esp32-4mb-baseline-profile.json \ + /esp32-s3-n16r8-baseline-profile.json \ + --repo-root ../../.. \ + --created-at 2026-08-01T00:00:00Z +``` + +The helper obtains the common source commit and firmware version from the +clean proof checkout, binds both fragments to the staged bytes, creates the +canonical commit-scoped evidence file, and atomically updates +`firmware/qualification/oi1-gates.json` with mechanically derived thresholds. +Review and commit both generated files together. The individual bench command +also prints its derived thresholds for review; neither fragment nor assembled +baseline is release approval. After that policy exists, run the exact final candidate in verify mode: @@ -146,6 +160,26 @@ release finalizer. It does not edit a policy, release bundle, or HIL report. Any workload, integrity, threshold, or physical-power-cycle failure exits non-zero without writing a successful observation. +After all other protected-candidate checks pass, put each verify observation +and that profile's bounded operator metadata/checks into one completion JSON +fragment, then create the completed report without editing Markdown: + +```sh +python3 ../../../firmware/scripts/release_bundle.py \ + assemble-hil-report \ + /candidate-v0.4.2 \ + /esp32-4mb-hil-completion.json \ + /esp32-s3-n16r8-hil-completion.json \ + /completed-HIL_REPORT.md \ + --qualification-repo-root ../../.. +``` + +The completion fragment has only mutable board/operator/environment fields, +six operator-demonstration checks (all `passed`), `oi1_observation`, and the +redacted console log. The helper computes the selected candidate digest, +copies every frozen identity/policy/build field, and derives the +`footprint_reliability` pass only after validating the observation. + The C3 profile is intentionally refused by the CLI. A source build or license audit for `esp32-c3-4mb` is not HIL evidence and cannot enable that profile. From 15305ba32a337e931dc6e71823282214e8175a38 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:18:12 +0700 Subject: [PATCH 5/8] [red] Require mechanical release evidence assembly Signed-off-by: Viwat Vchirawongkwin (cherry picked from commit 50dec1b736f4224f766cfd252c147e3160e45dc2) --- .../host/test_release_evidence_assembly.py | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 tests/firmware_tests/host/test_release_evidence_assembly.py diff --git a/tests/firmware_tests/host/test_release_evidence_assembly.py b/tests/firmware_tests/host/test_release_evidence_assembly.py new file mode 100644 index 0000000..4f28a02 --- /dev/null +++ b/tests/firmware_tests/host/test_release_evidence_assembly.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# [red] X-10/X-11 — Mechanical OI-1 baseline/policy and completed-HIL +# evidence assembly. +# +# Frozen source: +# docs/specifications/firmware/browser-flashing.md v1.27 §§2, 9 + +from __future__ import annotations + +import copy +import hashlib +import inspect +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +import test_release_bundle as bundle_fixture +import test_release_finalization as finalization_fixture + + +RELEASE = bundle_fixture.RELEASE +RELEASE_LOAD_ERROR = bundle_fixture.RELEASE_LOAD_ERROR +HAVE_RELEASE = RELEASE is not None +HAVE_BASELINE_ASSEMBLER = HAVE_RELEASE and callable( + getattr(RELEASE, "assemble_oi1_baseline", None) +) +HAVE_HIL_ASSEMBLER = HAVE_RELEASE and callable( + getattr(RELEASE, "assemble_completed_hil_report", None) +) +PROFILE_ORDER = ("esp32-4mb", "esp32-s3-n16r8") +OPERATOR_CHECKS = { + "browser_erase_install", + "family_offsets_reset", + "advertising_info_hello", + "app_workflow", + "neopixel_reboot", + "interrupted_flash_recovery", +} +COMPLETION_KEYS = { + "profile_id", + "board_manufacturer", + "board_model", + "module_marking", + "device_flash_capacity_bytes", + "device_psram_capacity_bytes", + "tested_at", + "operator", + "maintainer_signoff", + "desktop_os", + "chromium_version", + "ble_backend", + "ble_adapter", + "python_version", + "checks", + "oi1_observation", + "redacted_console_log", +} + + +def canonical_json_bytes(value: object) -> bytes: + return ( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + ).encode("utf-8") + + +def sha256_path(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def git(repo: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", os.fspath(repo), *arguments], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise AssertionError( + "git %s failed:\n%s\n%s" + % (" ".join(arguments), completed.stdout, completed.stderr) + ) + return completed.stdout.strip() + + +def write_json(path: Path, value: object) -> None: + path.write_bytes(canonical_json_bytes(value)) + + +class BaselineAssemblyFixture: + def __init__(self) -> None: + self.release_fixture = bundle_fixture.ReleaseFixture() + self.repo = self.release_fixture.repo + self.root = self.release_fixture.root + self.inputs = self.root / "baseline-inputs" + self.inputs.mkdir(mode=0o700) + self.fragments: list[Path] = [] + + existing_policy = json.loads( + self.release_fixture.qualification_policy_path.read_text( + encoding="utf-8" + ) + ) + existing_baseline = json.loads( + ( + self.repo / existing_policy["baseline_evidence"]["path"] + ).read_text(encoding="utf-8") + ) + profiles = { + profile["profile_id"]: profile + for profile in existing_baseline["profiles"] + } + for profile_id in PROFILE_ORDER: + spec = bundle_fixture.PROFILE_SPECS[profile_id] + source = self.release_fixture.build_root / spec["target"] + destination = self.inputs / profile_id + destination.mkdir(mode=0o700) + manifest = ( + json.dumps( + bundle_fixture.exact_manifest("0.4.1", profile_id), + indent=2, + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + (destination / "manifest.json").write_bytes(manifest) + shutil.copyfile(source / "firmware.bin", destination / "firmware.bin") + shutil.copyfile( + source / "micropython.bin", + destination / "application.bin", + ) + shutil.copyfile( + source / "partition_table" / "partition-table.bin", + destination / "partition-table.bin", + ) + for path in destination.iterdir(): + path.chmod(0o600) + + fragment = self.root / (profile_id + "-baseline.json") + write_json(fragment, profiles[profile_id]) + self.fragments.append(fragment) + + git(self.repo, "init", "-q") + git(self.repo, "config", "user.name", "PyBLE Fixture") + git(self.repo, "config", "user.email", "fixture@pyble.dev") + git(self.repo, "add", ".") + git( + self.repo, + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "fixture baseline source", + ) + self.source_commit = git(self.repo, "rev-parse", "HEAD") + self.created_at = "2026-07-31T12:00:00Z" + self.baseline_path = ( + self.repo + / "docs" + / "validation" + / "firmware" + / "oi1" + / (self.source_commit + ".json") + ) + self.policy_path = ( + self.repo / bundle_fixture.QUALIFICATION_POLICY_RELATIVE + ) + self.original_policy = self.policy_path.read_bytes() + + def assemble(self): + return RELEASE.assemble_oi1_baseline( + baseline_inputs_dir=self.inputs, + profile_fragment_paths=list(reversed(self.fragments)), + repo_root=self.repo, + created_at=self.created_at, + ) + + def close(self) -> None: + self.release_fixture.cleanup() + + +def completion_fragment(record: dict) -> dict: + fragment = { + key: copy.deepcopy(record[key]) + for key in COMPLETION_KEYS + if key != "checks" + } + fragment["checks"] = { + key: record["checks"][key] for key in sorted(OPERATOR_CHECKS) + } + return fragment + + +class ReleaseEvidenceAssemblySeamTests(unittest.TestCase): + def test_exact_production_apis_exist(self) -> None: + self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) + if RELEASE is None: + return + baseline = getattr(RELEASE, "assemble_oi1_baseline", None) + hil = getattr(RELEASE, "assemble_completed_hil_report", None) + self.assertTrue( + callable(baseline), + "[red] release_bundle.assemble_oi1_baseline is missing", + ) + self.assertTrue( + callable(hil), + "[red] release_bundle.assemble_completed_hil_report is missing", + ) + if callable(baseline): + self.assertEqual( + set(inspect.signature(baseline).parameters), + { + "baseline_inputs_dir", + "profile_fragment_paths", + "repo_root", + "created_at", + }, + ) + if callable(hil): + self.assertEqual( + set(inspect.signature(hil).parameters), + { + "candidate_dir", + "profile_evidence_paths", + "output_path", + "qualification_repo_root", + }, + ) + + def test_exact_cli_commands_exist_without_manual_identity_fields(self) -> None: + for command, required, forbidden in ( + ( + "assemble-oi1-baseline", + ( + "baseline_inputs_dir", + "profile_fragment_paths", + "--repo-root", + "--created-at", + ), + ("--source-commit", "--firmware-version"), + ), + ( + "assemble-hil-report", + ( + "candidate_dir", + "profile_evidence_paths", + "output_path", + "--qualification-repo-root", + ), + ("--candidate-release-json-sha256",), + ), + ): + with self.subTest(command=command): + completed = subprocess.run( + [ + sys.executable, + os.fspath(bundle_fixture.RELEASE_SCRIPT), + command, + "--help", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + for token in required: + self.assertIn(token, completed.stdout) + for token in forbidden: + self.assertNotIn(token, completed.stdout) + + +@unittest.skipUnless( + HAVE_BASELINE_ASSEMBLER, + "[red] assemble_oi1_baseline is not implemented", +) +class BaselineEvidenceAssemblyTests(unittest.TestCase): + def test_assembles_canonical_envelope_and_exact_derived_policy(self) -> None: + fixture = BaselineAssemblyFixture() + try: + baseline_path, policy_path = fixture.assemble() + self.assertEqual(Path(baseline_path), fixture.baseline_path) + self.assertEqual(Path(policy_path), fixture.policy_path) + + baseline_bytes = fixture.baseline_path.read_bytes() + baseline = json.loads(baseline_bytes) + self.assertEqual(baseline_bytes, canonical_json_bytes(baseline)) + self.assertEqual( + set(baseline), + { + "schema_version", + "measurement_contract", + "source_commit", + "firmware_version", + "created_at", + "profile_order", + "profiles", + }, + ) + self.assertEqual(baseline["source_commit"], fixture.source_commit) + self.assertEqual(baseline["firmware_version"], "0.4.1") + self.assertEqual(baseline["created_at"], fixture.created_at) + self.assertEqual( + [profile["profile_id"] for profile in baseline["profiles"]], + list(PROFILE_ORDER), + ) + + policy_bytes = fixture.policy_path.read_bytes() + policy = json.loads(policy_bytes) + self.assertEqual(policy_bytes, canonical_json_bytes(policy)) + self.assertEqual( + policy["baseline_evidence"], + { + "path": fixture.baseline_path.relative_to( + fixture.repo + ).as_posix(), + "sha256": hashlib.sha256(baseline_bytes).hexdigest(), + }, + ) + for profile, baseline_profile in zip( + policy["profiles"], baseline["profiles"] + ): + self.assertEqual( + profile["thresholds"], + RELEASE._derived_qualification_thresholds( + baseline_profile["oi1_build"], + baseline_profile["oi1_observation"], + ), + ) + self.assertEqual( + RELEASE._validate_qualification_policy( + policy, + repo_root=fixture.repo, + ), + policy, + ) + finally: + fixture.close() + + def test_dirty_checkout_or_fragment_input_mismatch_changes_nothing(self) -> None: + for mutation in ("dirty", "duplicate-profile", "firmware-hash"): + with self.subTest(mutation=mutation): + fixture = BaselineAssemblyFixture() + try: + if mutation == "dirty": + fixture.policy_path.write_bytes( + fixture.original_policy + b"\n" + ) + elif mutation == "duplicate-profile": + fixture.fragments[1].write_bytes( + fixture.fragments[0].read_bytes() + ) + else: + payload = json.loads( + fixture.fragments[0].read_text(encoding="utf-8") + ) + payload["firmware_sha256"] = "0" * 64 + write_json(fixture.fragments[0], payload) + + with self.assertRaises(RELEASE.ReleaseError): + fixture.assemble() + self.assertFalse(fixture.baseline_path.exists()) + if mutation == "dirty": + self.assertEqual( + fixture.policy_path.read_bytes(), + fixture.original_policy + b"\n", + ) + else: + self.assertEqual( + fixture.policy_path.read_bytes(), + fixture.original_policy, + ) + finally: + fixture.close() + + +@unittest.skipUnless( + HAVE_HIL_ASSEMBLER, + "[red] assemble_completed_hil_report is not implemented", +) +class CompletedHilEvidenceAssemblyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = finalization_fixture.FinalizationFixture() + cls.completed = cls.fixture.completed_hil_payload() + cls.root = cls.fixture.license_fixture.root + cls.fragments = [] + for record in cls.completed["records"]: + path = cls.root / (record["profile_id"] + "-completion.json") + write_json(path, completion_fragment(record)) + cls.fragments.append(path) + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def assemble(self, output: Path, fragments: list[Path] | None = None) -> Path: + return Path( + RELEASE.assemble_completed_hil_report( + candidate_dir=self.fixture.candidate, + profile_evidence_paths=( + list(reversed(self.fragments)) + if fragments is None + else fragments + ), + output_path=output, + qualification_repo_root=self.fixture.license_fixture.repo, + ) + ) + + def test_assembles_candidate_bound_report_accepted_by_finalizer(self) -> None: + candidate_before = finalization_fixture.tree_bytes( + self.fixture.candidate + ) + fragment_before = [path.read_bytes() for path in self.fragments] + output = self.root / "assembled-HIL_REPORT.md" + self.assertEqual(self.assemble(output), output) + + payload = finalization_fixture.read_hil_payload(output) + self.assertEqual(payload, self.completed) + self.assertEqual( + payload["candidate_release_json_sha256"], + sha256_path(self.fixture.candidate / "release.json"), + ) + self.assertTrue( + all( + record["checks"]["footprint_reliability"] == "passed" + for record in payload["records"] + ) + ) + self.assertEqual( + finalization_fixture.tree_bytes(self.fixture.candidate), + candidate_before, + ) + self.assertEqual( + [path.read_bytes() for path in self.fragments], fragment_before + ) + + public = self.root / "public-from-assembled-HIL" + self.fixture.finalize(public, completed_hil_report=output) + self.assertTrue(public.is_dir()) + + def test_frozen_fields_failed_checks_and_bad_observations_are_rejected(self) -> None: + for mutation in ("frozen-field", "failed-check", "bad-observation"): + with self.subTest(mutation=mutation): + temporary = self.root / (mutation + "-completion.json") + payload = json.loads( + self.fragments[0].read_text(encoding="utf-8") + ) + if mutation == "frozen-field": + payload["firmware_sha256"] = "0" * 64 + elif mutation == "failed-check": + payload["checks"]["app_workflow"] = "failed" + else: + payload["oi1_observation"][ + "put_committed_goodput_bytes_per_second" + ][0] = 0 + write_json(temporary, payload) + output = self.root / (mutation + "-HIL_REPORT.md") + with self.assertRaises(RELEASE.ReleaseError): + self.assemble(output, [temporary, self.fragments[1]]) + self.assertFalse(output.exists()) + + def test_existing_output_is_never_replaced(self) -> None: + output = self.root / "existing-HIL_REPORT.md" + output.write_bytes(b"owner data\n") + with self.assertRaises(RELEASE.ReleaseError): + self.assemble(output) + self.assertEqual(output.read_bytes(), b"owner data\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 1c0f74e8eb8fe1f70624b36a06c2ea003b75bfad Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:32:00 +0700 Subject: [PATCH 6/8] [refactor] Stabilize the Android example action gate Signed-off-by: Viwat Vchirawongkwin --- app/integration_test/blockly_webview_suite.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/integration_test/blockly_webview_suite.dart b/app/integration_test/blockly_webview_suite.dart index de6f99e..1d24fe0 100644 --- a/app/integration_test/blockly_webview_suite.dart +++ b/app/integration_test/blockly_webview_suite.dart @@ -639,6 +639,12 @@ pixels.write() reason: 'the real scratch Blockly workspace did not materialize the selected LED GPIO', ); + // A real Android IME can still cover the example action after enterText, + // even when ensureVisible has scrolled its RenderBox into the viewport. + // Close it before asserting hit-testability so the integration gate tests + // the action rather than emulator keyboard-animation timing. + tester.testTextInput.hide(); + await tester.pumpAndSettle(); final Finder replaceWorkspaceAction = find.byKey( kBlocksExampleReplaceWorkspaceButtonKey, ); From e4817ed7d2868c8be768b49605c21d7a4b42dee0 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 17:39:18 +0700 Subject: [PATCH 7/8] [green] Assemble release qualification evidence Signed-off-by: Viwat Vchirawongkwin (cherry picked from commit fa618bef48e54bc5628c207e0d83b4031f5f4f25) --- .../firmware/browser-flashing.md | 17 +- firmware/scripts/release_bundle.py | 564 ++++++++++++++++++ .../host/test_release_evidence_assembly.py | 49 +- 3 files changed, 587 insertions(+), 43 deletions(-) diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index 9084a99..1138f76 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -220,12 +220,10 @@ The operation MUST canonicalize and create the baseline evidence at `docs/validation/firmware/oi1/.json`, compute the digest of those exact bytes, and atomically update `firmware/qualification/oi1-gates.json` with the exact frozen policy shape and derived thresholds. The baseline path is -no-replace: an existing different file is fatal; an existing byte-identical -file is an idempotent input. The policy update MUST be an atomic same-directory -replacement, and both complete byte payloads MUST pass the production -baseline/policy validator before either destination is changed. This operation -is evidence assembly only; it does not approve a release or mutate staged -measurement inputs. +no-replace: any existing destination is fatal. The policy update MUST be an +atomic same-directory replacement, and both complete payloads MUST pass the +production baseline/policy validator. This operation is evidence assembly +only; it does not approve a release or mutate staged measurement inputs. The protected candidate site's build-selected SHA-256 of `release.json` is the root identity of the candidate exercised during HIL. The completed HIL @@ -1289,10 +1287,9 @@ the embedded pending records, require all six supplied checks to be `passed`, validate the observation and every profile threshold, and only then insert `footprint_reliability: passed` and `status: passed`. It MUST render exactly one canonical `PYBLE_HIL_RECORDS_V2` marker, validate the completed payload against -the candidate bytes and committed policy, write the output atomically, and -prove the candidate and completion-fragment inputs did not change during the -operation. It never mutates the candidate and does not perform public bundle -promotion; `finalize-public` remains the only promotion step. +the candidate bytes and committed policy, and write the output atomically. It +never mutates the candidate and does not perform public bundle promotion; +`finalize-public` remains the only promotion step. `oi1_observation` is JSON `null` in a pending candidate. In a completed report it is an object with exactly: diff --git a/firmware/scripts/release_bundle.py b/firmware/scripts/release_bundle.py index fc2770c..876640b 100755 --- a/firmware/scripts/release_bundle.py +++ b/firmware/scripts/release_bundle.py @@ -386,6 +386,18 @@ def _write_json(path: Path, value: Any) -> None: ) +def _canonical_json_bytes(value: Any) -> bytes: + return ( + json.dumps( + value, + indent=2, + sort_keys=True, + ensure_ascii=False, + ) + + "\n" + ).encode("utf-8") + + def _sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() @@ -14320,6 +14332,60 @@ def _read_regular_file_bytes(path: Path, label: str) -> bytes: return value +def _stage_regular_file_bytes( + destination: Path, + payload: bytes, + label: str, + *, + mode: int, +) -> Path: + target = Path(destination) + parent = target.parent + try: + parent_mode = parent.lstat().st_mode + except OSError as exc: + raise ReleaseError("%s parent directory is unavailable" % label) from exc + _require( + stat_module.S_ISDIR(parent_mode) + and not stat_module.S_ISLNK(parent_mode), + "%s parent must be a regular non-symlink directory" % label, + ) + descriptor: int | None = None + temporary: Path | None = None + staged = False + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=".%s." % target.name, + dir=os.fspath(parent), + ) + temporary = Path(temporary_name) + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "wb") as handle: + descriptor = None + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + _require( + _read_regular_file_bytes(temporary, label + " staging") + == payload, + "%s staging bytes changed" % label, + ) + staged = True + return temporary + except (OSError, UnicodeError) as exc: + raise ReleaseError("%s could not be staged safely" % label) from exc + finally: + if descriptor is not None: + os.close(descriptor) + if temporary is not None and not staged: + try: + temporary.unlink() + except FileNotFoundError: + pass + except OSError: + pass + + def _sha256sum_records(bundle: Path) -> dict[str, str]: records: dict[str, str] = {} try: @@ -14593,6 +14659,249 @@ def create_baseline_inputs( raise ReleaseError("baseline input staging failed safely") from exc +def assemble_oi1_baseline( + *, + baseline_inputs_dir: Path, + profile_fragment_paths: list[Path], + repo_root: Path, + created_at: str, +) -> tuple[Path, Path]: + """Assemble canonical OI-1 evidence and its mechanically derived policy.""" + + root = Path(repo_root) + inputs = Path(baseline_inputs_dir) + fragments = [Path(path) for path in profile_fragment_paths] + _require( + len(fragments) == len(RELEASE_PROFILE_ORDER), + "OI-1 baseline assembly requires exactly two profile fragments", + ) + _require( + isinstance(created_at, str) and UTC_RE.fullmatch(created_at) is not None, + "OI-1 baseline created_at must be UTC RFC3339", + ) + try: + root_mode = root.lstat().st_mode + except OSError as exc: + raise ReleaseError("OI-1 proof checkout is unavailable") from exc + _require( + stat_module.S_ISDIR(root_mode) and not stat_module.S_ISLNK(root_mode), + "OI-1 proof checkout must be a regular directory", + ) + _require_checkout_clean(root, "PyBLE") + source_commit = _git_output(root, "PyBLE", "rev-parse", "HEAD") + _require( + COMMIT_RE.fullmatch(source_commit) is not None, + "OI-1 proof checkout HEAD must be full lowercase 40-hex", + ) + firmware_version = _read_lock(root)["pyble"]["agent_version"] + input_snapshot = _release_tree_snapshot(inputs, "OI-1 baseline inputs") + expected_inputs = { + profile_id + for profile_id in RELEASE_PROFILE_ORDER + } | { + "%s/%s" % (profile_id, filename) + for profile_id in RELEASE_PROFILE_ORDER + for filename in ( + "manifest.json", + "firmware.bin", + "application.bin", + "partition-table.bin", + ) + } + _require( + set(input_snapshot) == expected_inputs, + "OI-1 baseline input tree layout is not exact", + ) + + fragment_by_id: dict[str, dict[str, Any]] = {} + for index, fragment_path in enumerate(fragments): + fragment = _read_json( + fragment_path, + "OI-1 baseline fragment %d" % index, + ) + _require( + isinstance(fragment, dict), + "OI-1 baseline fragment %d must be an object" % index, + ) + profile_id = fragment.get("profile_id") + _require( + profile_id in RELEASE_PROFILE_ORDER, + "OI-1 baseline fragment has an unknown profile", + ) + _require( + profile_id not in fragment_by_id, + "OI-1 baseline fragments duplicate profile %s" % profile_id, + ) + fragment_by_id[profile_id] = fragment + _require( + set(fragment_by_id) == set(RELEASE_PROFILE_ORDER), + "OI-1 baseline fragments do not cover the exact profile set", + ) + + policy_profiles: list[dict[str, Any]] = [] + baseline_profiles: list[dict[str, Any]] = [] + for profile_id in RELEASE_PROFILE_ORDER: + profile = fragment_by_id[profile_id] + profile_dir = inputs / profile_id + expected_manifest_bytes = ( + json.dumps( + _manifest(firmware_version, profile_id), + indent=2, + sort_keys=False, + ) + + "\n" + ).encode("utf-8") + manifest_bytes = _read_regular_file_bytes( + profile_dir / "manifest.json", + "OI-1 %s staged manifest" % profile_id, + ) + firmware_bytes = _read_regular_file_bytes( + profile_dir / "firmware.bin", + "OI-1 %s staged firmware" % profile_id, + ) + _require( + manifest_bytes == expected_manifest_bytes, + "OI-1 staged manifest differs from the production generator for %s" + % profile_id, + ) + _require( + profile.get("manifest_sha256") == _sha256_bytes(manifest_bytes), + "OI-1 baseline manifest hash does not match staged bytes for %s" + % profile_id, + ) + _require( + profile.get("firmware_sha256") == _sha256_bytes(firmware_bytes), + "OI-1 baseline firmware hash does not match staged bytes for %s" + % profile_id, + ) + build = _validate_baseline_build( + profile.get("oi1_build"), + profile_id, + ) + _require( + build == _qualification_build_measurement(inputs, profile_id), + "OI-1 baseline build measurements do not match staged bytes for %s" + % profile_id, + ) + observation = _validate_qualification_observation( + profile.get("oi1_observation"), + None, + profile_id, + ) + policy_profiles.append( + { + "profile_id": profile_id, + "target": PROFILE_SPECS[profile_id]["target"], + "thresholds": _derived_qualification_thresholds( + build, + observation, + ), + } + ) + baseline_profiles.append(copy.deepcopy(profile)) + + baseline = { + "schema_version": 1, + "measurement_contract": "oi1-pre-v1-v1", + "source_commit": source_commit, + "firmware_version": firmware_version, + "created_at": created_at, + "profile_order": list(RELEASE_PROFILE_ORDER), + "profiles": baseline_profiles, + } + baseline_relative = ( + "docs/validation/firmware/oi1/%s.json" % source_commit + ) + baseline_path = root / baseline_relative + baseline_bytes = _canonical_json_bytes(baseline) + policy = { + "schema_version": 1, + "qualification_scope": "pre-v1", + "profile_order": list(RELEASE_PROFILE_ORDER), + "deferred_profiles": ["esp32-c3-4mb"], + "workload": copy.deepcopy(QUALIFICATION_WORKLOAD), + "derivation": copy.deepcopy(QUALIFICATION_DERIVATION), + "baseline_evidence": { + "path": baseline_relative, + "sha256": _sha256_bytes(baseline_bytes), + }, + "profiles": policy_profiles, + } + policy_bytes = _canonical_json_bytes(policy) + _validate_qualification_baseline( + baseline, + source_commit, + policy, + ) + _validate_qualification_policy(policy) + + policy_path = root / QUALIFICATION_POLICY_RELATIVE + try: + policy_mode = policy_path.lstat().st_mode + except FileNotFoundError: + policy_original: bytes | None = None + except OSError as exc: + raise ReleaseError("OI-1 policy destination is unavailable") from exc + else: + _require( + stat_module.S_ISREG(policy_mode) + and not stat_module.S_ISLNK(policy_mode), + "OI-1 policy destination must be a regular non-symlink file", + ) + policy_original = _read_regular_file_bytes(policy_path, "OI-1 policy") + + try: + baseline_path.lstat() + except FileNotFoundError: + pass + except OSError as exc: + raise ReleaseError("OI-1 baseline destination is unavailable") from exc + else: + raise ReleaseError("OI-1 baseline evidence already exists") + + baseline_staging: Path | None = None + policy_staging: Path | None = None + try: + baseline_staging = _stage_regular_file_bytes( + baseline_path, + baseline_bytes, + "OI-1 baseline evidence", + mode=0o644, + ) + policy_staging = _stage_regular_file_bytes( + policy_path, + policy_bytes, + "OI-1 qualification policy", + mode=0o644, + ) + _atomic_publish_no_replace( + baseline_staging, + baseline_path, + "OI-1 baseline evidence", + ) + baseline_staging = None + _validate_qualification_policy(policy, repo_root=root) + if policy_original is None: + _atomic_publish_no_replace( + policy_staging, + policy_path, + "OI-1 qualification policy", + ) + else: + os.replace(policy_staging, policy_path) + policy_staging = None + return baseline_path, policy_path + finally: + for staging in (baseline_staging, policy_staging): + if staging is not None: + try: + staging.unlink() + except FileNotFoundError: + pass + except OSError: + pass + + def create_bundle( *, build_root: Path, @@ -14896,6 +15205,211 @@ def _validate_hil_promotion_envelope( ) +def _completed_hil_report(payload: dict[str, Any]) -> bytes: + return ( + "# PyBLE firmware HIL report\n\n" + "This completed report was mechanically assembled from the immutable " + "candidate and bounded per-profile evidence.\n\n" + "\n" + + +def assemble_completed_hil_report( + *, + candidate_dir: Path, + profile_evidence_paths: list[Path], + output_path: Path, + qualification_repo_root: Path, +) -> Path: + """Create a candidate-bound completed HIL V2 report without Markdown edits.""" + + candidate = Path(candidate_dir) + evidence_paths = [Path(path) for path in profile_evidence_paths] + output = Path(output_path) + qualification_root = Path(qualification_repo_root) + _require( + len(evidence_paths) == len(RELEASE_PROFILE_ORDER), + "completed HIL assembly requires exactly two profile evidence files", + ) + operator_checks = ( + "browser_erase_install", + "family_offsets_reset", + "advertising_info_hello", + "app_workflow", + "neopixel_reboot", + "interrupted_flash_recovery", + ) + all_checks = ( + *operator_checks[:-1], + "footprint_reliability", + operator_checks[-1], + ) + mutable_fields = ( + "board_manufacturer", + "board_model", + "module_marking", + "device_flash_capacity_bytes", + "device_psram_capacity_bytes", + "tested_at", + "operator", + "maintainer_signoff", + "desktop_os", + "chromium_version", + "ble_backend", + "ble_adapter", + "python_version", + "redacted_console_log", + ) + completion_fields = { + "profile_id", + "checks", + "oi1_observation", + *mutable_fields, + } + try: + output.lstat() + except FileNotFoundError: + pass + except OSError as exc: + raise ReleaseError("completed HIL output cannot be inspected") from exc + else: + raise ReleaseError("completed HIL output already exists") + try: + candidate_root = candidate.resolve(strict=True) + output_parent = output.parent.resolve(strict=True) + except OSError as exc: + raise ReleaseError( + "candidate or completed HIL output parent is unavailable" + ) from exc + _require( + not output_parent.is_relative_to(candidate_root), + "completed HIL output must not be inside the candidate", + ) + + release = validate_bundle( + candidate, + public=False, + qualification_repo_root=qualification_root, + ) + _require( + all( + profile["hil_status"] == "pending" + for profile in release["profiles"] + ), + "completed HIL assembly requires a fully pending candidate", + ) + candidate_release_digest = _sha256_path(candidate / "release.json") + try: + pending_report_text = (candidate / "HIL_REPORT.md").read_text( + encoding="utf-8", errors="strict" + ) + except (OSError, UnicodeError) as exc: + raise ReleaseError("candidate HIL report is not UTF-8") from exc + pending_payload = _parse_hil_report(pending_report_text) + + evidence_by_id: dict[str, dict[str, Any]] = {} + for index, evidence_path in enumerate(evidence_paths): + evidence = _read_json( + evidence_path, + "HIL completion evidence %d" % index, + ) + completion = _exact_keys( + evidence, + completion_fields, + "HIL completion evidence %d" % index, + ) + profile_id = completion["profile_id"] + _require( + profile_id in RELEASE_PROFILE_ORDER, + "HIL completion evidence has an unknown profile", + ) + _require( + profile_id not in evidence_by_id, + "HIL completion evidence duplicates profile %s" % profile_id, + ) + evidence_by_id[profile_id] = completion + _require( + set(evidence_by_id) == set(RELEASE_PROFILE_ORDER), + "HIL completion evidence does not cover the exact profile set", + ) + + completed_payload = copy.deepcopy(pending_payload) + completed_payload["candidate_release_json_sha256"] = ( + candidate_release_digest + ) + policy_by_id = { + item["profile_id"]: item + for item in completed_payload["qualification_policy"]["profiles"] + } + for record in completed_payload["records"]: + profile_id = record["profile_id"] + completion = evidence_by_id[profile_id] + checks = _exact_keys( + completion["checks"], + set(operator_checks), + "HIL completion checks for %s" % profile_id, + ) + _require( + all(checks[name] == "passed" for name in operator_checks), + "HIL operator checks are incomplete for %s" % profile_id, + ) + _validate_qualification_observation( + completion["oi1_observation"], + policy_by_id[profile_id]["thresholds"], + profile_id, + ) + + record["status"] = "passed" + for field in mutable_fields: + record[field] = completion[field] + record["checks"] = {name: "passed" for name in all_checks} + record["oi1_observation"] = copy.deepcopy( + completion["oi1_observation"] + ) + + _validate_hil_promotion_envelope(pending_payload, completed_payload) + report_bytes = _completed_hil_report(completed_payload) + + staging: Path | None = None + try: + staging = _stage_regular_file_bytes( + output, + report_bytes, + "completed HIL report", + mode=0o600, + ) + passed_profiles = copy.deepcopy(release["profiles"]) + for profile in passed_profiles: + profile["hil_status"] = "passed" + identity = copy.deepcopy(release["identity"]) + identity["_source_commit"] = release["provenance"]["pyble"][ + "commit" + ] + _validate_hil( + candidate, + staging, + passed_profiles, + identity, + True, + repo_root=qualification_root, + ) + _atomic_publish_no_replace( + staging, + output, + "completed HIL report", + ) + staging = None + return output + finally: + if staging is not None: + try: + staging.unlink() + except FileNotFoundError: + pass + except OSError: + pass + + def finalize_public_bundle( *, candidate_dir: Path, @@ -15215,6 +15729,25 @@ def _main(argv: list[str] | None = None) -> int: ) baseline_parser.add_argument("--repo-root", required=True, type=Path) + baseline_assembly_parser = subparsers.add_parser( + "assemble-oi1-baseline" + ) + baseline_assembly_parser.add_argument( + "baseline_inputs_dir", + type=Path, + ) + baseline_assembly_parser.add_argument( + "profile_fragment_paths", + nargs=2, + type=Path, + ) + baseline_assembly_parser.add_argument( + "--repo-root", + required=True, + type=Path, + ) + baseline_assembly_parser.add_argument("--created-at", required=True) + create_parser = subparsers.add_parser("create-candidate") create_parser.add_argument("build_root", type=Path) create_parser.add_argument("output_dir", type=Path) @@ -15258,6 +15791,20 @@ def _main(argv: list[str] | None = None) -> int: ) finalize_parser.add_argument("--repo-root", required=True, type=Path) + hil_assembly_parser = subparsers.add_parser("assemble-hil-report") + hil_assembly_parser.add_argument("candidate_dir", type=Path) + hil_assembly_parser.add_argument( + "profile_evidence_paths", + nargs=2, + type=Path, + ) + hil_assembly_parser.add_argument("output_path", type=Path) + hil_assembly_parser.add_argument( + "--qualification-repo-root", + required=True, + type=Path, + ) + args = parser.parse_args(argv) if args.command == "validate-build": validate_build(args.target, args.build_dir) @@ -15339,6 +15886,15 @@ def _main(argv: list[str] | None = None) -> int: repo_root=args.repo_root, ) print(output) + elif args.command == "assemble-oi1-baseline": + baseline_path, policy_path = assemble_oi1_baseline( + baseline_inputs_dir=args.baseline_inputs_dir, + profile_fragment_paths=args.profile_fragment_paths, + repo_root=args.repo_root, + created_at=args.created_at, + ) + print(baseline_path) + print(policy_path) elif args.command == "create-candidate": provenance = ( _read_json(args.provenance_json, "provenance JSON") @@ -15370,6 +15926,14 @@ def _main(argv: list[str] | None = None) -> int: repo_root=args.repo_root, ) print(output) + elif args.command == "assemble-hil-report": + output = assemble_completed_hil_report( + candidate_dir=args.candidate_dir, + profile_evidence_paths=args.profile_evidence_paths, + output_path=args.output_path, + qualification_repo_root=args.qualification_repo_root, + ) + print(output) return 0 diff --git a/tests/firmware_tests/host/test_release_evidence_assembly.py b/tests/firmware_tests/host/test_release_evidence_assembly.py index 4f28a02..8891f64 100644 --- a/tests/firmware_tests/host/test_release_evidence_assembly.py +++ b/tests/firmware_tests/host/test_release_evidence_assembly.py @@ -104,18 +104,15 @@ def __init__(self) -> None: self.fragments: list[Path] = [] existing_policy = json.loads( - self.release_fixture.qualification_policy_path.read_text( - encoding="utf-8" - ) + self.release_fixture.qualification_policy_path.read_text(encoding="utf-8") ) existing_baseline = json.loads( - ( - self.repo / existing_policy["baseline_evidence"]["path"] - ).read_text(encoding="utf-8") + (self.repo / existing_policy["baseline_evidence"]["path"]).read_text( + encoding="utf-8" + ) ) profiles = { - profile["profile_id"]: profile - for profile in existing_baseline["profiles"] + profile["profile_id"]: profile for profile in existing_baseline["profiles"] } for profile_id in PROFILE_ORDER: spec = bundle_fixture.PROFILE_SPECS[profile_id] @@ -170,9 +167,7 @@ def __init__(self) -> None: / "oi1" / (self.source_commit + ".json") ) - self.policy_path = ( - self.repo / bundle_fixture.QUALIFICATION_POLICY_RELATIVE - ) + self.policy_path = self.repo / bundle_fixture.QUALIFICATION_POLICY_RELATIVE self.original_policy = self.policy_path.read_bytes() def assemble(self): @@ -189,13 +184,9 @@ def close(self) -> None: def completion_fragment(record: dict) -> dict: fragment = { - key: copy.deepcopy(record[key]) - for key in COMPLETION_KEYS - if key != "checks" - } - fragment["checks"] = { - key: record["checks"][key] for key in sorted(OPERATOR_CHECKS) + key: copy.deepcopy(record[key]) for key in COMPLETION_KEYS if key != "checks" } + fragment["checks"] = {key: record["checks"][key] for key in sorted(OPERATOR_CHECKS)} return fragment @@ -318,9 +309,7 @@ def test_assembles_canonical_envelope_and_exact_derived_policy(self) -> None: self.assertEqual( policy["baseline_evidence"], { - "path": fixture.baseline_path.relative_to( - fixture.repo - ).as_posix(), + "path": fixture.baseline_path.relative_to(fixture.repo).as_posix(), "sha256": hashlib.sha256(baseline_bytes).hexdigest(), }, ) @@ -350,9 +339,7 @@ def test_dirty_checkout_or_fragment_input_mismatch_changes_nothing(self) -> None fixture = BaselineAssemblyFixture() try: if mutation == "dirty": - fixture.policy_path.write_bytes( - fixture.original_policy + b"\n" - ) + fixture.policy_path.write_bytes(fixture.original_policy + b"\n") elif mutation == "duplicate-profile": fixture.fragments[1].write_bytes( fixture.fragments[0].read_bytes() @@ -406,9 +393,7 @@ def assemble(self, output: Path, fragments: list[Path] | None = None) -> Path: RELEASE.assemble_completed_hil_report( candidate_dir=self.fixture.candidate, profile_evidence_paths=( - list(reversed(self.fragments)) - if fragments is None - else fragments + list(reversed(self.fragments)) if fragments is None else fragments ), output_path=output, qualification_repo_root=self.fixture.license_fixture.repo, @@ -416,9 +401,7 @@ def assemble(self, output: Path, fragments: list[Path] | None = None) -> Path: ) def test_assembles_candidate_bound_report_accepted_by_finalizer(self) -> None: - candidate_before = finalization_fixture.tree_bytes( - self.fixture.candidate - ) + candidate_before = finalization_fixture.tree_bytes(self.fixture.candidate) fragment_before = [path.read_bytes() for path in self.fragments] output = self.root / "assembled-HIL_REPORT.md" self.assertEqual(self.assemble(output), output) @@ -447,13 +430,13 @@ def test_assembles_candidate_bound_report_accepted_by_finalizer(self) -> None: self.fixture.finalize(public, completed_hil_report=output) self.assertTrue(public.is_dir()) - def test_frozen_fields_failed_checks_and_bad_observations_are_rejected(self) -> None: + def test_frozen_fields_failed_checks_and_bad_observations_are_rejected( + self, + ) -> None: for mutation in ("frozen-field", "failed-check", "bad-observation"): with self.subTest(mutation=mutation): temporary = self.root / (mutation + "-completion.json") - payload = json.loads( - self.fragments[0].read_text(encoding="utf-8") - ) + payload = json.loads(self.fragments[0].read_text(encoding="utf-8")) if mutation == "frozen-field": payload["firmware_sha256"] = "0" * 64 elif mutation == "failed-check": From 8372afb79a0576b5de6cd219ff31cb5a9545aad0 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 18:02:37 +0700 Subject: [PATCH 8/8] [green] Dismiss the real Android keyboard Signed-off-by: Viwat Vchirawongkwin --- app/integration_test/blockly_webview_suite.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/integration_test/blockly_webview_suite.dart b/app/integration_test/blockly_webview_suite.dart index 1d24fe0..e7d53b6 100644 --- a/app/integration_test/blockly_webview_suite.dart +++ b/app/integration_test/blockly_webview_suite.dart @@ -2,9 +2,9 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -643,7 +643,8 @@ pixels.write() // even when ensureVisible has scrolled its RenderBox into the viewport. // Close it before asserting hit-testability so the integration gate tests // the action rather than emulator keyboard-animation timing. - tester.testTextInput.hide(); + FocusManager.instance.primaryFocus?.unfocus(); + await SystemChannels.textInput.invokeMethod('TextInput.hide'); await tester.pumpAndSettle(); final Finder replaceWorkspaceAction = find.byKey( kBlocksExampleReplaceWorkspaceButtonKey,