diff --git a/CHANGELOG.md b/CHANGELOG.md index 232f50e..630e277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,18 @@ 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. + +## Firmware 0.4.2 — 2026-07-31 + +- Published the exact hardware-tested beta for `esp32-4mb` and + `esp32-s3-n16r8`; ESP32-C3 remains unavailable. +- Validated production Chrome installation, deliberate interruption, + interrupted-flash recovery, and reset on real hardware for both exact + profiles. +- Bound the public release to its annotated source tag, immutable metadata, + binary hashes, and post-release production-browser attestation. +- The complete release qualification remains pending across the app, PBLE/1, + resource, and remaining firmware matrices. ## App 0.1.0-beta — 2026-07-30 diff --git a/README.md b/README.md index d508b3c..7db7112 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,21 @@ BLE GATT peripheral. It supports: - board naming and identify support; and - upstream MicroPython’s standard `neopixel` module. -The public browser installer is currently unavailable pending v0.4.2 HIL on -both exact current profiles: - -| Installer profile | Exact target constraint | Public status | -| ----------------- | ------------------------------------------------------------ | --------------------------------------------------- | -| `esp32-4mb` | Classic ESP32, 4 MiB external SPI flash; no PSRAM assumed | v0.4.2 HIL pending; installer unavailable | -| `esp32-s3-n16r8` | ESP32-S3, 16 MiB flash / 8 MiB Octal PSRAM; N16R8-class only | v0.4.2 HIL pending; installer unavailable | -| `esp32-c3-4mb` | ESP32-C3, 4 MiB external SPI flash; no PSRAM assumed | Planned; no public image; exact-profile HIL pending | +The public browser installer currently offers the exact v0.4.2 hardware-tested +beta for both current profiles. Production Chrome erase/install and deliberately +interrupted-flash recovery passed on real hardware for both exact profiles. +Complete release qualification continues across the app, PBLE/1, resource, and +remaining firmware matrices: + +| Installer profile | Exact target constraint | Public status | +| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `esp32-4mb` | Classic ESP32, 4 MiB external SPI flash; no PSRAM assumed | v0.4.2 hardware-tested beta; browser install/recovery passed | +| `esp32-s3-n16r8` | ESP32-S3, 16 MiB flash / 8 MiB Octal PSRAM; N16R8-class only | v0.4.2 hardware-tested beta; browser install/recovery passed | +| `esp32-c3-4mb` | ESP32-C3, 4 MiB external SPI flash; no PSRAM assumed | Planned; unavailable; no public image | + +See the +[post-release production-browser attestation](docs/validation/browser-flashing/v0.4.2-production.md) +for the exact hashes, completed checks, and deliberately bounded claim. These are the initial port targets, not a chip-family allowlist. A future board is compatible when it has a maintained PyBLE agent port, BLE GATT @@ -106,12 +113,14 @@ shared conformance corpus, documentation, and CI atomically. 1. Install the iPad beta from [TestFlight](https://testflight.apple.com/join/yU4e8s6d), or build the Flutter app locally. -2. Check [pyble.dev/flash](https://pyble.dev/flash) in desktop Chrome or Edge. - The public installer is currently unavailable pending v0.4.2 HIL. Wait for - that page to show an active release version, your exact profile, and an - enabled install action. -3. Only after that gate opens, back up the board, confirm its exact memory - profile, and use the one-time wired installer. Flashing erases the board. +2. Open [pyble.dev/flash](https://pyble.dev/flash) in desktop Chrome or Edge. + The exact v0.4.2 hardware-tested beta is active. Browser installation and + interrupted-flash recovery passed on both exact profiles; complete release + qualification continues. Confirm the active version, your exact profile, + and the enabled install action. +3. Before flashing, back up the board, confirm its exact memory profile, and + accept every safety acknowledgement before using the one-time wired + installer. Flashing erases the board. 4. Open PyBLE, scan for the provisioned board, connect, and run an example over BLE. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index aeb3354..571e313 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,13 +8,15 @@ promising dates; accepted work is tracked through GitHub issues and milestones. - iPad external beta through TestFlight - PBLE/1 editing, run/stop, console, and file workflows over BLE - Offline Blockly with beginner GPIO and NeoPixel examples -- Browser installation for qualified `esp32-4mb` and `esp32-s3-n16r8` - profiles +- Browser installation for the exact `esp32-4mb` and `esp32-s3-n16r8` profiles + as the v0.4.2 hardware-tested beta; production Chrome install/recovery passed + on both profiles - MIT-licensed app, agent firmware, protocol, website, tests, and release tools ## Near term -- Re-establish firmware release provenance from the canonical public history +- Complete the app, PBLE/1, resource, and remaining firmware release + qualification for the exact v0.4.2 bytes - Complete real-hardware qualification before enabling the ESP32-C3 installer - Expand user-facing setup, recovery, and board-specific wiring guidance - Open and document the Android beta distribution path diff --git a/docs/specifications/firmware.md b/docs/specifications/firmware.md index 7459b39..99c225b 100644 --- a/docs/specifications/firmware.md +++ b/docs/specifications/firmware.md @@ -1,6 +1,6 @@ # PyBLE — Agent Firmware -Status: **DRAFT** · Last updated: 2026-07-30 +Status: **DRAFT** · Last updated: 2026-08-01 The PyBLE agent is small board-side firmware that turns a compatible MicroPython target into a PyBLE-speaking board: it advertises the BLE service, @@ -67,14 +67,15 @@ confined to Layer 2 (board overlay: pins, flash size, USB), and the shared agent core contains no per-chip product logic. The browser installer does not publish unqualified family-wide images. The -v0.4.2 candidate set is `esp32-4mb` (classic ESP32, 4 MiB flash) and -`esp32-s3-n16r8` (ESP32-S3, 16 MiB flash plus 8 MiB Octal PSRAM), but the -public browser installer remains unavailable pending final HIL on the exact -candidate bytes for both profiles. `esp32-c3-4mb` remains a known initial v1 -profile but is not released or selectable until exact-profile real-hardware -validation is complete. ESP Web Tools detects the chip family but cannot by -that fact alone prove the required flash/PSRAM topology. The full compatibility -and artifact contract is frozen in +exact v0.4.2 bundle is offered as a hardware-tested beta for `esp32-4mb` +(classic ESP32, 4 MiB flash) and `esp32-s3-n16r8` (ESP32-S3, 16 MiB flash plus +8 MiB Octal PSRAM). On both exact profiles, browser installation and interrupted-flash recovery passed; +complete release qualification remains pending. +`esp32-c3-4mb` remains a known initial v1 profile but is unavailable +and has no public image until exact-profile real-hardware validation is +complete. ESP Web Tools detects the chip family but cannot by that fact alone +prove the required flash/PSRAM topology. The full compatibility, artifact, and +bounded public-beta contracts are frozen in [firmware/browser-flashing.md](firmware/browser-flashing.md). These targets are the initial reference/build family, not the product boundary. @@ -118,13 +119,13 @@ MUST NOT require a known-chip allowlist. The measurement method is frozen in [firmware/specs.md §5.3](firmware/specs.md#53-footprint-gates-nfr-fp); numeric values remain provisional until derived from retained baseline samples. -The v0.4.2 candidate qualification scope is profile-scoped: +The current v0.4.2 qualification work remains profile-scoped: | Profile | Current numeric status | Release effect | |---|---|---| -| `esp32-4mb` | Measure, derive, freeze, and verify on the owned exact profile | Required before v0.4.2 candidate qualification and installer activation | -| `esp32-s3-n16r8` | Measure, derive, freeze, and verify on the owned exact N16R8 profile | Required before v0.4.2 candidate qualification and installer activation | -| `esp32-c3-4mb` | Deferred; no current threshold or HIL row | Blocks C3 enablement and v1.0, but not qualification of the two-profile candidate | +| `esp32-4mb` | Browser install/recovery passed; numeric/resource qualification pending | Enabled only by the exact v0.4.2 public-beta exception; required for qualification | +| `esp32-s3-n16r8` | Browser install/recovery passed; numeric/resource qualification pending | Enabled only by the exact v0.4.2 public-beta exception; required for qualification | +| `esp32-c3-4mb` | Deferred; no current threshold or HIL row | Blocks C3 enablement and v1.0; absent from the v0.4.2 beta | The enforced metrics are: diff --git a/docs/specifications/firmware/TDD.md b/docs/specifications/firmware/TDD.md index c002112..4292583 100644 --- a/docs/specifications/firmware/TDD.md +++ b/docs/specifications/firmware/TDD.md @@ -528,8 +528,10 @@ Download (`FILE_GET_*`) is the symmetric streamer: `FILE_GET_BEGIN{path,offset}` Static, boot-time allocation of all large buffers (D3) makes resource headroom repeatable enough to measure after HELLO and after transfer workloads. -The current pre-v1 release measures the owned exact profiles -`esp32-4mb` and `esp32-s3-n16r8`. The S3's PSRAM is useful Python headroom but +The current v0.4.2 public-beta qualification work measures the owned exact +profiles `esp32-4mb` and `esp32-s3-n16r8`. Its supplemental production-browser +rows passed, while its formal resource and remaining HIL rows stay open. The +S3's PSRAM is useful Python headroom but MUST NOT conceal internal-RAM pressure, so the gate records Python GC memory and internal ESP-IDF heap separately. The design still targets the **ESP32-C3 floor** for v1.0 (single-core RISC-V, ~400 KB SRAM — @@ -720,10 +722,12 @@ pipeline while retaining the shared PBLE/1 conformance gates. generate THIRD_PARTY_LICENSES.txt mechanically BLD-8/14 11. run no-leak, SPDX, manifest/integrity/license/reproducibility gates CON-6, BLD-14/18 -12. publish identical immutable bytes to the versioned same-origin path - and matching GitHub Release only after every included profile passes HIL; - the current pre-v1 gate covers esp32-4mb and esp32-s3-n16r8, while C3 is - unavailable until a later candidate BLD-7/21/22 +12. publish identical immutable bytes to the versioned same-origin path and + matching GitHub Release only after every included profile passes HIL; + alternatively, the exact digest-bound v0.4.2 exception may publish the two + profiles as a hardware-tested beta and GitHub pre-release after both pass + the scoped production-browser install/recovery run; C3 stays unavailable + BLD-7/21/22 ``` ### 10.2 Entry points @@ -774,8 +778,8 @@ exact public tree, manifest, separate integrity/provenance metadata, recovery, HIL report, activation, and rollback are frozen in [browser-flashing.md](browser-flashing.md). Identical immutable bytes publish both at the versioned `pyble.dev` path and through the matching GitHub Release, -with exact release-profile parity: two qualified profiles in the current -pre-v1 release and all three at v1.0 (BLD-7/17…22). `DEVICE_INFO`/HELLO, +with exact release-profile parity: two hardware-tested beta profiles in v0.4.2 +and all three qualified profiles at v1.0 (BLD-7/17…22). `DEVICE_INFO`/HELLO, `manifest.json`/`release.json`, tag, and release notes make agent/protocol/ upstream/source/artifact versions recoverable (BLD-13); the agent follows SemVer (BLD-12). @@ -1077,7 +1081,7 @@ Chip facts are owned by [hardware.md §1](../hardware.md#1-supported-chip-famili frozen-Python does not fit, hot paths go native (D1, [§8.6](#86-esp32-c3-mitigation)). The known `esp32-c3-4mb` provisioning profile is defined only for C3 silicon revision v0.3 or newer but remains - unavailable in the current pre-v1 release pending exact-profile HIL. Its + unavailable in the current v0.4.2 public beta pending exact-profile HIL. Its exact image revision window appears in release metadata only after a later candidate qualifies it. @@ -1155,22 +1159,27 @@ PBLE/1 **conformance** tests run against an **in-memory fake transport** shared toolchain-distribution license bytes from the trusted ESP-IDF download cache, exact metadata/cache/install binding with distinct archive and version roots, absence of host-absolute paths in receipts, and profile-specific zero-input - not-shipped proof. + not-shipped proof. Supplemental source-tree digests exclude Python bytecode + cache artifacts while the audit rejects any such artifacts in the retained + checkout; release builds force bytecode generation off so checkout-local + absolute paths cannot contaminate otherwise identical source evidence. - **size:** enforce the total application-image ceiling and derived factory-partition headroom floor during build/candidate validation. Continue structural application-fit checks on all three source targets, including deferred C3. Heap, boot, goodput, and reliability are not mislabeled as static size gates. - **HIL:** the release-blocking bench runs on every exact profile included in - the release. For the current pre-v1 candidate that is exactly + the release. For the v0.4.2 formal candidate matrix that is exactly `esp32-4mb` and `esp32-s3-n16r8`; it covers the frozen §8.5 resource workload, multi-file integrity (NFR-REL-5), STOP authority (NFR-SAFE-1), cold-boot safety (NFR-SAFE-3), candidate-browser install, and interrupted-flash recovery from an access-controlled, production-equivalent HTTPS deployment (BLD-20/21). C3 HIL and footprint/goodput gates remain open, block C3 enablement, and block v1.0. - Public activation then needs only the non-destructive origin/integrity smoke - defined by BLD-22. + The later supplemental production-browser run completed only the browser + install and interrupted-recovery rows for both profiles. The other formal + rows remain open; the exact public-beta activation follows the bounded + exception in browser-flashing §10 rather than claiming BLD-21 completion. ### 14.4 Required red matrix for pre-v1 qualification @@ -1240,12 +1249,12 @@ Design element → satisfied requirement IDs. Each `FR-*` block has at least one - **R2 — Frozen→native trigger point.** Which paths move to C, and on which chip the budget forces it, is undecided until HIL measurement (OI-3). The module boundaries ([§4](#4-module-design)) are drawn to make the move contract-neutral (NFR-MAINT-3). - **R3 — iOS/Android BLE MTU quirks.** Central platforms negotiate MTU differently and may not grant 247; the firmware must operate correctly across the negotiated MTU down to the default (FR-BLE-8). Fragmentation/reassembly is tested across an MTU matrix ([§14.1](#141-per-module-verification-approach)). - **R4 — Single-core C3 STOP latency.** With one core the runner and BLE/agent task time-share; STOP must still land promptly against a tight loop ([§5.2](#52-stop-delivery)). Validate on C3 HIL first ([§11](#11-per-chip-design-notes)). -- **R5 — Candidate pins not yet selected or HIL-approved.** `versions.lock` - values (MicroPython v1.28.0 / ESP-IDF v5.5.1) remain proposed defaults until - selected as candidate-frozen inputs before the release builds and HIL - (OI-2). Candidate-freezing makes the input immutable; it does not approve - C3 compatibility or public release. A pin change creates a new candidate and - reruns all build, audit, deployment, and exact-profile HIL gates through +- **R5 — v0.4.2 candidate pins are selected but not fully HIL-approved.** The + exact `versions.lock` values (MicroPython v1.28.0 / ESP-IDF v5.5.1) are + candidate-frozen for v0.4.2 (OI-2). Candidate-freezing makes the input + immutable; it does not approve the remaining formal matrix, C3 compatibility, + or a qualified release. A pin change creates a new candidate and reruns all + build, audit, deployment, and exact-profile HIL gates through `upgrade_micropython.sh` (BLD-9/19/21). - **R6 — PBLE/1 still DRAFT.** Opcode/UUID/status numbers are provisional until [protocol.md](../protocol.md) §2/§4 freeze (OI-4); the single constants mirror (D6) localizes the churn. - **R7 — Auto-run caps flag naming.** The opt-in `main.py` auto-run flag name/encoding is owned by [protocol.md §7](../protocol.md#7-hello--capabilities) and must be fixed before F-12 (OI-5). diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index 24834e0..79199ee 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -1,6 +1,6 @@ # PyBLE ESP32-Family Browser Flashing and Release Bundle -Status: **FROZEN v1.27** · Owner: project maintainer · Frozen: +Status: **FROZEN v1.28** · 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 and mechanical baseline/policy @@ -20,7 +20,8 @@ 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, plus bounded -completed-HIL report assembly, on the same date) +completed-HIL report assembly, plus the exact one-version unqualified public +beta exception, on the same date) This document is the source of truth for the initial browser-provisioning release bundle. It refines @@ -37,21 +38,23 @@ BLE and PBLE/1. ## 1. Release image profiles -The v0.4.2 release candidate targets exactly these two **provisioning image -profiles**. Neither becomes a qualified public profile until the complete -reproducibility, license, exact-byte HIL, and activation gate passes: +The current pre-v1 browser bundle contains exactly these two +**provisioning image profiles**. The exact audited `v0.4.2` bytes may be offered +only as the hardware-tested public beta defined in §10; that narrow validation +does not make them qualified-release profiles. +A later qualified public bundle uses the same exact profile definitions: -| Profile ID | ESP Web Tools `chipFamily` | Required target configuration | ESP image silicon window (`min_chip_rev_full`…`max_chip_rev_full`) | Merge settings | Browser image and component map | -|---|---|---|---|---|---| -| `esp32-4mb` | `ESP32` | Classic ESP32 with 4 MiB external SPI flash; no PSRAM assumption | `0`…`399` | DIO, 40 MHz, 4 MiB | merged `firmware.bin` at `0x1000`; bootloader `0x1000`; partition table `0x8000`; application `0x10000` | -| `esp32-s3-n16r8` | `ESP32-S3` | ESP32-S3 with 16 MiB flash and 8 MiB **Octal** PSRAM (N16R8-class) | `0`…`99` | DIO, 80 MHz, 16 MiB | merged `firmware.bin` at `0x0000`; bootloader `0x0000`; partition table `0x8000`; application `0x10000` | +| Profile ID | ESP Web Tools `chipFamily` | Required target configuration | ESP image silicon window (`min_chip_rev_full`…`max_chip_rev_full`) | Merge settings | Browser image and component map | +| ---------------- | -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------- | +| `esp32-4mb` | `ESP32` | Classic ESP32 with 4 MiB external SPI flash; no PSRAM assumption | `0`…`399` | DIO, 40 MHz, 4 MiB | merged `firmware.bin` at `0x1000`; bootloader `0x1000`; partition table `0x8000`; application `0x10000` | +| `esp32-s3-n16r8` | `ESP32-S3` | ESP32-S3 with 16 MiB flash and 8 MiB **Octal** PSRAM (N16R8-class) | `0`…`99` | DIO, 80 MHz, 16 MiB | merged `firmware.bin` at `0x0000`; bootloader `0x0000`; partition table `0x8000`; application `0x10000` | `esp32-c3-4mb` remains a known provisioning profile and an initial v1 firmware target, with this frozen future qualification: -| Deferred profile ID | ESP Web Tools `chipFamily` | Required target configuration | ESP image silicon window (`min_chip_rev_full`…`max_chip_rev_full`) | Merge settings | Browser image and component map | -|---|---|---|---|---|---| -| `esp32-c3-4mb` | `ESP32-C3` | ESP32-C3 revision v0.3 or newer with 4 MiB external flash; no PSRAM assumption | `3`…`199` | DIO, 80 MHz, 4 MiB | merged `firmware.bin` at `0x0000`; bootloader `0x0000`; partition table `0x8000`; application `0x10000` | +| Deferred profile ID | ESP Web Tools `chipFamily` | Required target configuration | ESP image silicon window (`min_chip_rev_full`…`max_chip_rev_full`) | Merge settings | Browser image and component map | +| ------------------- | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | +| `esp32-c3-4mb` | `ESP32-C3` | ESP32-C3 revision v0.3 or newer with 4 MiB external flash; no PSRAM assumption | `3`…`199` | DIO, 80 MHz, 4 MiB | merged `firmware.bin` at `0x0000`; bootloader `0x0000`; partition table `0x8000`; application `0x10000` | That deferred profile is **not** part of the current public release: it MUST NOT have an entry in `release.json`, a public manifest or binary directory, or @@ -164,7 +167,7 @@ as immutable inputs to one release candidate. Candidate-freezing MUST happen before the two clean release builds, license audit, candidate packaging, protected-site staging, or HIL. It is an input-selection state only: it does **not** assert that the pins work on hardware and does not approve them for a -public release. Exact-profile HIL on both current candidate profiles, +public release. Exact-profile HIL on both current release profiles, `esp32-4mb` and `esp32-s3-n16r8`, remains the pre-v1 public-release approval gate. @@ -243,6 +246,7 @@ block same-origin publication. ## 3. Same-origin, versioned layout The canonical public files are static and use this exact layout: + ```text https://pyble.dev/firmware/v/ release.json @@ -327,9 +331,7 @@ one build for its owning profile. For example, "builds": [ { "chipFamily": "ESP32-S3", - "parts": [ - { "path": "firmware.bin", "offset": 0 } - ] + "parts": [{ "path": "firmware.bin", "offset": 0 }] } ] } @@ -337,7 +339,7 @@ one build for its owning profile. For example, `` above is a template substitution, not literal released JSON. The `esp32-4mb` manifest has the same shape and exactly one `ESP32` build with -offset `4096`. There is no C3 manifest in the current candidate. The website MUST +offset `4096`. There is no C3 manifest in the current release. The website MUST set the custom element's manifest URL to the verified manifest for the selected profile only. It MUST NOT pass an all-family catalog to ESP Web Tools or change manifests after the browser has selected a serial device. @@ -390,12 +392,12 @@ beside their metadata: UTC `built_at`; - provenance: full PyBLE commit and clean state; MicroPython ref/commit; ESP-IDF ref/commit; patch count; runner and compiler/tool versions; -- one entry for each current candidate profile in the first table of §1, +- one entry for each current release profile in the first table of §1, including profile ID, `chip_family`, flash/PSRAM requirements, flash mode/frequency, required `silicon_revision.minimum_full` and `silicon_revision.maximum_full` integers matching the §1 image window, and HIL status (`pending` for a candidate or - `passed` for a public bundle); + the exact `v0.4.2` public beta, or `passed` for a qualified public bundle); - one `manifest` entry per profile with its relative path, exact byte size, and lowercase 64-hex SHA-256; - one `install` entry per profile for the merged image, with relative `path`, @@ -409,7 +411,9 @@ beside their metadata: All values are required; placeholders, `unknown`, and abbreviated commits fail any bundle. `pending` is accepted only on an access-controlled candidate used -for HIL; an HIL status other than `passed` fails a public bundle. +for HIL or the exact audited and digest-bound `v0.4.2` `public-beta` exception +in §10; an HIL +status other than `passed` fails every ordinary public bundle. `release.schema.json` itself is versioned and immutable beside the metadata. `SHA256SUMS` MUST use the conventional lowercase-hex, two-space, relative-path format and cover every @@ -458,7 +462,7 @@ public notice. The conservative build audit runs against all six authoritative ESP-IDF descriptions: application and bootloader `project_description.json` for each of the three initial build targets. This preserves the v1 three-target build -and license gate even while the pre-v1 candidate bundle targets two profiles. +and license gate even while the pre-v1 public bundle contains two profiles. The released notice MUST classify as redistributed only the dependency union of the two packaged profiles; C3-only observations remain retained review evidence and MUST NOT be represented as a shipped C3 image or shipped profile. @@ -562,6 +566,7 @@ The following resolution rules are part of that fail-closed mapping: A missing file, unexpected basename, alternate or sibling retained tree, escaped path, near-match root, nonzero generated ELF anchor, or symlinked input is fatal. + 5. A generated component archive has only a stable topology matcher in committed policy. An ordinary matcher contains exactly its ESP-IDF component name. A nested CMake archive produced below a `CONFIG_ONLY` @@ -678,6 +683,7 @@ The following resolution rules are part of that fail-closed mapping: observations. Rechecking only archive/tree digests is insufficient: project/compile/map/link documents, metadata inputs, archive sources, direct outputs, and direct sources are all race-sensitive release inputs. + 6. An archive outside the repository and build roots is admitted only below one versioned toolchain root proven by the exact compile-command executables and the locked ESP-IDF `tools/tools.json` entry. The policy @@ -727,6 +733,7 @@ The following resolution rules are part of that fail-closed mapping: only logical metadata, semantic hashes, constrained relative frontend/member paths, and distribution identity; they MUST NOT contain a host-absolute tools-home, cache, frontend, installed-root, or runtime path. + 7. Each raw package is matched exactly once by profile/role and its complete raw property set, including name, version state, download-location state, copyright state, declared/concluded license states, checksums, annotations, @@ -768,6 +775,7 @@ The following resolution rules are part of that fail-closed mapping: share the same SPDX ID in different profiles; a union expression MUST NOT be invented to make unlike occurrences share one review. Any unexpected property, value, package, relationship, resolution, or ambiguity is fatal. + 8. A raw package that the pinned tool reports but that contributes no linked archive, compiled source, or aggregate project identity may be excluded from the redistribution notice only through an explicit `not-shipped` @@ -810,6 +818,7 @@ The following resolution rules are part of that fail-closed mapping: independent ledger says `allow-aggregate`. The receipt binds both the `shipment_review` policy record and the normalized occurrence classifications. + 9. A redistributed dependency absent from the raw ESP-IDF package graph is a deterministic supplemental SPDX package, not misrepresented as raw tool output. In this release the exact frozen NeoPixel tree and the three linked @@ -895,15 +904,16 @@ The following resolution rules are part of that fail-closed mapping: host-absolute path is retained. A source, metadata version, optimization level, module order, qstr header, `.mpy`, generator, compiler, architecture, copied-board byte, or frozen C mismatch is fatal. -9. Coverage is exact without asserting false package/archive cardinality. - Every observed raw package, linked archive, compiled source, frozen - destination, frozen source tree, - prebuilt blob, and compiler/runtime input is consumed by exactly one - resolution record; each record declares its complete many-to-many - package/input attribution, and aggregate packages may own no archive - directly only through rule 7. Every expected relationship is present, every - declared stable matcher is observed exactly where applicable, and no - unexpected record is accepted. + +10. Coverage is exact without asserting false package/archive cardinality. + Every observed raw package, linked archive, compiled source, frozen + destination, frozen source tree, + prebuilt blob, and compiler/runtime input is consumed by exactly one + resolution record; each record declares its complete many-to-many + package/input attribution, and aggregate packages may own no archive + directly only through rule 7. Every expected relationship is present, every + declared stable matcher is observed exactly where applicable, and no + unexpected record is accepted. The initial runtime/input review explicitly includes `libgcc.a` and `libstdc++.a` under `GPL-3.0-or-later WITH GCC-exception-3.1`; @@ -1090,7 +1100,7 @@ Automated release tests MUST cover: - static-export and candidate/production-origin retrieval of every versioned byte. -One HIL record MUST be completed for each of the two exact current candidate +One HIL record MUST be completed for each of the two exact current release profiles using the final, hash-locked release candidate. The report contains exactly one embedded JSON object marked `PYBLE_HIL_RECORDS_V2`; a V1 marker, an additional marker, or keys not defined below are invalid. @@ -1109,16 +1119,16 @@ Candidate generation MUST read both its parsed JSON object and the lowercase SHA-256 of its exact source bytes. The policy object has exactly these keys: -| Key | Exact value/type | -|---|---| -| `schema_version` | integer `1` | -| `qualification_scope` | string `"pre-v1"` | -| `profile_order` | exact string array `["esp32-4mb", "esp32-s3-n16r8"]` | -| `deferred_profiles` | exact string array `["esp32-c3-4mb"]` | -| `workload` | exact object defined below | -| `derivation` | exact object defined below | -| `baseline_evidence` | exact object `{path, sha256}` | -| `profiles` | two policy-entry objects, in `profile_order` | +| Key | Exact value/type | +| --------------------- | ---------------------------------------------------- | +| `schema_version` | integer `1` | +| `qualification_scope` | string `"pre-v1"` | +| `profile_order` | exact string array `["esp32-4mb", "esp32-s3-n16r8"]` | +| `deferred_profiles` | exact string array `["esp32-c3-4mb"]` | +| `workload` | exact object defined below | +| `derivation` | exact object defined below | +| `baseline_evidence` | exact object `{path, sha256}` | +| `profiles` | two policy-entry objects, in `profile_order` | `workload` has exactly these integer/string keys and values: @@ -1437,9 +1447,10 @@ requires the complete two-profile HIL matrix again. ## 10. Activation and rollback -The public action progresses through `candidate` → `verified` → `published` → -`active`. The public `pyble.dev/flash` action remains disabled while candidate -HIL runs on the access-controlled production-equivalent HTTPS deployment. It +The qualified public action progresses through `candidate` → `verified` → +`published` → `active`. The public `pyble.dev/flash` action remains disabled +while candidate HIL runs on the access-controlled production-equivalent HTTPS +deployment. It is valid for that protected candidate deployment alone to expose the action with `hil_status: pending` after every non-HIL automated/integrity gate is green; the candidate-mode selection MUST be build-time explicit, inaccessible @@ -1465,24 +1476,54 @@ manifest. Rollback changes the website's selected-release descriptor to a previous fully qualified immutable bundle and redeploys the site. It never mutates or partially replaces the active version directory. -Once a fully qualified public release is active, later website-only -deployments MUST carry its exact selector and immutable firmware tree forward +Once a public release is active, later website-only deployments MUST carry its +exact selector and immutable firmware tree forward through authenticated retrieval and the canonical staged-release validation path. Each website release with an active installer retains an unserved canonical selector marker for this purpose. The preserved-public validator MUST repeat every self-contained public bundle, schema, HIL, profile, artifact, path, size, digest, descriptor, and annotated-tag check. It MUST prove exact selected-byte continuity and MUST NOT accept a different version or byte. It -does not repeat the source/build license audit whose passing evidence was -required for the original activation of those same immutable bytes. A +does not repeat the source/build license audit for a fully qualified release +whose passing evidence was required for the original activation of those same +immutable bytes. A preserved `v0.4.2` public beta MUST instead repeat canonical +`--audited-candidate` validation with the retained license-evidence directory +and exact release-build root, using the exact firmware-source checkout recorded +by the release as `--repo-root`, and MUST revalidate the annotated +`firmware-v0.4.2` tag. A deployment MUST fail before the build if that state cannot be retrieved or validated. Transitioning an active public installer to unavailable is a separate reviewed operation requiring an explicit truth-valued disable flag and a production smoke test of the disabled state; absence of staging input alone is never authorization to disable it. -The pre-public `v0.4.1` candidate path was exposed without the required access -control and is permanently burned. The origin MUST quarantine -`/firmware/v0.4.1/` with a non-cacheable not-found response, MUST NOT select or -promote those bytes, and MUST retain any forensic copy outside public routing. -A qualified public release therefore starts at a new immutable version. +As a one-time transitional exception, the fresh audited `v0.4.2` candidate MAY +be published as a **hardware-tested public beta**. The selector deployment +mode MUST be `public-beta`, `accessControlled` MUST be `false`, both profile HIL +states and the aggregate `hilStatus` MUST remain `pending`, and the +`release.json` SHA-256 MUST equal +`5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde`. +The exact profile set is `esp32-4mb` plus `esp32-s3-n16r8`; C3 MUST remain +absent. Before staging or carrying the beta forward, the canonical release tool +MUST accept the exact bundle with `validate --audited-candidate`, its retained +license-evidence directory, its exact release-build root, and the exact +firmware-source checkout recorded by the release as `--repo-root`. The annotated +`firmware-v0.4.2` tag MUST exist and peel directly to the full PyBLE provenance +commit recorded in `release.json`, and deployment MUST bind the tag object +before and after the website build and before upload. The exact production +browser-flashing validation recorded in +`docs/validation/browser-flashing/v0.4.2-production.json` supports the narrower +claim that real-board Chrome installation, interruption, recovery, and reset +passed for both enabled exact profiles. Before the install control appears, the +website MUST state that completed scope and MUST separately say that complete +release qualification remains pending. It MUST NOT call the beta +access-controlled, protected, a qualified release, fully validated, +production-ready, or generally available. + +The beta path MUST retain all existing audited-candidate, license, tag, schema, +checksum, manifest, image, same-origin, browser-capability, +profile-confirmation, consent, recovery, and production-smoke checks. The +exception changes only publication policy; it does not allow byte mutation, +substitute evidence, a different version/digest, or a new profile. A later +qualified public release therefore starts at a new immutable version and still +requires the complete gate above. diff --git a/docs/specifications/firmware/specs.md b/docs/specifications/firmware/specs.md index 1016a79..872af89 100644 --- a/docs/specifications/firmware/specs.md +++ b/docs/specifications/firmware/specs.md @@ -90,9 +90,10 @@ Where this document and [TDD.md](TDD.md) touch the same topic, this document win - **Workspace jail** — the constraint that PBLE/1 file commands may only read/write within `fs_root`. - **Runner** — the task that executes user code (file or inline source). - **HIL** — hardware-in-the-loop testing on every exact real-hardware profile - claimed by a release. The current pre-v1 candidate matrix is exactly - `esp32-4mb` and `esp32-s3-n16r8`; the v1.0 matrix additionally requires - `esp32-c3-4mb` (PRD §1B.3, §10.12). + claimed by a release. The current v0.4.2 public-beta profile set is exactly + `esp32-4mb` and `esp32-s3-n16r8`: its supplemental production-browser rows + passed, while its formal qualification matrix remains pending. The v1.0 + matrix additionally requires `esp32-c3-4mb` (PRD §1B.3, §10.12). - **Frozen-Python agent** — agent modules baked into the firmware image as `.py` (frozen at build); the recommended first implementation. - **Native agent** — hot paths moved to a `USER_C_MODULE` for throughput/RAM, behind the unchanged PBLE/1 contract. - **Verification categories** (PRD §1B.3, cited in each requirement's `verify:`): *unit* (host-side native/unit), *conformance* (PBLE/1 protocol conformance), *build* (build sanity / SHA gate), *size* (static application-image/partition gate), *HIL* (runtime hardware-in-the-loop resource and behaviour gates). @@ -251,7 +252,7 @@ are screenless. - **FR-LIB-1** — Every `esp32`, `esp32-s3`, and `esp32-c3` firmware image MUST make the pinned upstream MicroPython `neopixel.NeoPixel` API importable offline by user file/source runs and after a soft reboot. MUST (*source: PRD §9.8, §11.3; verify: resolved-manifest/build/HIL; story: F-24/A-31*) - **FR-LIB-2** — The module MUST be selected from the pristine pinned MicroPython/micropython-lib tree through each target's frozen manifest; PyBLE MUST NOT copy, fork, patch, or replace it with a custom WS2812 driver. MUST (*source: PRD §1A, §10.9, §10.10; verify: build/structure; story: F-24*) - **FR-LIB-3** — Bundling NeoPixel MUST NOT add an agent GPIO abstraction, PBLE/1 opcode/capability, board/onboard-LED name, pin/count/colour default, or target-specific user-code routing. GPIO, pixel count, index, colour, timing, and physical suitability remain explicit user-program/runtime concerns. MUST (*source: PRD §9.8, §11.3; verify: unit/no-leak/HIL; story: F-24/A-31*) -- **FR-LIB-4** — Release validation MUST resolve exactly one `neopixel.py` for each of the three build targets, record the per-target firmware-size delta, and run a runtime import smoke on every exact profile included in that release. The current pre-v1 candidate runtime matrix is the two profiles in §2.2; `esp32-c3-4mb` runtime smoke remains required before that profile is enabled and before v1.0. Any visual LED smoke MUST take an operator-supplied GPIO, use a bounded dim sequence, and turn the pixel off on exit. MUST (*source: PRD §10.11, §10.13, §13.3; verify: build/size/HIL; story: F-24*) +- **FR-LIB-4** — Release validation MUST resolve exactly one `neopixel.py` for each of the three build targets, record the per-target firmware-size delta, and run a runtime import smoke on every exact profile included in that release. The current v0.4.2 formal runtime-qualification matrix is the two profiles in §2.2 and remains open beyond the supplemental browser run; `esp32-c3-4mb` runtime smoke remains required before that profile is enabled and before v1.0. Any visual LED smoke MUST take an operator-supplied GPIO, use a bounded dim sequence, and turn the pixel off on exit. MUST (*source: PRD §10.11, §10.13, §13.3; verify: build/size/HIL; story: F-24*) This NeoPixel contract applies to the three initial ESP32-family images. A future platform port MUST NOT claim equivalent support until it validates the @@ -283,13 +284,16 @@ upstream package and required runtime primitive for that target. > and evidence schema before any threshold is selected. It does not invent or > claim a numeric threshold. -The current pre-v1 candidate qualification set is exactly, and in this order, -`esp32-4mb` and `esp32-s3-n16r8`. Each MUST have a complete numeric policy and -final-candidate HIL record before the current public installer can be enabled. -`esp32-c3-4mb` MUST NOT have a threshold entry or HIL row in this pre-v1 -policy. It remains a build/source/license-audit target, but its numeric -qualification remains open and blocks enabling C3 and blocks v1.0. The v1.0 -matrix remains all three profiles. +The current v0.4.2 public-beta set is exactly, and in this order, `esp32-4mb` +and `esp32-s3-n16r8`. Production-browser installation and interrupted-flash +recovery passed for both under the bounded exception in +[browser-flashing §10](browser-flashing.md#10-activation-and-rollback). +Each still MUST have a complete numeric policy and final-candidate HIL record +before the release may be called qualified. `esp32-c3-4mb` MUST NOT have a +threshold entry or HIL row in this pre-v1 policy. It remains a +build/source/license-audit target, but its numeric qualification remains open +and blocks enabling C3 and blocks v1.0. The v1.0 matrix remains all three +profiles. - **NFR-FP-FLASH** — The total shipped application image MUST not exceed its frozen per-profile ceiling and MUST leave at least the frozen headroom in the @@ -320,13 +324,13 @@ matrix remains all three profiles. machine-readable final-candidate HIL validator. A crossing MUST fail the applicable gate. — *(source: PRD §10.13, §1B.3; verify: size, build, HIL; story: X-03, F-13/14)* -- **NFR-FP-CLOSE** — Every exact profile included in a release is +- **NFR-FP-CLOSE** — Every exact profile included in a qualified release is **release-blocking** until all of its thresholds are frozen and its - hash-locked final-candidate evidence passes. For the current pre-v1 candidate - this means exactly the two profiles above. The still-open C3 portion blocks - any C3 release and v1.0, but it does not block an otherwise-qualified - two-profile pre-v1 release. — *(source: PRD §10.12, §10.13, §7.1; verify: - size, HIL; story: F-13/14)* + hash-locked final-candidate evidence passes. For a qualified two-profile + pre-v1 release this means exactly the two profiles above. The exact v0.4.2 + beta exception does not satisfy or waive this gate. The still-open C3 portion + blocks any C3 release and v1.0. — *(source: PRD §10.12, §10.13, §7.1; + verify: size, HIL; story: F-13/14)* #### 5.3.1 Frozen metric definitions @@ -576,7 +580,7 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety containing exactly one build matching the schema, family, merged-image path, and base offset in [browser-flashing §4](browser-flashing.md#4-esp-web-tools-manifest), so a - qualified user can flash from `pyble.dev/flash` with no local toolchain and a + compatible-profile user can flash from `pyble.dev/flash` with no local toolchain and a connected family other than the selected profile is rejected rather than offered another release image. — *(source: PRD §10.12, §15.3, @@ -586,9 +590,9 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety bundle at the canonical versioned same-origin path. A v0.x mirror is optional and every corresponding file and byte MUST be identical when one is published. v1.0 and later MUST additionally publish the matching - byte-identical GitHub Release. The current pre-v1 candidate bundle MUST target - exactly the two profiles and MUST NOT become public until both qualify; v1.0 - MUST restore three-target release parity. — + byte-identical GitHub Release. The exact v0.4.2 public-beta bundle covers exactly the two enabled, not-yet-qualified profiles + and its GitHub publication MUST be marked as a pre-release; v1.0 MUST restore + three-target release parity. — *(source: PRD §10.12, §18.2, [browser-flashing §3](browser-flashing.md#3-same-origin-versioned-layout); verify: build, release; story: X-11)* @@ -665,7 +669,7 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety verification MUST inspect generated frozen content or the running image, not stale intermediate `.mpy` files. — *(source: FR-LIB, ADR-0018; verify: build/HIL; story: F-24)* -- **BLD-17** — The current pre-v1 browser candidate MUST target exactly +- **BLD-17** — The v0.4.2 public-beta bundle MUST expose exactly `esp32-4mb` and `esp32-s3-n16r8`, with the memory qualifications, merge settings, browser-image base offsets, and component offsets frozen in [browser-flashing §1](browser-flashing.md#1-release-image-profiles). Family @@ -706,8 +710,9 @@ This is software-level safety of the IDE/agent, **not** hardware/actuator safety - **BLD-21** — The final hash-locked artifact set MUST pass the complete automated matrix and, on an access-controlled production-equivalent HTTPS candidate, browser install plus interrupted-flash recovery on real hardware - for every exact profile included in that release. The public action remains - disabled until this passes. + for every exact profile included in that release. Except for the exact, + digest-bound v0.4.2 public-beta exception in browser-flashing §10, the public + action remains disabled until this passes. One chip, simulation, an older binary, or build-only evidence MUST NOT substitute for another profile. — *(source: PRD §1B.3, website §7, [browser-flashing §9](browser-flashing.md#9-automated-and-hil-acceptance); @@ -763,23 +768,22 @@ These are tracked, release-blocking where noted; they MUST be closed before the - **OI-1 — Per-profile resource numbers pending HIL.** The measurement method, exact current scope, evidence contract, and threshold derivation are frozen - in §5.3. Numeric thresholds remain open. The current pre-v1 candidate portion closes + in §5.3. Numeric thresholds remain open. The current pre-v1 portion closes only when `esp32-4mb` and `esp32-s3-n16r8` each have committed evidence-derived policy values and passing final-candidate HIL. That state MUST be described as **“qualified for the current two-profile pre-v1 release”**, not as global OI-1 closure. `esp32-c3-4mb` remains open and release-blocking for C3 enablement and v1.0; it requires a later SemVer candidate and its own thresholds/evidence. — *(verify: size, build, HIL)* -- **OI-2 — Version-pin candidate selection and release approval remain open.** - `versions.lock` currently pins MicroPython `v1.28.0` and ESP-IDF `v5.5.1` as - **proposed defaults** ([PRD §10.9](../prd.md), - [§17.1](../prd.md), [`versions.lock`](../../../firmware/versions.lock)). - Before release builds and HIL, its exact committed bytes MUST be selected as - candidate-frozen immutable inputs. That selection is not hardware approval: - the exact candidate MUST still pass HIL on every exact profile included in - that release. The current pre-v1 candidate set is the two profiles in §5.3; C3 remains - mandatory before C3 enablement and before v1.0. A pin change creates a new - candidate and resets all candidate-bound evidence. — *(verify: build, HIL)* +- **OI-2 — v0.4.2 version-pin selection is closed; full release approval remains open.** + The exact `versions.lock` bytes for MicroPython `v1.28.0` and ESP-IDF `v5.5.1` + were selected and candidate-frozen for v0.4.2 ([PRD §10.9](../prd.md), + [§17.1](../prd.md), [`versions.lock`](../../../firmware/versions.lock)). That + selection and the supplemental browser run are not complete hardware + approval: the exact two-profile candidate still requires every remaining + formal HIL and resource gate before qualification. C3 remains mandatory + before C3 enablement and before v1.0. A pin change creates a new candidate + and resets all candidate-bound evidence. — *(verify: build, HIL)* - **OI-3 — Frozen → native split point TBD.** The agent starts frozen-Python; the decision of which hot paths (BLE I/O, framing, file chunking) move to a native `USER_C_MODULE`, and on which chip the budget forces it, is open and determined by HIL footprint/throughput measurement ([firmware.md §2](../firmware.md#2-agent-base-native-vs-frozen), [PRD §10.2](../prd.md)). The PBLE/1 wire contract MUST NOT change across the move (NFR-MAINT-3). — *(verify: size, conformance, HIL)* - **OI-4 — PBLE/1 opcode/status freeze dependency. ✅ CLOSED 2026-07-01 (`[docs]`).** [protocol.md §2](../protocol.md#2-ble-transport-gatt)/[§3](../protocol.md#3-framing) froze at G0 and [§4 (opcodes)](../protocol.md#4-opcodes)/[§8 (status)](../protocol.md#8-status--error-codes-1-byte-status-in-rsp) froze here — status-only, no wire byte changed. The opcode set + numbers and the 1-byte status set + numbers are now stable for v1.0, so FR-BLE-1/8/10 and FR-PROTO-1…10 no longer inherit provisional numbers; F-01/F-02 DoR is met. Each dependent story still MUST cite its frozen spec section per [PRD §1B.4](../prd.md). Payload-level encodings for the identity/identify opcodes remain **OI-6**. — *(verify: conformance)* - **OI-5 — Auto-run capability flag naming. ✅ CLOSED 2026-07-01 (`[docs]`).** The opt-in `main.py` auto-run cap is **`auto_run`** (u8, 0=off default / 1=on), set via the additive **`SET_AUTORUN` (0x23)** opcode ([protocol.md §7](../protocol.md#7-hello--capabilities)/[§4](../protocol.md#4-opcodes)), persisted at NVS `pyble/autorun` (owned by `pble_boot`), entry `/main.py`. FR-BOOT-3 DoR met (F-12). — *(verify: conformance)* diff --git a/docs/specifications/hardware.md b/docs/specifications/hardware.md index e41398e..34b61d5 100644 --- a/docs/specifications/hardware.md +++ b/docs/specifications/hardware.md @@ -1,19 +1,19 @@ # PyBLE — Hardware Support & Pin Guidance -Status: **DRAFT** · Last updated: 2026-07-30 +Status: **DRAFT** · Last updated: 2026-08-01 PyBLE's platform scope is any microcontroller board that can run MicroPython and provide a Bluetooth Low Energy peripheral stack capable of hosting a conforming PBLE/1 agent. It makes no assumptions about wiring and carries no board-specific routing profile. Hardware eligibility is broader than current -support: a board works with PyBLE only after a maintained agent port or firmware -image for that target passes the protocol, resource, recovery, and -hardware-in-the-loop gates. +support. A qualified port must pass its complete protocol, resource, recovery, +and hardware-in-the-loop gates; a narrower beta must name the exact evidence it +has passed and the qualification that remains open. Classic ESP32, ESP32-S3, and ESP32-C3 are the **initial v1 reference target families**, not the permanent product boundary. A public browser image is -narrower than a target family and is supported only for the exact memory profile -qualified below. Their firmware exposes hardware to user MicroPython through +narrower than a target family and is supported only for an exact listed memory +profile and its stated release status. Their firmware exposes hardware through standard runtime APIs, including `machine` and the frozen upstream `neopixel` package. The app may ship read-only target guidance to help users avoid common footguns. @@ -35,8 +35,8 @@ under the same PBLE/1 protocol. | Image profile | Required memory configuration | Installer family check | Release status | Public compatibility claim | |---|---|---|---|---| -| `esp32-4mb` | Classic ESP32; 4 MiB external SPI flash; no PSRAM assumed | `ESP32` | v0.4.2 HIL pending; installer unavailable | Only boards whose module documentation confirms this flash layout | -| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM | `ESP32-S3` | v0.4.2 HIL pending; installer unavailable | N16R8-class modules only; not generic ESP32-S3 | +| `esp32-4mb` | Classic ESP32; 4 MiB external SPI flash; no PSRAM assumed | `ESP32` | v0.4.2 hardware-tested beta; browser install/recovery passed; qualification pending | Only boards whose module documentation confirms this flash layout | +| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM | `ESP32-S3` | v0.4.2 hardware-tested beta; browser install/recovery passed; qualification pending | N16R8-class modules only; not generic ESP32-S3 | | `esp32-c3-4mb` | ESP32-C3 revision v0.3 or newer; 4 MiB external flash; no PSRAM assumed | `ESP32-C3` | Unavailable pending exact-profile HIL | No public installer compatibility claim yet | The installer family check cannot establish flash capacity, PSRAM type, USB @@ -47,11 +47,13 @@ different flash size or Quad/no PSRAM, are not covered by `esp32-s3-n16r8`; they require another profile and its own HIL evidence. The C3 profile is neither selectable nor published while its status is unavailable; owning or building the target is not a substitute for HIL. The -current machine-readable resource policy and HIL report therefore contain -exactly the first two profile IDs and no C3 thresholds or record. C3 continues -to build and participate in source/reproducibility/license audits, but its -real-board resource qualification remains required before C3 enablement and -before v1.0. +current machine-readable resource policy and immutable HIL ledger therefore +contain exactly the first two profile IDs and no C3 thresholds or record. The +[supplemental production-browser attestation](../validation/browser-flashing/v0.4.2-production.md) +records the two completed browser rows; the ledger's other formal rows remain +pending. C3 continues to build and participate in +source/reproducibility/license audits, but its real-board resource +qualification remains required before C3 enablement and before v1.0. These are **provisioning image profiles**, not board-routing profiles. They exist solely to keep destructive flash layouts honest. They do not define GPIO diff --git a/docs/specifications/prd.md b/docs/specifications/prd.md index 3a16411..9cca760 100644 --- a/docs/specifications/prd.md +++ b/docs/specifications/prd.md @@ -159,10 +159,11 @@ Each story selects the applicable categories; the protocol and firmware stories - **Hardware-in-the-loop (HIL)** — on every exact profile claimed by the release: connect, `DEVICE_INFO`, run/stop, console streaming, and a clean multi-file upload without dropping the link, plus resume-on-reconnect and - the resource measurements above. The current pre-v1 candidate matrix is exactly - `esp32-4mb` plus `esp32-s3-n16r8`; v1.0 additionally requires - `esp32-c3-4mb`. A milestone is gated by a working HIL demo, not by merged - code alone. + the resource measurements above. The current v0.4.2 public-beta profile set + is exactly `esp32-4mb` plus `esp32-s3-n16r8`; production-browser installation + and interrupted-flash recovery passed, but the other formal HIL rows remain + pending. v1.0 additionally requires `esp32-c3-4mb`. A milestone is gated by + a working HIL demo, not by merged code alone. ### §1B.4 SDD+TDD interlock @@ -386,11 +387,10 @@ never a fork, never edited in place** (see targets — `esp32`, `esp32s3`, `esp32c3` — all using NimBLE, with per-chip differences confined to Layer 2. The pinned base is MicroPython `v1.28.0` + ESP-IDF `v5.5.1` per - [`firmware/versions.lock`](../../firmware/versions.lock); these pins are - proposed defaults until deliberately selected as **candidate-frozen** - inputs. Candidate-freezing precedes release builds and HIL; it is not - hardware approval. The exact candidate still requires the complete - exact-profile HIL pass before public release. Future + [`firmware/versions.lock`](../../firmware/versions.lock). Those exact lock + bytes are candidate-frozen for v0.4.2; this is not complete hardware + approval. The exact candidate still requires the remaining formal + exact-profile HIL and resource gates before qualification. Future MicroPython ports MAY use different platform adapters while preserving the same agent/PBLE contract. See [firmware.md §4](firmware.md#4-chip-targets-and-release-profiles). - The agent MUST start frozen-Python to nail PBLE/1 and reliability, then port hot paths to native C where the chip budget demands it (especially ESP32-C3); the wire contract MUST NOT change across that move. See [firmware.md §2](firmware.md#2-agent-base-native-vs-frozen). @@ -802,7 +802,7 @@ Pin selection and pin approval are separate lifecycle states: profile. Any upstream pin change creates a new source state and candidate and requires the build, audit, deployment, and complete HIL matrix to restart. -- `versions.lock` is the **single source of truth** for the pinned upstream MicroPython tag + commit and the ESP-IDF version + commit. The current proposed defaults are MicroPython `v1.28.0` and ESP-IDF `v5.5.1`. +- `versions.lock` is the **single source of truth** for the pinned upstream MicroPython tag + commit and the ESP-IDF version + commit. MicroPython `v1.28.0` and ESP-IDF `v5.5.1` are candidate-frozen for v0.4.2; a future release selects its own exact committed lock state. - One MicroPython + ESP-IDF pin MUST drive all three chip targets; per-chip differences live in the board overlays, not in the lock. - The build MUST refuse to proceed if the checked-out upstream submodule SHA does not match the SHA recorded in `versions.lock` (**SHA-drift gate**). - Upgrades MUST go through the controlled workflow (`firmware/scripts/upgrade_micropython.sh`), never by hand-editing during a build. ESP-IDF MUST be installed from the pin into a gitignored directory (it is not an outer submodule); `mpy-cross` MUST be rebuilt from the pinned MicroPython. @@ -834,13 +834,16 @@ Each successful per-target build MUST emit a flashable artifact set ([firmware.m tag and matched to the `versions.lock` pin used to build them. A v0.x mirror is optional and MUST be byte-identical if published; v1.0 and later additionally require the matching GitHub Release. -- A pre-v1 beta release MAY publish only the exact profiles for which the +- A qualified pre-v1 release MAY publish only the exact profiles for which the maintainer owns matching hardware and has completed the full hash-locked HIL - matrix. An unqualified profile MUST be absent from release metadata, - artifacts, selection, and recovery commands and shown as unavailable, never - silently marked supported. -- The current pre-v1 candidate set is exactly `esp32-4mb` and - `esp32-s3-n16r8`; `esp32-c3-4mb` remains unavailable pending exact-profile + matrix. The narrowly digest-bound v0.4.2 exception in + [browser-flashing §10](firmware/browser-flashing.md#10-activation-and-rollback) + MAY instead expose its two exact images only as a hardware-tested beta after + their supplemental production-browser installation and recovery run passes; + it MUST say that complete qualification remains pending. +- The current v0.4.2 public-beta set is exactly `esp32-4mb` and + `esp32-s3-n16r8`. `esp32-c3-4mb` remains unavailable and absent from release + metadata, artifacts, selection, and recovery commands pending exact-profile real-hardware validation. Re-enabling it requires a new SemVer candidate and immutable bundle. - The flasher manifest MUST select the correct artifact per released profile; @@ -855,7 +858,7 @@ numbers are frozen. The detailed method, exact workload, metric meanings, rounding formulas, and evidence contract are normative in [firmware/specs.md §5.3](firmware/specs.md#53-footprint-gates-nfr-fp). -| Gate | Metric and direction | Current pre-v1 candidate profiles | ESP32-C3 / v1.0 | +| Gate | Metric and direction | Current pre-v1 profiles | ESP32-C3 / v1.0 | |---|---|---|---| | **FP-FLASH** | Total shipped application-image ceiling plus factory-partition headroom floor | Freeze separately for `esp32-4mb` and `esp32-s3-n16r8` | Remains open for `esp32-c3-4mb`; C3 is the hard constraint | | **FP-HEAP** | Python GC and internal-IDF current/largest/minimum heap floors after HELLO and transfer workloads | Freeze separately for both included profiles; default-capability `free_mem` is diagnostic only | Must leave usable user-code and control-plane headroom | @@ -864,8 +867,10 @@ rounding formulas, and evidence contract are normative in Requirements: -- The current pre-v1 candidate set is exactly the two profiles in §10.12. Their - numeric thresholds and hash-locked final-candidate HIL are release-blocking. +- The current v0.4.2 public-beta set is exactly the two profiles in §10.12. + Their numeric thresholds and hash-locked final-candidate HIL remain + release-blocking for qualification; the bounded beta exception does not + satisfy or waive those gates. `esp32-c3-4mb` MUST remain absent from that release's policy, HIL rows, artifacts, recovery, and installer selection. - **ESP32-C3 remains the binding v1.0 constraint.** Its source target continues @@ -1101,13 +1106,14 @@ Exact per-package licenses MUST be generated mechanically at build time (not han ### §15.3 Distribution - The app MUST be distributed **free** on the **Apple App Store** and **Google Play**, at feature parity across iPadOS and Android tablets (see §13.6 and §19). No account, no paywall, no in-app purchase. -- A browser-based **web flasher** MUST be hosted at `pyble.dev/flash`, built on **esp-web-tools**, with one profile-scoped, single-build manifest per exact profile included in that release (see [firmware.md §6](firmware.md#6-build--distribution)). It MUST allow a user to flash the agent from a supported desktop browser over USB without installing a toolchain, and MUST NOT give ESP Web Tools a multi-family manifest that could override the user's selected profile. The current pre-v1 candidate set is the two profiles in §10.12; C3 is unavailable until separately qualified. +- A browser-based **web flasher** MUST be hosted at `pyble.dev/flash`, built on **esp-web-tools**, with one profile-scoped, single-build manifest per exact profile included in that release (see [firmware.md §6](firmware.md#6-build--distribution)). It MUST allow a user to flash the agent from a supported desktop browser over USB without installing a toolchain, and MUST NOT give ESP Web Tools a multi-family manifest that could override the user's selected profile. The current v0.4.2 hardware-tested beta contains the two exact profiles in §10.12; complete release qualification remains pending and C3 is unavailable. - Firmware binaries (`firmware.bin`, bootloader, partition table, profile-scoped `manifest.json` files, and `THIRD_PARTY_LICENSES`) MUST be published at the canonical immutable `pyble.dev/firmware/v/` path, one set per exact - qualified profile per version. A v0.x mirror is optional and MUST be - byte-identical if present; v1.0 and later additionally require the matching - GitHub Release. + listed profile and truthful release state. A v0.x mirror is optional and MUST + be byte-identical if present; v1.0 and later additionally require the + matching GitHub Release. Any pre-qualification publication MUST be an + explicitly permitted beta, never described as qualified. - Self-build from source MUST remain fully supported (`firmware/scripts/build.sh `, `build_all.sh`) so no user depends on the hosted flasher. - Sustainability is via **donations / GitHub Sponsors**, not sales ([ADR-0003](../decisions/0003-license-mit.md)); a Sponsors link MAY appear in `README`/About but MUST NOT gate any functionality. @@ -1185,14 +1191,14 @@ PyBLE depends on third-party code at two layers (firmware upstream and Flutter p ### §17.1 Upstream pins -- The single source of truth for upstream firmware versions is [`firmware/versions.lock`](../../firmware/versions.lock). It pins **MicroPython `v1.28.0`** (commit `e0e9fbb17ed6fd06bb76e266ae554784c9c80804`) and **ESP-IDF `v5.5.1`** (commit `fcae32885b0296b32044cb99ecbdc50d98dddb83`). One MicroPython + ESP-IDF pair drives all three chip targets; per-chip differences live only in the board overlays. These values remain **proposed defaults** until selected for a candidate under §10.9. +- The single source of truth for upstream firmware versions is [`firmware/versions.lock`](../../firmware/versions.lock). It pins **MicroPython `v1.28.0`** (commit `e0e9fbb17ed6fd06bb76e266ae554784c9c80804`) and **ESP-IDF `v5.5.1`** (commit `fcae32885b0296b32044cb99ecbdc50d98dddb83`). One MicroPython + ESP-IDF pair drives all three chip targets; per-chip differences live only in the board overlays. These exact values are candidate-frozen for v0.4.2 under §10.9; later candidates must deliberately select their own committed lock state. - Before release-candidate builds or HIL, the exact committed lock file MUST be **candidate-frozen**. This makes the selected input immutable; it does not approve the pins. Public-release approval still requires the same candidate to pass HIL on every exact profile included in that release. The current - pre-v1 set is the two profiles in §10.12; all three, including the binding - ESP32-C3 footprint, are required for v1.0 (§10.13, §21.2). A pin change - abandons that candidate and all evidence bound to it. + v0.4.2 formal matrix is the two profiles in §10.12; all three, including the + binding ESP32-C3 footprint, are required for v1.0 (§10.13, §21.2). A pin + change abandons that candidate and all evidence bound to it. - A **SHA gate** MUST run in the build: the build prep verifies the checked-out submodule SHA against `versions.lock` and **refuses to proceed on mismatch**. CI MUST run this gate on every PR. - ESP-IDF is **not** a submodule; it is installed from the pinned version into a gitignored directory by the build scripts. MicroPython's own `lib/` dependencies are fetched by the standard port build (`make … submodules`). - Upgrades MUST go only through the **controlled upgrade workflow** (`firmware/scripts/upgrade_micropython.sh`) — never edited by hand during a build. An upgrade MUST: bump `versions.lock` (ref + resolved SHA) in its own commit; rebuild `mpy-cross`; pass the full host + protocol-conformance suite; pass the applicable per-profile resource gates (§10.13); candidate-freeze the updated lock before release-candidate generation; and validate that exact candidate on every profile included in the release. All three profiles are mandatory for v1.0. The default patch count against upstream is **zero**; any patch is re-reviewed for retirement at every upgrade. @@ -1228,12 +1234,14 @@ PyBLE depends on third-party code at two layers (firmware upstream and Flutter p ### §18.2 Release cadence & artifacts - Releases are **milestone-gated by a working demo on real hardware**, not by a fixed calendar (see the [public roadmap](../ROADMAP.md)). v1.0 ships when the §21.1 gate passes; v1.x follows as features mature. -- Each firmware release MUST publish, per exact qualified profile, at the +- Each qualified firmware release MUST publish, per exact profile, at the canonical immutable `pyble.dev/firmware/v/` path: `firmware.bin`, bootloader, partition table, `manifest.json` (for the web flasher), and `THIRD_PARTY_LICENSES` (§15.3). A v0.x mirror is optional and byte-identical; v1.0 and later additionally require the matching GitHub - Release. + Release. The exact v0.4.2 public-beta exception publishes the same immutable + artifact shape and a matching GitHub pre-release while retaining its pending + formal qualification state. - Each app release MUST be submitted to the **App Store and Google Play** at parity; neither platform may ship a release ahead of the other. - The web flasher at `pyble.dev/flash` MUST be updated to the matching per-chip manifests on each firmware release. - Release notes MUST state the app version, agent version, protocol version, and the upstream pins in effect. @@ -1390,8 +1398,8 @@ The entry flow is scan → connect → use, with no QR pairing, no account, and These are the production targets the project measures itself against. Numeric BLE/throughput targets are validated on hardware for every exact profile included in a release and MUST be frozen per profile after measurement. The -current pre-v1 candidate matrix has two profiles; the v1.0 matrix has all three. Until a -profile's values are frozen, they are stated as intent, not asserted. + current v0.4.2 formal matrix has two profiles; the v1.0 matrix has all three. + Until a profile's values are frozen, they are stated as intent, not asserted. | Metric | Definition | v1.0 target | Status | |---|---|---|---| @@ -1530,23 +1538,21 @@ The foundational product decisions are resolved and recorded as Architecture Dec - **Initial app platforms** → iPadOS and Android tablet at parity, released together (§13.6). - **Wi-Fi / USB as primary transport** → no; BLE-first and BLE-only for v1 (this is what makes iPad first-class). - **Board scope** → capability-defined MicroPython + BLE platform; ESP32, - ESP32-S3, and ESP32-C3 are the initial build/reference targets, while - release compatibility remains exact-profile and HIL-gated + ESP32-S3, and ESP32-C3 are the initial build/reference targets. Current + public compatibility remains exact-profile and evidence-gated ([ADR-0021](../decisions/0021-capability-defined-board-scope.md)). **Pending (resolved by measurement, not debate):** -- **Upstream-pin approval and footprint gates on real hardware** — the - MicroPython/ESP-IDF values and per-target footprint budgets (especially - **ESP32-C3**) are proposed defaults in - [`versions.lock`](../../firmware/versions.lock) and §10.13. The exact lock - file MUST first be candidate-frozen as the immutable release-build/HIL - input; that state is not approval. The same candidate MUST then pass the - complete exact-profile HIL matrix before its pins and resource gates are - approved. The current pre-v1 candidate subset is exactly the two profiles in §10.12; - all three, including C3, are required for v1.0 (§10.9, §17.1, §21.2). A pin - change creates a new candidate. New ADRs are added if a pin or budget - changes materially. +- **Upstream-pin approval and footprint gates on real hardware** — the exact + MicroPython/ESP-IDF lock bytes are candidate-frozen for v0.4.2, but the + per-target footprint budgets and formal approval (especially **ESP32-C3**) + remain open under §10.13. Candidate-freezing is not approval. The same + candidate MUST pass the complete exact-profile HIL matrix before its pins and + resource gates are approved. The current v0.4.2 formal subset is exactly the + two profiles in §10.12; all three, including C3, are required for v1.0 + (§10.9, §17.1, §21.2). A pin change creates a new candidate. New ADRs are + added if a pin or budget changes materially. - **Agent base transition point** — frozen-Python first, then C `USER_C_MODULES` for hot paths; the exact point where the C port becomes necessary per chip is decided by HIL footprint/throughput data, not up front (see [firmware.md §2](firmware.md#2-agent-base-native-vs-frozen)). - **State-management library choice** for the Flutter app (§16.1) — to be fixed by an ADR before broad adoption. @@ -1564,4 +1570,4 @@ New significant decisions MUST be captured as additional ADRs (`docs/decisions/N | **Control plane** | The agent's protected layer that owns BLE, the runner, and the filesystem bridge. It MUST NOT be editable by user code; a frozen `while True` in user code MUST NOT be able to wedge BLE or block `STOP`. | | **Workspace** | The user's own files on the board — `/main.py`, `/lib/*.py`, `/data/*` (Layer 4). Just programs the agent runs; never the control plane. | | **Platform port / target adapter** | Layer-2 integration for a MicroPython target: BLE host, scheduler/interrupt boundary, storage/config, identity, build, and provisioning. The initial ESP32 port uses per-chip board overlays for `esp32` / `esp32-s3` / `esp32-c3`, copied into the upstream tree at build prep so the submodule stays pristine. | -| **HIL** | Hardware-in-the-loop — validation and measurement performed on a real board (as opposed to host-side or fake-transport tests). Resource and BLE/goodput numbers are frozen only after HIL measurement for every exact profile claimed by a release. The current pre-v1 candidate matrix is `esp32-4mb` plus `esp32-s3-n16r8`; v1.0 additionally requires `esp32-c3-4mb`. | +| **HIL** | Hardware-in-the-loop — validation and measurement performed on a real board (as opposed to host-side or fake-transport tests). Resource and BLE/goodput numbers are frozen only after HIL measurement for every exact profile claimed by a release. The current v0.4.2 formal matrix is `esp32-4mb` plus `esp32-s3-n16r8`; its supplemental browser rows passed while other formal rows remain pending. v1.0 additionally requires `esp32-c3-4mb`. | diff --git a/docs/specifications/website.md b/docs/specifications/website.md index cadd602..2485c05 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -11,7 +11,9 @@ firmware remain governed by their own specifications. The website MUST explain PyBLE accurately, help a beta user get started or ask for support, publish the app's privacy posture, and provide a gated browser -firmware installer for the qualified initial ESP32-family image profiles. +firmware installer for the exact initial ESP32-family image profiles. The +installer MUST distinguish a hardware-tested public beta from a fully +qualified public release. It MUST NOT imply that: @@ -63,8 +65,7 @@ The home page MAY make these verified claims: - Blocks runs offline, includes editable beginner examples, supports the current explicit numeric-GPIO and standard MicroPython NeoPixel subset, and can reopen exact sidecars or import a deliberately bounded Python subset. - Those hardware APIs are implemented and exercised on the initial - ESP32-family builds; release support still requires exact-profile HIL and + Those hardware APIs are initially validated on ESP32-family firmware and MUST NOT be promised for every future port. - PBLE/1 is an open PyBLE-owned protocol. @@ -72,14 +73,15 @@ Compatibility copy MUST distinguish platform scope from current support: - hardware eligibility requires MicroPython, a PBLE/1-capable BLE peripheral stack, sufficient resources, and a conforming agent port; -- actual support requires a released, validated firmware image for the target; -- the current v0.4.2 candidate set is the exact `esp32-4mb` and - `esp32-s3-n16r8` profiles, but neither is released or selectable until both - exact candidate images pass real-hardware HIL; ESP32-C3 remains an initial - firmware target but is planned/unavailable until its own exact profile - passes HIL; -- browser provisioning is activated only for exact HIL-qualified memory - profiles in §7, including N16R8-class hardware for the ESP32-S3 image; +- actual support requires a published firmware image for the exact target and a + truthful release state; browser-installation validation for a beta is + narrower than complete release qualification; +- the current public-beta list is the exact `esp32-4mb` and + `esp32-s3-n16r8` profiles; ESP32-C3 remains an initial firmware target but + is planned/unavailable until its exact profile passes real-hardware HIL; +- browser provisioning is offered only for the exact memory profiles in §7, + including N16R8-class hardware for the initial ESP32-S3 image, and MUST show + whether those bytes are a hardware-tested beta or a qualified release; - users select pins for their exact board and wiring. It MUST NOT imply that Bluetooth hardware or stock MicroPython alone is enough, @@ -126,8 +128,8 @@ metadata suitable for the external beta announcement. The image MUST use the canonical prompt-chip mark and a privacy-reviewed capture of the real app described in §4. It MAY add authored brand text and framing, but MUST NOT retouch or generate the pictured app interface. Its claims MUST be limited to -the current iPad external beta, the exact selected candidate profiles, and -their current qualification status. +the current iPad external beta and the exact installer profiles and release +state actually available. The social image MUST have useful alternative text, remain legible under common center crops, and make no third-party runtime request. A QR code MUST @@ -136,17 +138,45 @@ platform cropping can make it unreliable. A separate local square TestFlight card MAY include the exact invitation QR, visible destination, and plain-text instructions. -### 3.4 Pre-activation release claims and support intake - -Until the first v0.4.2 public firmware selector passes the complete gate in §7, -the repository README and home page MUST say that the browser installer is -unavailable pending v0.4.2 HIL on both exact current profiles. They MUST NOT -describe either profile or its browser image as released, available, or -qualified. The home-page target cards MUST identify the profiles and constraints -as `esp32-4mb` (classic ESP32, 4 MiB external SPI flash, no PSRAM assumed) and +### 3.4 Transitional firmware beta claims and support intake + +Until the first firmware selector passes the complete qualified-release gate in +§7, the repository README and home page MUST NOT describe either current profile +or its browser image as a qualified release. The exact audited `v0.4.2` +public-beta selector MAY make the `esp32-4mb` and `esp32-s3-n16r8` images +available under the exception in §7. Following the production browser-flashing +validation recorded in +`docs/validation/browser-flashing/v0.4.2-production.json`, every active +installer state MUST instead identify these exact bytes as a +**hardware-tested firmware beta** and say that real-board browser installation +and interrupted-flash recovery passed for both enabled exact profiles. It MUST +also distinguish that narrow result from complete release qualification and +MUST NOT say or imply that the beta is access-controlled, a qualified release, +fully validated, production-ready, or generally available. The home-page target +cards MUST identify the constraints as +`esp32-4mb` (classic ESP32, 4 MiB external SPI flash, no PSRAM assumed) and `esp32-s3-n16r8` (ESP32-S3, 16 MiB flash, 8 MiB **Octal** PSRAM), and give each -the truthful pending-HIL/unavailable state. `esp32-c3-4mb` remains a separate -planned, unavailable profile. +the truthful hardware-tested-beta/release-qualification-pending state while +that selector is active. +`esp32-c3-4mb` remains a separate planned, unavailable profile and MUST NOT be +present in the beta selector, release metadata, public firmware tree, or +recovery commands. + +The repository README, home-page hero, provisioning workflow, exact-profile +cards, TestFlight callout, support getting-started guide, and public roadmap +MUST agree with the build-selected installer state. While the exact beta +selector is active, each current-profile status MUST name `v0.4.2`, +**hardware-tested beta**, and **release qualification pending**; installation +instructions MUST direct users to the enabled `/flash` action while preserving +the exact profile, backup, erase, cable/power, and port acknowledgements. They +MUST name the completed browser installation and interrupted-flash recovery +scope rather than the stale blanket phrase **full HIL pending**. When no selector +is active, including an explicit installer-disable deployment, the generated +home and support pages MUST instead say that the installer is unavailable and +MUST NOT claim that the beta is available. The roadmap MUST mark two-profile +browser-flashing validation complete while retaining the broader app, PBLE/1, +resource, and release-qualification work. Every one of those surfaces MUST keep +C3 explicitly unavailable. README getting-started instructions MUST gate destructive flashing on `/flash` showing an active version, exact profile, and enabled install action. While the @@ -155,11 +185,11 @@ supposedly qualified public image. A real-app capture caption MUST describe only what is visible; it MUST NOT claim that a physical board is pictured when the capture shows only the app. -While the installer is unavailable, the wide social card MUST describe the -workflow as one-time USB setup followed by everyday use over BLE. It MUST NOT -advertise an enabled web flasher; it MAY explicitly label firmware HIL as -pending. Its mechanically rendered PNG and authored SVG MUST remain paired by -reviewed content and exact-dimension tests. +The wide social card MUST describe the workflow as one-time USB setup followed +by everyday use over BLE. If it advertises the enabled `v0.4.2` web flasher, it +MUST use the narrow claim **web flashing validated** and MUST NOT imply complete +release qualification. Its mechanically rendered PNG and authored SVG MUST +remain paired by reviewed content and exact-dimension tests. Every changed social-card byte set MUST use a new content-versioned public pathname before its metadata is deployed. Replacing a PNG or SVG at an existing @@ -303,22 +333,30 @@ VPS origin. The origin MUST: firmware 4xx/5xx with `Cache-Control: no-store`, and use the selected `release.json` SHA-256 as the deterministic cache key for both verification and ESP Web Tools retrieval; -- quarantine the burned pre-public `/firmware/v0.4.1/` candidate path with a - non-cacheable not-found response, without deleting the retained forensic - copy or allowing it to enter any active selector; +- serve `/firmware/v0.4.2/` only while the exact audited public-beta selector + defined in §7 is active; its successful immutable responses use the ordinary + versioned-firmware cache policy, while every missing/error response remains + `no-store`; - use a valid origin certificate with Cloudflare **Full (strict)** TLS, never Flexible mode; and - expose only the required web and key-authenticated administration ports. -After the first qualified public installer is activated, an ordinary -website-only deployment MUST preserve that exact immutable selected release. +After a public installer is activated, an ordinary website-only deployment MUST +preserve that exact immutable selected release. The deployment obtains the selector and firmware tree from the current managed release over the authenticated deployment transport, validates them through the preserved-public staged-release and checksum gates, and embeds the selector at build time. That carry-forward gate repeats the self-contained bundle, -schema, HIL, profile, artifact, path, size, digest, descriptor, and annotated-tag -checks. It does not repeat the source/build license audit because the exact -immutable bytes already passed that audit during their original activation. +schema, HIL, profile, artifact, path, size, digest, descriptor, and +annotated-tag checks. A preserved qualified release does not repeat the +source/build license audit because the exact immutable bytes already passed that +audit during its original activation. The exact `v0.4.2` public beta is a fresh +pending candidate and MUST instead repeat canonical `--audited-candidate` +validation with its retained license-evidence directory and exact build root on +every deployment that carries it forward. That validation MUST receive the +exact firmware-source checkout recorded by the release, rather than substituting +the later website checkout. A preserved qualified release MUST NOT require +those retained beta source/build/evidence inputs. It MUST NOT infer availability from the mere presence of a firmware directory or accept a caller-supplied selector. @@ -351,10 +389,9 @@ image profiles, offsets, same-origin layout, manifest, integrity/provenance metadata, recovery content, and HIL matrix. This section owns the website state and user experience. -The v0.4.2 candidate profiles are exactly `esp32-4mb` and -`esp32-s3-n16r8`; neither is public or qualified before the complete gate below -passes. The S3 image requires 16 MiB flash plus 8 MiB Octal PSRAM and MUST NOT -be described as suitable for every ESP32-S3 board. The known +The current pre-v1 public profiles are exactly `esp32-4mb` and +`esp32-s3-n16r8`. The S3 image requires 16 MiB flash plus 8 MiB Octal PSRAM +and MUST NOT be described as suitable for every ESP32-S3 board. The known `esp32-c3-4mb` profile remains an initial v1 target but is explicitly unavailable until exact-profile real-hardware validation is complete. It MUST be shown separately as planned/unavailable and MUST NOT appear in the active @@ -362,11 +399,43 @@ selector, release metadata, public firmware paths, or recovery commands. ESP Web Tools' family detection is necessary but not sufficient to establish memory-profile or silicon-revision compatibility. -The `/flash` action MUST fail closed and remain explicitly unavailable until -all of the following are true for one exact immutable version: +One narrow pre-qualification exception exists for the fresh audited `v0.4.2` +candidate. A build-time selector MAY use deployment mode `public-beta` only +when all of these facts are true: + +- `version` is exactly `0.4.2`, `releaseJson.sha256` is exactly + `5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde`, + `hilStatus` is `pending`, and `accessControlled` is `false`; +- the selector contains exactly `esp32-4mb` and `esp32-s3-n16r8` with the + frozen paths, offsets, memory requirements, and silicon windows; C3 is absent; +- the same-origin `release.json`, schema, manifests, firmware, documents, and + `SHA256SUMS` pass the existing path, shape, size, and SHA-256 integrity checks; +- the canonical release tool accepts the exact bundle with + `validate --audited-candidate`, the retained license-evidence directory, and + the exact release-build root, using the exact firmware-source checkout + recorded by the release as `--repo-root`; the annotated `firmware-v0.4.2` tag + exists and peels directly to `release.json` provenance; and +- `/flash` visibly labels the firmware a **hardware-tested firmware beta**, + names the completed real-board browser installation and interrupted-flash + recovery scope for both enabled profiles, says complete release qualification + remains pending, and never calls it protected, access-controlled, a qualified + release, fully validated, production-ready, or generally available. + +This exception attests the identity, integrity, provenance, and audited-candidate +license state of the exact bytes; it does not manufacture HIL evidence. It MUST +NOT accept another version or +digest, broaden either profile, enable C3, or satisfy any qualified-release +gate. Removing or replacing the beta requires an explicit deployment action and +production smoke verification. A later website-only deployment MAY carry the +same exact beta selector and byte tree forward only by repeating the same +audited-candidate, license-evidence, annotated-tag, and integrity gates. + +Except for that exact transitional beta, the `/flash` action MUST fail closed +and remain explicitly unavailable until all of the following are true for one +exact immutable version: 1. two clean, provenance-recorded, reproducible builds produced - byte-identical current candidate-profile artifact sets from the frozen + byte-identical current release-profile artifact sets from the frozen source/toolchain pins, while the three-target source/build audit remained green; 2. the versioned, same-origin profile-scoped ESP Web Tools manifests and separate @@ -374,7 +443,7 @@ all of the following are true for one exact immutable version: partition, path, schema, license, and integrity gates; 3. the final hash-locked bytes passed the complete browser-install and interrupted-flash recovery HIL matrix on real hardware for both exact - current candidate profiles from an access-controlled, + current release profiles from an access-controlled, production-equivalent HTTPS candidate deployment, while the public action remained disabled; 4. the exact bytes, release notes, licenses, recovery guide, and HIL report are @@ -390,7 +459,8 @@ all of the following are true for one exact immutable version: 6. the activation deployment passes a non-destructive production-origin retrieval, redirect, size, SHA-256, CSP, and render smoke test. -Once the gate is green, `/flash` MUST render an active action only after: +For either the exact public beta above or a release whose gate is green, +`/flash` MUST render an active action only after: - secure-context, `navigator.serial`, and Web Crypto capability detection; - exact profile selection and the compatibility/backup/erase/cable/power @@ -449,9 +519,10 @@ The v1 site is releasable when: sitemap, and web manifest are static client files; - an unknown pathname returns the generated not-found page with status 404; - generated output contains no unsupported third-party runtime request; -- the active flasher, when selected, names an exact qualified profile; verifies - the embedded release-metadata root plus every manifest part; requires - compatibility/backup/erase consent; and links the matching recovery guide; +- the active flasher, when selected, names an exact profile and its truthful + beta-or-qualified state; verifies the embedded release-metadata root plus + every manifest part; requires compatibility/backup/erase consent; and links + the matching recovery guide; - all versioned firmware files are same-origin, immutable, byte-identical to the reviewed release, and retrievable from the public production origin; - browser verification and the subsequent ESP Web Tools fetches use the same diff --git a/docs/validation/README.md b/docs/validation/README.md index c2c7614..9f42999 100644 --- a/docs/validation/README.md +++ b/docs/validation/README.md @@ -8,6 +8,14 @@ Pre-publication records are retained only when their provenance is explicitly described; new releases must be regenerated from commits in the canonical public repository. +`browser-flashing/v0.4.2-production.md` is the human-readable post-release +attestation for the supplemental production-browser installation and +interrupted-flash recovery run on the two enabled v0.4.2 profiles. Its +companion `browser-flashing/v0.4.2-production.json` is the public, redacted +machine-readable record. They support only the scope stated in those records +and do not replace the formal final-candidate HIL, OI-1 resource, app, or PBLE/1 +qualification matrices. + The retained firmware 0.4.1 OI-1 record was produced from the archived private development history. Its source identifier is deliberately not resolvable in this fresh public history. It documents the legacy release baseline and must diff --git a/docs/validation/browser-flashing/v0.4.2-production.json b/docs/validation/browser-flashing/v0.4.2-production.json new file mode 100644 index 0000000..66f879c --- /dev/null +++ b/docs/validation/browser-flashing/v0.4.2-production.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "kind": "pyble-production-browser-flash-validation-summary", + "scope": "supplemental-browser-installation-and-recovery", + "release": { + "version": "0.4.2", + "source_commit": "ce02b68ab73da903035aa9f992c1f7e8eb2a3691", + "release_json_sha256": "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde" + }, + "environment": { + "origin": "https://pyble.dev/flash", + "browser": "Google Chrome 150.0.7871.187", + "puppeteer": "25.1.0", + "visible_browser": true, + "serial_selection_method": "isolated-profile-grant" + }, + "profiles": [ + { + "profile_id": "esp32-4mb", + "chip_family": "ESP32", + "offset": 4096, + "firmware_sha256": "3bd148df6163d21dd6ee86eecdff47820f3b20323e7cc39a3253937c60af1245", + "recorded_at": "2026-07-31T16:03:55.320Z", + "interruption_percentage": 7, + "interruption_fetch_rounds": { + "release": 1, + "manifest": 2, + "firmware": 2 + }, + "recovery_fetch_rounds": { + "release": 1, + "manifest": 3, + "firmware": 2 + }, + "full_erase": true, + "recovery_write_percentage": 100, + "hard_reset": true, + "visible_completion": true, + "serial_route_released": true, + "private_evidence_sha256": "f68d9d78c33b1d25993c74c06b7ef438eabdfbe38486574cf651a7aadb4e9bfe" + }, + { + "profile_id": "esp32-s3-n16r8", + "chip_family": "ESP32-S3", + "offset": 0, + "firmware_sha256": "7cb73313b7108d9ee7bcd34780ecc25f6fef1590dfeee49bb08c424e58f741ff", + "recorded_at": "2026-07-31T16:06:09.062Z", + "interruption_percentage": 7, + "interruption_fetch_rounds": { + "release": 1, + "manifest": 2, + "firmware": 2 + }, + "recovery_fetch_rounds": { + "release": 1, + "manifest": 3, + "firmware": 2 + }, + "full_erase": true, + "recovery_write_percentage": 100, + "hard_reset": true, + "visible_completion": true, + "serial_route_released": true, + "private_evidence_sha256": "818d22c8c40905e8cf2704014b32d437dc144eb9870ca86e73d247f0b1c92228" + } + ], + "result": "passed", + "limitations": [ + "This is not the formal final-candidate HIL V2 or OI-1 resource qualification record.", + "The current-byte final app and PBLE/1 qualification matrices remain open.", + "ESP32-C3 is absent and was not tested or enabled by this run." + ] +} diff --git a/docs/validation/browser-flashing/v0.4.2-production.md b/docs/validation/browser-flashing/v0.4.2-production.md new file mode 100644 index 0000000..67f2d4a --- /dev/null +++ b/docs/validation/browser-flashing/v0.4.2-production.md @@ -0,0 +1,75 @@ + + + +# PyBLE firmware v0.4.2 production-browser attestation + +Supplemental production-browser result: **passed** + +This attestation records the real-board browser installation and recovery run +performed against the exact PyBLE firmware v0.4.2 bytes served by +[`https://pyble.dev/flash`](https://pyble.dev/flash). It supports the current +**hardware-tested beta** claim for the two profiles below. It is not a qualified release +and does not close the remaining firmware, resource, PBLE/1, or app qualification +matrices. + +The companion +[`v0.4.2-production.json`](v0.4.2-production.json) is the machine-readable, +redacted record. + +## Exact release identity + +| Field | Value | +| --- | --- | +| Firmware version | `0.4.2` | +| Agent / protocol | `0.4.2` / `PBLE/1` | +| Annotated source tag | `firmware-v0.4.2` | +| PyBLE source commit | `ce02b68ab73da903035aa9f992c1f7e8eb2a3691` | +| MicroPython | `v1.28.0` at `e0e9fbb17ed6fd06bb76e266ae554784c9c80804` | +| ESP-IDF | `v5.5.1` at `fcae32885b0296b32044cb99ecbdc50d98dddb83` | +| `release.json` SHA-256 | `5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde` | +| Immutable bundled `HIL_REPORT.md` SHA-256 | `458ccedd89fbfbc7fbb515426b642aacbb79fd51fa36ffd3c9e4256e098468d2` | +| Companion validation JSON SHA-256 | `790c7040fa5cc7b7ac9c0faf09e33fa803000b84a080a3fd21b58cce346952a4` | +| `esp32-4mb/firmware.bin` SHA-256 | `3bd148df6163d21dd6ee86eecdff47820f3b20323e7cc39a3253937c60af1245` | +| `esp32-4mb/manifest.json` SHA-256 | `b4852423ad8d03d6bec9722d809752e54af6c4dc5e53673a709be0c4a70ae5b1` | +| `esp32-s3-n16r8/firmware.bin` SHA-256 | `7cb73313b7108d9ee7bcd34780ecc25f6fef1590dfeee49bb08c424e58f741ff` | +| `esp32-s3-n16r8/manifest.json` SHA-256 | `859640d4336ad2dfbcd9ba022bda78aeac44438ecfbf4d8e6f5d76822ed5195c` | + +The production release metadata, bundled HIL ledger, release notes, manifests, +and both merged firmware images were re-fetched from `pyble.dev` and matched +the retained staged bytes. + +## Completed production-browser checks + +The run used visible Google Chrome `150.0.7871.187` over the production HTTPS +origin. Each board used an isolated serial-port grant. + +| Exact profile | Full erase and install | Deliberate interruption | Recovery write | Reset and completion | +| --- | --- | --- | --- | --- | +| `esp32-4mb` | Passed | Interrupted at 7% | Reached 100% | Hard reset, visible completion, and serial-port release passed | +| `esp32-s3-n16r8` | Passed | Interrupted at 7% | Reached 100% | Hard reset, visible completion, and serial-port release passed | + +The classic ESP32 run completed at `2026-07-31T16:03:55.320Z`; the ESP32-S3 +run completed at `2026-07-31T16:06:09.062Z`. The redacted evidence digests are +recorded in the companion JSON. Detailed traces and screenshots are retained +privately; their published digests bind this summary without exposing serial +device-route details. + +## Relationship to the immutable release bundle + +The versioned bundle's `HIL_REPORT.md` is an immutable pre-public qualification ledger +whose digest is already part of the release. It remains unchanged. This later +production-browser attestation supersedes only its pending browser-installation and interrupted-recovery rows +(`browser_erase_install` and `interrupted_flash_recovery`) for the two exact +firmware profiles and bytes identified above; it does not change its other pending qualification rows. + +In particular: + +- the formal final-candidate HIL V2 and OI-1 resource qualification records are + still pending; +- current-byte final app and PBLE/1 qualification matrices are still pending; +- this evidence must not be generalized to other memory layouts or boards; and +- ESP32-C3 was not tested and remains unavailable. + +The completed scope is therefore accurately described as a +**hardware-tested firmware beta with production-browser installation and +interrupted-flash recovery passed on `esp32-4mb` and `esp32-s3-n16r8`**. diff --git a/firmware/licenses/license-policy.json b/firmware/licenses/license-policy.json index e2cd923..ee38433 100644 --- a/firmware/licenses/license-policy.json +++ b/firmware/licenses/license-policy.json @@ -17094,7 +17094,7 @@ "source": { "ref": "3.6.4@b5d87eaa6748b7a6fa70593178c08b4480e9b71e", "tree_path": "firmware/.esp-idf/components/mbedtls", - "tree_sha256": "0338a54ab75fa598cf77260998deb48879c326d8a785305e4d31f3e9b232a3cc", + "tree_sha256": "4366d742b5721fab1705ce58962558b25d08aa0176913e9466a3f02284d729b3", "url": "https://github.com/Mbed-TLS/mbedtls/tree/b5d87eaa6748b7a6fa70593178c08b4480e9b71e" }, "source_spdx_expression": "(Apache-2.0 OR GPL-2.0-or-later)" diff --git a/firmware/release-tools.lock b/firmware/release-tools.lock index c406b98..90e6b49 100644 --- a/firmware/release-tools.lock +++ b/firmware/release-tools.lock @@ -14,7 +14,7 @@ sha256 = "a1444a7f23740c44cacbce4845efb5cbcb08927878b6a3852c33a52d8b2b5da9" excluded_cves_path = "firmware/licenses/excluded-cves.yaml" excluded_cves_sha256 = "77c02542775c018c5f32c0dcb6e88050adb0e69dbcd95e84ad7be128e0f536e5" license_policy_path = "firmware/licenses/license-policy.json" -license_policy_sha256 = "ea1077f5ea9d8986fa2d1522d1552508d64712e4e0e930f8837c2a19a60938a8" +license_policy_sha256 = "f31f6c1b823e079ac15e810d9b982a30916f9c4670c3a6a2c2071fa166219a8f" [[artifacts]] name = "esp-idf-sbom" diff --git a/firmware/scripts/build.sh b/firmware/scripts/build.sh index 50dbba2..0324ffa 100755 --- a/firmware/scripts/build.sh +++ b/firmware/scripts/build.sh @@ -22,6 +22,12 @@ set -u +# Python helpers invoked by ESP-IDF must never write checkout-local bytecode. +# Besides mutating the retained source tree, .pyc payloads embed host paths and +# make otherwise identical license evidence depend on the checkout location. +PYTHONDONTWRITEBYTECODE=1 +export PYTHONDONTWRITEBYTECODE + HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)" FW="$(cd "$HERE/.." && pwd -P)" REPO_ROOT="$(cd "$FW/.." && pwd -P)" @@ -161,6 +167,14 @@ if [ ! -f "$IDF_DIR/export.sh" ]; then exit 1 fi +# Use the same physical checkout prefix that ESP-IDF exports to CMake. This is +# also the prefix covered by the final, more-specific deterministic path map. +if ! IDF_DIR="$(cd "$IDF_DIR" 2>/dev/null && pwd -P)"; then + echo "build.sh: cannot resolve the pinned ESP-IDF checkout" >&2 + exit 1 +fi +export PYBLE_IDF_DIR="$IDF_DIR" + # Release provenance may say clean=true only after this check. Generated build # outputs and overlay copies are ignored/submodule-untracked and do not weaken # tracked source cleanliness. @@ -255,11 +269,15 @@ export MICROPY_MPYCROSS # and PyBLE USER_C_MODULES sit outside those prefixes. -ffile-prefix-map is the # pinned GCC spelling that covers debug paths and macro/file expansions. GCC # gives the final matching map precedence, so order broad prefixes before the -# more-specific target build and retained MicroPython checkout. +# more-specific target build, retained MicroPython checkout, and ESP-IDF +# checkout. The IDF mapping must remain last because a repo-local pinned +# checkout is nested below REPO_ROOT and must keep ESP-IDF's stable /IDF +# spelling rather than the broader /PYBLE spelling. DETERMINISTIC_CFLAGS="\ -ffile-prefix-map=$REPO_ROOT=/PYBLE \ -ffile-prefix-map=$OUT=/IDF_BUILD \ --ffile-prefix-map=$UPSTREAM_DIR=/MICROPYTHON" +-ffile-prefix-map=$UPSTREAM_DIR=/MICROPYTHON \ +-ffile-prefix-map=$IDF_DIR=/IDF" make -C "$PORT" \ submodules \ diff --git a/firmware/scripts/release_bundle.py b/firmware/scripts/release_bundle.py index 876640b..cd4978f 100755 --- a/firmware/scripts/release_bundle.py +++ b/firmware/scripts/release_bundle.py @@ -2409,6 +2409,43 @@ def _audit_sha256_tree(path: Path) -> str: return digest.hexdigest() +def _audit_python_cache_artifact(relative: Path) -> bool: + return "__pycache__" in relative.parts or relative.suffix.lower() in { + ".pyc", + ".pyo", + } + + +def _audit_sha256_source_tree( + path: Path, + *, + reject_python_cache: bool = False, +) -> str: + """Hash source bytes canonically without host-generated Python caches.""" + + _require(path.is_dir(), "reviewed source tree is missing: %s" % path) + digest = hashlib.sha256() + for item in sorted(path.rglob("*")): + _require(not item.is_symlink(), "reviewed source tree contains a symlink") + if not item.is_file(): + continue + relative_path = item.relative_to(path) + if _audit_python_cache_artifact(relative_path): + _require( + not reject_python_cache, + "reviewed source tree contains Python cache artifact: %s" + % relative_path.as_posix(), + ) + continue + relative = relative_path.as_posix().encode("utf-8") + value = item.read_bytes() + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(len(value).to_bytes(8, "big")) + digest.update(value) + return digest.hexdigest() + + def _audit_artifact_requirements(artifact: dict[str, Any]) -> list[str]: value = artifact.get("requires") _require(isinstance(value, list), "locked artifact requires must be an array") @@ -4145,7 +4182,7 @@ def _audit_v2_source( path = repo_root / relative _audit_no_symlink_components(repo_root, path, "%s source tree" % label) _require( - _audit_sha256_tree(path) == digest, + _audit_sha256_source_tree(path, reject_python_cache=True) == digest, "%s source tree changed" % label, ) return copy.deepcopy(record) diff --git a/tests/firmware_tests/host/test_release_hardening.py b/tests/firmware_tests/host/test_release_hardening.py index 8027ed1..28fa579 100644 --- a/tests/firmware_tests/host/test_release_hardening.py +++ b/tests/firmware_tests/host/test_release_hardening.py @@ -746,7 +746,7 @@ def __init__(self): done printf '\n' } >> "$PYBLE_MAKE_LOG" - printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ + printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ "${CFLAGS_EXTRA-}" \ "${EXTRA_CPPFLAGS-}" \ "${CFLAGS-}" \ @@ -757,7 +757,8 @@ def __init__(self): "${MAKEFLAGS-}" \ "${MFLAGS-}" \ "${GNUMAKEFLAGS-}" \ - "${MAKEOVERRIDES-}" >> "$PYBLE_MAKE_ENV_LOG" + "${MAKEOVERRIDES-}" \ + "${PYTHONDONTWRITEBYTECODE-}" >> "$PYBLE_MAKE_ENV_LOG" printf '%s\n' "${MICROPY_MPYCROSS-}" \ >> "$PYBLE_MPY_CROSS_ENV_LOG" output="" @@ -968,11 +969,12 @@ def test_port_build_uses_only_stable_source_prefix_maps(self): fixture.output.resolve() / "esp32" ), "-ffile-prefix-map={}=/MICROPYTHON".format(upstream), + "-ffile-prefix-map={}=/IDF".format(fixture.idf.resolve()), ) ) environments = fixture.make_environments() self.assertEqual( - environments[0], + environments[0][:-1], ("", "", "", "", "", "", "", "", "", "", ""), "ambient compiler flags must not influence the rebuilt compiler", ) @@ -999,7 +1001,7 @@ def test_port_build_uses_only_stable_source_prefix_maps(self): 1, ) self.assertEqual( - environments, + [environment[:-1] for environment in environments], [("", "", "", "", "", "", "", "", "", "", "")] * 3, "submodule configuration and the final application build must " "share exact runner-owned path maps; ambient flags may not be " @@ -1008,6 +1010,21 @@ def test_port_build_uses_only_stable_source_prefix_maps(self): finally: fixture.cleanup() + def test_build_disables_python_bytecode_cache_generation(self): + fixture = BuildScriptFixture() + try: + completed = fixture.build() + self.assertEqual(completed.returncode, 0, completed.stdout) + environments = fixture.make_environments() + self.assertTrue(environments) + self.assertEqual( + [environment[-1] for environment in environments], + ["1"] * len(environments), + "every build phase must prevent checkout-local Python caches", + ) + finally: + fixture.cleanup() + def test_port_build_reuses_the_explicitly_built_host_mpy_cross(self): fixture = BuildScriptFixture() try: diff --git a/tests/firmware_tests/host/test_release_license_policy_v2_hardening.py b/tests/firmware_tests/host/test_release_license_policy_v2_hardening.py index 9e7bc0a..d88f40c 100644 --- a/tests/firmware_tests/host/test_release_license_policy_v2_hardening.py +++ b/tests/firmware_tests/host/test_release_license_policy_v2_hardening.py @@ -461,6 +461,33 @@ def test_supplemental_relationship_is_exactly_depends_on(self): ) self.assert_rejected() + def test_supplemental_source_hash_excludes_but_audit_rejects_python_caches(self): + source_hash = RELEASE._audit_sha256_source_tree + source_tree = self.fixture.mbedtls_tree + expected = source_hash(source_tree) + + cache = source_tree / "mbedcrypto" / "__pycache__" / "config.cpython-313.pyc" + legacy_cache = source_tree / "mbedcrypto" / "legacy.pyc" + cache.parent.mkdir(parents=True) + cache.write_bytes(b"cache containing /first/checkout/path\n") + legacy_cache.write_bytes(b"legacy cache containing /first/checkout/path\n") + self.assertEqual(source_hash(source_tree), expected) + cache.write_bytes(b"cache containing /second/checkout/path\n") + legacy_cache.write_bytes(b"legacy cache containing /second/checkout/path\n") + self.assertEqual(source_hash(source_tree), expected) + self.assert_rejected() + + cache.unlink() + legacy_cache.unlink() + cache.parent.rmdir() + real_source = source_tree / "mbedcrypto" / "mbedcrypto.c" + real_source.write_text( + real_source.read_text(encoding="utf-8") + "// mutation\n", + encoding="utf-8", + ) + self.assertNotEqual(source_hash(source_tree), expected) + self.assert_rejected() + def test_observed_input_with_symlink_ancestor_is_rejected(self): identifier = "core--esp32-4mb--application" observed = copy.deepcopy(self.fixture.observed_inputs) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index c40d9ed..22460a5 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import unittest from pathlib import Path @@ -23,6 +24,46 @@ def setUpClass(cls) -> None: cls.bug_template = ( REPO_ROOT / ".github" / "ISSUE_TEMPLATE" / "bug.yml" ).read_text(encoding="utf-8") + cls.roadmap = (REPO_ROOT / "docs" / "ROADMAP.md").read_text( + encoding="utf-8" + ) + cls.home_page = ( + REPO_ROOT / "tools" / "web" / "src" / "app" / "page.tsx" + ).read_text(encoding="utf-8") + cls.site_copy = ( + REPO_ROOT / "tools" / "web" / "src" / "lib" / "site.ts" + ).read_text(encoding="utf-8") + cls.support_page = ( + REPO_ROOT + / "tools" + / "web" + / "src" + / "app" + / "support" + / "page.tsx" + ).read_text(encoding="utf-8") + cls.browser_validation = json.loads( + ( + REPO_ROOT + / "docs" + / "validation" + / "browser-flashing" + / "v0.4.2-production.json" + ).read_text(encoding="utf-8") + ) + cls.browser_attestation = ( + REPO_ROOT + / "docs" + / "validation" + / "browser-flashing" + / "v0.4.2-production.md" + ).read_text(encoding="utf-8") + cls.changelog = (REPO_ROOT / "CHANGELOG.md").read_text( + encoding="utf-8" + ) + cls.flash_page = ( + REPO_ROOT / "tools" / "web" / "src" / "app" / "flash" / "page.tsx" + ).read_text(encoding="utf-8") cls.firmware_overview = ( REPO_ROOT / "docs" / "specifications" / "firmware.md" ).read_text(encoding="utf-8") @@ -32,47 +73,38 @@ def setUpClass(cls) -> None: cls.product_requirements = ( REPO_ROOT / "docs" / "specifications" / "prd.md" ).read_text(encoding="utf-8") - cls.website_readme = ( - REPO_ROOT / "tools" / "web" / "README.md" - ).read_text(encoding="utf-8") - cls.website_specification = ( - REPO_ROOT / "docs" / "specifications" / "website.md" - ).read_text(encoding="utf-8") cls.firmware_requirements = ( REPO_ROOT / "docs" / "specifications" / "firmware" / "specs.md" ).read_text(encoding="utf-8") - cls.browser_flashing = ( - REPO_ROOT - / "docs" - / "specifications" - / "firmware" - / "browser-flashing.md" + cls.firmware_tdd = ( + REPO_ROOT / "docs" / "specifications" / "firmware" / "TDD.md" ).read_text(encoding="utf-8") - cls.flash_page = ( - REPO_ROOT / "tools" / "web" / "src" / "app" / "flash" / "page.tsx" - ).read_text(encoding="utf-8") - cls.flash_status = ( - REPO_ROOT - / "tools" - / "web" - / "src" - / "components" - / "flash-status.tsx" + cls.website_readme = ( + REPO_ROOT / "tools" / "web" / "README.md" ).read_text(encoding="utf-8") - def test_readme_is_truthful_before_v042_hil_completes(self) -> None: + def test_readme_identifies_the_exact_hardware_tested_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") + normalized = " ".join(firmware.split()) self.assertIn( - "public browser installer is currently unavailable pending v0.4.2 HIL", - firmware, + "public browser installer currently offers the exact v0.4.2 hardware-tested beta", + normalized, ) + self.assertIn("Production Chrome erase/install", firmware) + self.assertIn("interrupted-flash recovery passed", firmware) + self.assertIn("Complete release qualification continues", firmware) self.assertIn("`esp32-4mb`", firmware) self.assertIn("Classic ESP32, 4 MiB external SPI flash", firmware) self.assertIn("`esp32-s3-n16r8`", firmware) self.assertIn("16 MiB flash / 8 MiB Octal PSRAM", firmware) + self.assertIn( + "v0.4.2 hardware-tested beta; browser install/recovery passed", + firmware, + ) + self.assertIn("Planned; unavailable", firmware) self.assertNotIn("currently offers qualified images", firmware) - self.assertNotIn("| Available", firmware) + self.assertNotIn("full HIL pending", firmware) def test_readme_caption_describes_only_the_visible_app(self) -> None: caption_start = self.readme.index("Actual PyBLE app") @@ -84,172 +116,217 @@ def test_readme_caption_describes_only_the_visible_app(self) -> None: self.assertIn("generated MicroPython", caption) self.assertNotRegex(caption, r"(?i)pictured|board|module") - def test_readme_try_steps_are_gated_on_an_active_installer(self) -> None: + def test_readme_try_steps_use_the_hardware_tested_beta_safely(self) -> None: try_section = markdown_section(self.readme, "Try PyBLE") + normalized = " ".join(try_section.split()) - self.assertIn("currently unavailable pending v0.4.2 HIL", try_section) - self.assertIn("active release version", try_section) + self.assertIn("v0.4.2 hardware-tested beta", normalized) + self.assertIn( + "Browser installation and interrupted-flash recovery passed", + normalized, + ) + self.assertIn("complete release qualification continues", normalized) + self.assertNotIn("full HIL pending", try_section) + self.assertNotIn("use it at your own risk", try_section) + self.assertIn("exact profile", try_section) + self.assertIn("back up", try_section) self.assertIn("enabled install action", try_section) - self.assertNotRegex(try_section, r"(?is)select .*qualified\s+agent firmware") + self.assertIn("Flashing erases the board", try_section) + self.assertNotIn("wait for that page", try_section.lower()) - def test_bug_template_collects_the_exact_installer_diagnostics(self) -> None: - for field_id in ( - "profile", - "module", - "memory", - "browser", - "operating_system", - "installer_stage", - "tablet", - "tablet_os", - ): - self.assertIn(f" id: {field_id}\n", self.bug_template) + def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: + combined = "\n".join( + (self.home_page, self.site_copy, self.support_page, self.roadmap) + ) for wording in ( - "Exact installer profile ID", - "Exact board model and module marking", - "Flash capacity, PSRAM capacity, and PSRAM type", - "Browser name and exact version", - "Desktop operating system and exact version", - "Failed installer stage", - "Exact tablet or device model", - "Tablet operating system and exact version", + "v0.4.2", + "hardware-tested beta", + "browser install/recovery passed", + "release qualification pending", + "esp32-4mb", + "esp32-s3-n16r8", ): - self.assertIn(wording, self.bug_template) - - self.assertRegex(self.bug_template, r"(?i)remove.*(?:secret|credential)") + self.assertIn(wording, combined) + self.assertIn("Production Chrome install", combined) + self.assertIn("interrupted-flash recovery passed", combined) + self.assertNotIn("full HIL pending", combined) + self.assertNotIn("use it at your own risk", combined.lower()) + self.assertIn("ESP32-C3", combined) + self.assertRegex(combined, r"(?is)ESP32-C3.{0,180}unavailable") + for stale in ( + "public browser installer stays unavailable", + "public installer is unavailable while v0.4.2 HIL runs", + "board provisioning will open only after v0.4.2", + "Browser installation for qualified `esp32-4mb`", + ): + self.assertNotIn(stale, combined) - def test_firmware_and_hardware_overviews_are_preactivation_truthful(self) -> None: - self.assertIn( - "v0.4.2 candidate set is `esp32-4mb`", - self.firmware_overview, - ) + near_term = markdown_section(self.roadmap, "Near term") self.assertIn( - "public browser installer remains unavailable pending final HIL", - self.firmware_overview, + "Complete the app, PBLE/1, resource, and remaining firmware release", + near_term, ) - self.assertNotIn( - "current pre-v1 release qualifies", - self.firmware_overview, + + def test_production_browser_claim_is_bound_to_public_evidence(self) -> None: + evidence = self.browser_validation + + self.assertEqual(evidence["result"], "passed") + self.assertEqual(evidence["release"]["version"], "0.4.2") + self.assertEqual( + evidence["release"]["release_json_sha256"], + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", ) - self.assertIn( - "| `esp32-4mb` | Classic ESP32; 4 MiB external SPI flash; " - "no PSRAM assumed | `ESP32` | v0.4.2 HIL pending; installer " - "unavailable |", - self.hardware_overview, + self.assertEqual( + [profile["profile_id"] for profile in evidence["profiles"]], + ["esp32-4mb", "esp32-s3-n16r8"], ) - self.assertIn( - "| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM " - "| `ESP32-S3` | v0.4.2 HIL pending; installer unavailable |", - self.hardware_overview, + for profile in evidence["profiles"]: + self.assertGreater(profile["interruption_percentage"], 5) + self.assertLess(profile["interruption_percentage"], 100) + self.assertEqual(profile["recovery_write_percentage"], 100) + self.assertTrue(profile["full_erase"]) + self.assertTrue(profile["hard_reset"]) + self.assertTrue(profile["visible_completion"]) + self.assertTrue(profile["serial_route_released"]) + self.assertEqual(profile["interruption_fetch_rounds"]["firmware"], 2) + self.assertEqual(profile["recovery_fetch_rounds"]["firmware"], 2) + self.assertEqual( + [profile["firmware_sha256"] for profile in evidence["profiles"]], + [ + "3bd148df6163d21dd6ee86eecdff47820f3b20323e7cc39a3253937c60af1245", + "7cb73313b7108d9ee7bcd34780ecc25f6fef1590dfeee49bb08c424e58f741ff", + ], ) - self.assertNotIn( - "| Current pre-v1 release |", - self.hardware_overview, + self.assertTrue( + any( + "not the formal" in limitation + for limitation in evidence["limitations"] + ) ) - def test_public_summaries_distinguish_build_targets_from_release_support( - self, - ) -> None: + def test_post_release_attestation_bounds_the_completed_hil_scope(self) -> None: + attestation = self.browser_attestation + + for identity in ( + "firmware-v0.4.2", + "ce02b68ab73da903035aa9f992c1f7e8eb2a3691", + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", + "3bd148df6163d21dd6ee86eecdff47820f3b20323e7cc39a3253937c60af1245", + "7cb73313b7108d9ee7bcd34780ecc25f6fef1590dfeee49bb08c424e58f741ff", + ): + self.assertIn(identity, attestation) + + for wording in ( + "Supplemental production-browser result: **passed**", + "`esp32-4mb`", + "`esp32-s3-n16r8`", + "7%", + "immutable pre-public qualification ledger", + "supersedes only its pending browser-installation and interrupted-recovery rows", + "does not change its other pending qualification rows", + "not a qualified release", + "ESP32-C3 was not tested and remains unavailable", + ): + self.assertIn(wording, attestation) + + def test_public_surfaces_link_the_release_evidence_and_changelog(self) -> None: self.assertIn( - "initial ESP-IDF build/reference targets", - self.website_readme, + "(docs/validation/browser-flashing/v0.4.2-production.md)", + self.readme, ) self.assertIn( - "public installer remains unavailable pending v0.4.2 HIL", - self.website_readme, - ) - self.assertNotIn( - "initial validated ESP32 / ESP32-S3 / ESP32-C3 firmware targets", - self.website_readme, - ) - board_scope_start = self.product_requirements.index( - "- **Board scope**", - ) - board_scope_end = self.product_requirements.index( - "\n\n**Pending", - board_scope_start, - ) - board_scope = self.product_requirements[ - board_scope_start:board_scope_end - ] - self.assertIn("initial build/reference targets", board_scope) - self.assertIn( - "release compatibility remains exact-profile and HIL-gated", - board_scope, + "https://github.com/PyBLE-dev/PyBLE/releases/tag/firmware-v0.4.2", + self.flash_page, ) - self.assertNotIn("initial validated firmware targets", board_scope) + self.assertIn("Release evidence and exact hashes", self.flash_page) - def test_preactivation_profiles_are_candidates_not_current_releases( + release = markdown_section(self.changelog, "Firmware 0.4.2 — 2026-07-31") + self.assertIn("hardware-tested beta", release) + self.assertIn("`esp32-4mb`", release) + self.assertIn("`esp32-s3-n16r8`", release) + self.assertIn("interrupted-flash recovery", release) + self.assertIn("complete release qualification remains pending", release) + self.assertNotIn("qualified release", release) + + def test_public_specifications_describe_the_exact_beta_without_overclaim( self, ) -> None: - self.assertIn( - "v0.4.2 candidate qualification scope is profile-scoped", - self.firmware_overview, - ) - self.assertIn( - "current v0.4.2 candidate set is the exact `esp32-4mb`", - self.website_specification, - ) - self.assertIn( - "v0.4.2 release candidate targets exactly these two", - self.browser_flashing, - ) - self.assertIn( - "current pre-v1 candidate set is exactly `esp32-4mb`", - self.product_requirements, - ) - self.assertIn( - "current pre-v1 candidate qualification set is exactly", - self.firmware_requirements, - ) - - prohibited_claims = ( - "current pre-v1 qualification is profile-scoped", - "current pre-v1 release list is", - "current pre-v1 public set is", - "current pre-v1 release set is", - "current pre-v1 public bundle contains exactly these two qualified", - "current pre-v1 qualification set is exactly", - ) - for claim in prohibited_claims: - for document in ( + combined = "\n".join( + ( self.firmware_overview, - self.website_specification, - self.browser_flashing, + self.hardware_overview, self.product_requirements, self.firmware_requirements, - ): - self.assertNotIn(claim, document) + self.firmware_tdd, + self.website_readme, + ) + ) - def test_preactivation_installer_ui_does_not_call_candidates_qualified( - self, - ) -> None: - self.assertIn( - "candidate ESP32 and ESP32-S3 profiles", - self.flash_page, + for wording in ( + "v0.4.2 hardware-tested beta", + "browser installation and interrupted-flash recovery passed", + "complete release qualification remains pending", + "`esp32-4mb`", + "`esp32-s3-n16r8`", + "ESP32-C3", + "unavailable", + ): + self.assertIn(wording, combined) + + for stale_claim in ( + "current pre-v1 release qualifies", + "| Current pre-v1 release |", + "the two qualified profiles", + "installer without claiming that release artifacts are ready", + "stages the future browser firmware installer", + "before the current public installer can be enabled", + "the first validated firmware family", + ): + self.assertNotIn(stale_claim, combined) + + self.assertRegex( + self.hardware_overview, + r"(?s)`esp32-4mb`.{0,240}hardware-tested beta.{0,200}" + r"`esp32-s3-n16r8`.{0,240}hardware-tested beta", ) self.assertIn( - "both exact current candidate profiles", - self.flash_page, + "The exact v0.4.2 public-beta bundle covers exactly the two enabled, " + "not-yet-qualified profiles", + self.firmware_requirements, ) self.assertIn( - "both exact current candidate profiles", - self.flash_status, - ) - self.assertNotIn( - "qualified ESP32 and ESP32-S3 profiles", - self.flash_page, - ) - self.assertNotIn( - "both exact current release profiles", - self.flash_page, - ) - self.assertNotIn( - "both exact current release profiles", - self.flash_status, + "two hardware-tested beta profiles in v0.4.2", + self.firmware_tdd, ) + def test_bug_template_collects_the_exact_installer_diagnostics(self) -> None: + for field_id in ( + "profile", + "module", + "memory", + "browser", + "operating_system", + "installer_stage", + "tablet", + "tablet_os", + ): + self.assertIn(f" id: {field_id}\n", self.bug_template) + + for wording in ( + "Exact installer profile ID", + "Exact board model and module marking", + "Flash capacity, PSRAM capacity, and PSRAM type", + "Browser name and exact version", + "Desktop operating system and exact version", + "Failed installer stage", + "Exact tablet or device model", + "Tablet operating system and exact version", + ): + self.assertIn(wording, self.bug_template) + + self.assertRegex(self.bug_template, r"(?i)remove.*(?:secret|credential)") + if __name__ == "__main__": unittest.main() diff --git a/tools/web/DEPLOYMENT.md b/tools/web/DEPLOYMENT.md index e542bdb..d656199 100644 --- a/tools/web/DEPLOYMENT.md +++ b/tools/web/DEPLOYMENT.md @@ -55,7 +55,7 @@ Review at least: The immutable deployment input is the exact committed source revision that produced the checked `out/` export. -## Stage a qualified firmware release +## Stage a firmware release The normal build and deploy path contains no firmware and keeps the installer unavailable. Do not put release bytes in `tools/web/public/`. Generate the @@ -70,6 +70,7 @@ For an all-HIL-passed public bundle: staged_root=$(mktemp -d) export PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR=/absolute/path/to/license-evidence export PYBLE_FIRMWARE_LICENSE_BUILD_ROOT=/absolute/path/to/release-build-root +export PYBLE_FIRMWARE_SOURCE_ROOT=/absolute/path/to/exact-firmware-source-checkout PYBLE_FIRMWARE_BUNDLE_DIR=/absolute/path/to/firmware-bundle \ PYBLE_FIRMWARE_OUTPUT_DIR="${staged_root}" \ @@ -85,11 +86,13 @@ annotated tag and peel directly to the full commit recorded by `release.json` at `provenance.pyble.commit`. The helper binds the tag object and peeled commit before and after the website build and again before upload. -Public and protected-candidate validation require the explicit license-evidence -and release-build paths shown above. The evidence directory must be the fresh, -reviewed output for those exact build inputs and must remain outside both the -source and build trees. Keep both variables exported through deployment because -the helper repeats canonical public validation for the private trusted staged +Public, public-beta, and protected-candidate validation require the explicit +license-evidence, release-build, and exact firmware-source paths shown above. +The source checkout must be the clean source identity recorded by the release, +with its pinned generated build inputs available. The evidence directory must +be the fresh, reviewed output for those exact source and build inputs and must +remain outside both trees. Keep all three variables exported through deployment +because the helper repeats canonical validation for the private trusted staged snapshot. The deploy helper canonically validates the caller staging, requires an @@ -108,6 +111,32 @@ website symlink. Firmware and upload evidence remain available through this remote verification. A caller-supplied evidence or inventory directory is never trusted. The helper retrieves every published byte afterward. +For the exact digest-bound v0.4.2 hardware-tested beta, use the same retained +license inputs and annotated `firmware-v0.4.2` tag, but select the explicit +public-beta mode: + +```sh +staged_root=$(mktemp -d) +export PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR=/absolute/path/to/license-evidence +export PYBLE_FIRMWARE_LICENSE_BUILD_ROOT=/absolute/path/to/release-build-root +export PYBLE_FIRMWARE_SOURCE_ROOT=/absolute/path/to/exact-firmware-source-checkout + +PYBLE_FIRMWARE_BUNDLE_DIR=/absolute/path/to/firmware-v0.4.2-bundle \ +PYBLE_FIRMWARE_OUTPUT_DIR="${staged_root}" \ +PYBLE_FLASH_DEPLOYMENT=public-beta \ +npm run firmware:stage + +PYBLE_FIRMWARE_STAGED_ROOT="${staged_root}" \ +deploy/vps/deploy.sh @ +``` + +The staging and deployment helpers run the canonical `--audited-candidate` +license gate, require both profile HIL states to remain pending, bind the exact +reviewed `release.json` SHA-256, and keep ESP32-C3 absent. The public site must +identify these bytes as a hardware-tested beta, name the completed production +Chrome installation and interrupted-flash recovery scope, and distinguish it +from complete release qualification. + For a pending release candidate, stage with both explicit controls: ```sh @@ -115,6 +144,7 @@ PYBLE_FIRMWARE_BUNDLE_DIR=/absolute/path/to/firmware-bundle \ PYBLE_FIRMWARE_OUTPUT_DIR="${staged_root}" \ PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR=/absolute/path/to/license-evidence \ PYBLE_FIRMWARE_LICENSE_BUILD_ROOT=/absolute/path/to/release-build-root \ +PYBLE_FIRMWARE_SOURCE_ROOT=/absolute/path/to/exact-firmware-source-checkout \ PYBLE_FLASH_DEPLOYMENT=candidate \ PYBLE_FLASH_ACCESS_CONTROLLED=1 \ npm run firmware:stage @@ -128,6 +158,7 @@ PYBLE_FIRMWARE_STAGED_ROOT="${staged_root}" \ PYBLE_FLASH_SELECTION_FILE="${staged_root}/.pyble-firmware-release-selection.json" \ PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR=/absolute/path/to/license-evidence \ PYBLE_FIRMWARE_LICENSE_BUILD_ROOT=/absolute/path/to/release-build-root \ +PYBLE_FIRMWARE_SOURCE_ROOT=/absolute/path/to/exact-firmware-source-checkout \ NEXT_TELEMETRY_DISABLED=1 \ npm run check ``` @@ -139,8 +170,9 @@ selector must be the exact descriptor inside that root, and supplying either one without the other is rejected. Deploy that artifact only behind enforced authentication. The boolean is an attestation to the fail-closed build policy, not access control itself. Never -send a candidate through the public VPS helper; it accepts only public releases -whose two final-byte HIL statuses are both `passed`. +send a protected candidate through the public VPS helper; it accepts only +qualified public releases whose two final-byte HIL statuses are both `passed` +or the exact audited and digest-bound v0.4.2 public beta. ## First-time VPS bootstrap @@ -229,6 +261,12 @@ From the repository root: tools/web/deploy/vps/deploy.sh @ ``` +When the current installer is the v0.4.2 public beta, a website-only deployment +must also export the same retained license-evidence, release-build, and exact +firmware-source paths and retain the annotated `firmware-v0.4.2` tag. +Carry-forward repeats the canonical audited-candidate and tag checks; it does +not rely on the presence of firmware bytes alone. + The SSH target is an argument so no host address or private-key path is stored in the repository. Authentication must be non-interactive and key-based. diff --git a/tools/web/README.md b/tools/web/README.md index 88fd8de..edec06a 100644 --- a/tools/web/README.md +++ b/tools/web/README.md @@ -5,10 +5,11 @@ The statically authored Next.js site for `pyble.dev`. It explains the PyBLE workflow and capability-defined board vision, distinguishes that vision from -the initial ESP-IDF build/reference targets (ESP32, ESP32-S3, and ESP32-C3), -publishes privacy and support information, and stages the future browser -firmware installer. The public installer remains unavailable pending v0.4.2 HIL -on the exact release-candidate bytes for both selected profiles. +the exact profiles available today, publishes privacy and support information, +and hosts the policy-gated browser installer. The live v0.4.2 selector offers a +hardware-tested beta for `esp32-4mb` and `esp32-s3-n16r8`; browser installation +and interrupted-flash recovery passed, while complete release qualification +remains pending. ESP32-C3 is unavailable. ## Why Next.js @@ -80,38 +81,49 @@ Keep the copies synchronized; never edit the website mark independently. ## Firmware installer boundary -The checked-in `/flash` selection is `null`, so the public install button is -deliberately unavailable. ESP Web Tools 10.4.0 is bundled locally, and the -browser loads it only after a selected profile's same-origin release metadata, +The checked-in `/flash` selection is `null`, so an ordinary source build remains +fail-closed. A production deployment may inject only an explicitly staged and +validated selector; the current live deployment uses the exact v0.4.2 +public-beta selector. ESP Web Tools 10.4.0 is bundled locally, and the browser +loads it only after a selected profile's same-origin release metadata, single-build manifest, merged image, exact size, and SHA-256 digest pass the strict verifier. The website dependency closure and complete license texts are published in `public/WEBSITE_THIRD_PARTY_LICENSES.txt`. Enablement requires the release gate frozen in -`docs/specifications/website.md`: reviewed artifacts for both current exact -profiles (`esp32-4mb` and `esp32-s3-n16r8`), automated checks, real-board -validation of the final bytes, HTTPS, capability detection, and recovery -instructions. The S3 profile specifically requires an N16R8 module. +`docs/specifications/website.md`. The exact v0.4.2 public beta requires reviewed +artifacts for both current exact profiles (`esp32-4mb` and +`esp32-s3-n16r8`), the canonical audited-candidate and license gates, an +annotated provenance tag, HTTPS, capability detection, and recovery +instructions. Production Chrome install and interrupted-flash recovery passed +on real hardware for both exact profiles; complete app, PBLE/1, resource, and +remaining firmware release qualification continues. The S3 profile specifically +requires an N16R8 module. `esp32-c3-4mb` remains visibly unavailable and has no public release bytes until a later exact-profile HIL-qualified candidate. Firmware is never checked into `public/` or selected through a public environment variable. `npm run firmware:stage` accepts an explicit external bundle, verifies its complete `SHA256SUMS` coverage and release contract, then -writes an external immutable tree and a build-selection descriptor. A public -bundle must have passed HIL on every profile. A pending candidate is accepted +writes an external immutable tree and a build-selection descriptor. A +qualified public bundle must have passed HIL on every profile. A pending +candidate is accepted only with both `PYBLE_FLASH_DEPLOYMENT=candidate` and `PYBLE_FLASH_ACCESS_CONTROLLED=1`, and must be built and hosted behind actual access control. Supply both `PYBLE_FIRMWARE_STAGED_ROOT` and `PYBLE_FLASH_SELECTION_FILE` to the protected build so the Sites adapter revalidates and packages the external bytes without modifying `public/`. The -public VPS deploy helper rejects candidates. Candidate staging requires +public VPS deploy helper rejects protected candidates. Candidate and exact +v0.4.2 public-beta staging require `PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR` and -`PYBLE_FIRMWARE_LICENSE_BUILD_ROOT`, and invokes the canonical audited-candidate -gate against those retained inputs. Public activation requires the exact local +`PYBLE_FIRMWARE_LICENSE_BUILD_ROOT`, plus +`PYBLE_FIRMWARE_SOURCE_ROOT` for the exact release-source checkout, and invokes +the canonical audited-candidate gate against those retained inputs. Public and +public-beta activation require the exact local annotated `firmware-v` tag to peel to the `release.json` PyBLE -provenance commit. The helper canonically validates the all-HIL-passed public -staging, freezes it in a mode-0700 private snapshot, and proves exact staging, +provenance commit. The helper canonically validates either the all-HIL-passed +public staging or the exact digest-bound audited v0.4.2 beta, freezes it in a +mode-0700 private snapshot, and proves exact staging, packaged-output, and upload-snapshot byte equality. Final `out/` is copied to a separate private read-only upload snapshot with a whole-site inventory; the VPS authenticates that inventory separately and verifies the exact file set and all @@ -120,7 +132,8 @@ hashes before publication or activation. The deploy helper rejects every validation requires explicit `PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR` and `PYBLE_FIRMWARE_LICENSE_BUILD_ROOT` paths for the reviewed evidence and exact -release-build inputs. Activation is guarded by systemd rollback and +release-build inputs, plus `PYBLE_FIRMWARE_SOURCE_ROOT` for their exact source +checkout. Activation is guarded by systemd rollback and confirmation transactions until the public smoke suite succeeds. MicroPython plus generic Bluetooth hardware is not itself a support claim. diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index 21bd825..26d2c92 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -9,6 +9,16 @@ map $status $pyble_firmware_cache_control { default "no-store"; } +# $request_uri retains the original public path across the internal 404-page +# redirect. Keep missing immutable firmware paths and the retired unversioned +# social cards out of caches while retaining the normal no-cache/no-transform +# policy for the shared website 404 page. +map $request_uri $pyble_not_found_cache_control { + ~^/social/pyble-beta-og-1200x630\.(?:png|svg)(?:\?|$) "no-store"; + ~^/firmware/ "no-store"; + default "no-cache, no-transform"; +} + server { listen 443 ssl http2; listen [::]:443 ssl http2; @@ -75,22 +85,6 @@ server { try_files $uri =404; } - # v0.4.1 was a pending pre-public candidate exposed without the required - # access control. Keep its retained bytes outside public routing forever. - location ^~ /firmware/v0.4.1/ { - include /etc/nginx/snippets/pyble-security-headers.conf; - add_header Cache-Control "no-store" always; - error_page 404 = @burned_firmware_candidate; - return 404; - } - - location @burned_firmware_candidate { - internal; - include /etc/nginx/snippets/pyble-security-headers.conf; - add_header Cache-Control "no-store" always; - return 404; - } - location ^~ /firmware/ { include /etc/nginx/snippets/pyble-security-headers.conf; add_header Cache-Control $pyble_firmware_cache_control always; @@ -142,7 +136,7 @@ server { location = /404.html { internal; include /etc/nginx/snippets/pyble-security-headers.conf; - add_header Cache-Control "no-cache, no-transform" always; + add_header Cache-Control $pyble_not_found_cache_control always; } } diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index dde630e..c39af24 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -24,10 +24,13 @@ staged_firmware_root= firmware_evidence_root= upload_evidence_root= firmware_version= +firmware_deployment= firmware_tag= firmware_provenance_commit= firmware_release_json_path= local_firmware_tag_object_before_build= +local_firmware_tag_object_after_build= +local_firmware_tag_object_before_upload= staged_validation_flag=--verify-staged cleanup_firmware_evidence() { @@ -104,6 +107,21 @@ verify_firmware_tree_parity() { fi } +require_firmware_release_inputs() { + if [[ -z ${PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR:-} ]]; then + printf 'Refusing firmware activation: PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR is required.\n' >&2 + exit 65 + fi + if [[ -z ${PYBLE_FIRMWARE_LICENSE_BUILD_ROOT:-} ]]; then + printf 'Refusing firmware activation: PYBLE_FIRMWARE_LICENSE_BUILD_ROOT is required.\n' >&2 + exit 65 + fi + if [[ -z ${PYBLE_FIRMWARE_SOURCE_ROOT:-} ]]; then + printf 'Refusing firmware activation: PYBLE_FIRMWARE_SOURCE_ROOT is required.\n' >&2 + exit 65 + fi +} + verify_local_firmware_tag() { local expected_tag_object=${1:-} local tag_ref="refs/tags/${firmware_tag}" @@ -153,15 +171,6 @@ verify_local_firmware_tag() { } if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then - if [[ -z ${PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR:-} ]]; then - printf 'Refusing public firmware activation: PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR is required.\n' >&2 - exit 65 - fi - if [[ -z ${PYBLE_FIRMWARE_LICENSE_BUILD_ROOT:-} ]]; then - printf 'Refusing public firmware activation: PYBLE_FIRMWARE_LICENSE_BUILD_ROOT is required.\n' >&2 - exit 65 - fi - staged_firmware_root=$( cd -- "${PYBLE_FIRMWARE_STAGED_ROOT}" pwd -P @@ -169,14 +178,36 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then staged_selection="${staged_firmware_root}/.pyble-firmware-release-selection.json" test -f "${staged_selection}" test -d "${staged_firmware_root}/firmware" + firmware_deployment=$( + node -e ' + const { readFileSync } = require("node:fs"); + const descriptor = JSON.parse(readFileSync(process.argv[1], "utf8")); + if (!["public", "candidate", "public-beta"].includes(descriptor.deployment)) { + throw new Error("staged firmware deployment is invalid"); + } + process.stdout.write(descriptor.deployment); + ' "${staged_selection}" + ) + require_firmware_release_inputs PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" \ node "${web_directory}/scripts/stage-firmware-release.js" \ --verify-staged node -e ' const { readFileSync } = require("node:fs"); const descriptor = JSON.parse(readFileSync(process.argv[1], "utf8")); - if (descriptor.deployment !== "public" || descriptor.hilStatus !== "passed") { - throw new Error("The public VPS accepts only an all-HIL-passed public release"); + const qualifiedPublic = + descriptor.deployment === "public" && + descriptor.hilStatus === "passed" && + descriptor.accessControlled === false; + const exactPublicBeta = + descriptor.deployment === "public-beta" && + descriptor.version === "0.4.2" && + descriptor.hilStatus === "pending" && + descriptor.accessControlled === false && + descriptor.releaseJson?.sha256 === + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; + if (!qualifiedPublic && !exactPublicBeta) { + throw new Error("The public VPS accepts only a qualified public release or the exact attested public beta"); } ' "${staged_selection}" @@ -307,18 +338,37 @@ REMOTE const { readFileSync } = require("node:fs"); const descriptor = JSON.parse(readFileSync(process.argv[1], "utf8")); const semver = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + const qualifiedPublic = + descriptor.deployment === "public" && + descriptor.hilStatus === "passed" && + descriptor.accessControlled === false; + const exactPublicBeta = + descriptor.deployment === "public-beta" && + descriptor.version === "0.4.2" && + descriptor.hilStatus === "pending" && + descriptor.accessControlled === false && + descriptor.releaseJson?.sha256 === + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; if ( - descriptor.deployment !== "public" || - descriptor.hilStatus !== "passed" || - descriptor.accessControlled !== false || + (!qualifiedPublic && !exactPublicBeta) || typeof descriptor.version !== "string" || !semver.test(descriptor.version) ) { - throw new Error("The preserved selector is not an unrestricted passed public release"); + throw new Error("The preserved selector is not an unrestricted qualified public release or the exact public beta"); } process.stdout.write(descriptor.version); ' "${preserved_selection}" ) + firmware_deployment=$( + node -e ' + const { readFileSync } = require("node:fs"); + const descriptor = JSON.parse(readFileSync(process.argv[1], "utf8")); + process.stdout.write(descriptor.deployment); + ' "${preserved_selection}" + ) + if [[ "${firmware_deployment}" == public-beta ]]; then + require_firmware_release_inputs + fi ( cd -- "${preserved_staged_root}/firmware" ssh -o BatchMode=yes "${deploy_target}" \ @@ -459,7 +509,7 @@ if [[ -n "${staged_firmware_root}" ]]; then install -m 0644 \ "${staged_selection}" \ out/.pyble-firmware-release-selection.json - readonly local_firmware_tag_object_after_build=$( + local_firmware_tag_object_after_build=$( verify_local_firmware_tag "${local_firmware_tag_object_before_build}" ) fi @@ -497,7 +547,7 @@ if [[ -n "${staged_firmware_root}" ]]; then "${staged_firmware_root}/firmware" \ "${web_directory}/out/firmware" \ "final packaged website firmware" - readonly local_firmware_tag_object_before_upload=$( + local_firmware_tag_object_before_upload=$( verify_local_firmware_tag "${local_firmware_tag_object_after_build}" ) fi @@ -1107,6 +1157,100 @@ if [[ "${not_found_status}" != 404 ]]; then exit 66 fi +retired_public_asset_paths=( + /social/pyble-beta-og-1200x630.png + /social/pyble-beta-og-1200x630.svg +) +retired_public_asset_methods=( GET HEAD ) +retired_public_asset_index=0 +for retired_public_asset_path in "${retired_public_asset_paths[@]}"; do + for retired_public_asset_method in "${retired_public_asset_methods[@]}"; do + retired_public_asset_headers="${smoke_root}/retired-public-asset-${retired_public_asset_index}-${retired_public_asset_method}.headers" + retired_public_asset_curl_mode=() + if [[ "${retired_public_asset_method}" == HEAD ]]; then + retired_public_asset_curl_mode=( --head ) + fi + retired_public_asset_status=$( + curl --silent --show-error --max-time 30 \ + --location --max-redirs 0 --proto '=https' \ + "${retired_public_asset_curl_mode[@]}" \ + --dump-header "${retired_public_asset_headers}" \ + --output /dev/null \ + --write-out '%{http_code}' \ + "https://pyble.dev${retired_public_asset_path}" + ) + if [[ "${retired_public_asset_status}" != 404 ]]; then + printf 'Retired public asset smoke failed for %s %s: expected 404, received %s.\n' \ + "${retired_public_asset_method}" \ + "${retired_public_asset_path}" \ + "${retired_public_asset_status}" >&2 + exit 66 + fi + retired_public_asset_normalized_headers="${retired_public_asset_headers}.normalized" + tr -d '\r' < "${retired_public_asset_headers}" > \ + "${retired_public_asset_normalized_headers}" + if ! grep -Eiq '^Cache-Control: *no-store *$' \ + "${retired_public_asset_normalized_headers}"; then + printf 'Retired public asset smoke failed for %s %s: Cache-Control is not no-store.\n' \ + "${retired_public_asset_method}" \ + "${retired_public_asset_path}" >&2 + exit 66 + fi + done + retired_public_asset_index=$((retired_public_asset_index + 1)) +done + +firmware_not_found_paths=( + /firmware/not-found-smoke + /firmware/v0.4.1/release.json + /firmware/v0.4.1/esp32-4mb/manifest.json +) +if [[ "${expected_installer_state}" == active ]]; then + selected_firmware_root=${firmware_release_json_path%/release.json} + test "${selected_firmware_root}" != "${firmware_release_json_path}" + firmware_not_found_paths+=( + "${selected_firmware_root}/esp32-c3-4mb/manifest.json" + ) +fi +firmware_not_found_methods=( GET HEAD ) +firmware_not_found_index=0 +for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do + for firmware_not_found_method in "${firmware_not_found_methods[@]}"; do + firmware_not_found_headers="${smoke_root}/firmware-not-found-${firmware_not_found_index}-${firmware_not_found_method}.headers" + firmware_not_found_curl_mode=() + if [[ "${firmware_not_found_method}" == HEAD ]]; then + firmware_not_found_curl_mode=( --head ) + fi + firmware_not_found_status=$( + curl --silent --show-error --max-time 30 \ + --location --max-redirs 0 --proto '=https' \ + "${firmware_not_found_curl_mode[@]}" \ + --dump-header "${firmware_not_found_headers}" \ + --output /dev/null \ + --write-out '%{http_code}' \ + "https://pyble.dev${firmware_not_found_path}" + ) + if [[ "${firmware_not_found_status}" != 404 ]]; then + printf 'Firmware 404 smoke failed for %s %s: expected 404, received %s.\n' \ + "${firmware_not_found_method}" \ + "${firmware_not_found_path}" \ + "${firmware_not_found_status}" >&2 + exit 66 + fi + firmware_not_found_normalized_headers="${firmware_not_found_headers}.normalized" + tr -d '\r' < "${firmware_not_found_headers}" > \ + "${firmware_not_found_normalized_headers}" + if ! grep -Eiq '^Cache-Control: *no-store *$' \ + "${firmware_not_found_normalized_headers}"; then + printf 'Firmware 404 smoke failed for %s %s: Cache-Control is not no-store.\n' \ + "${firmware_not_found_method}" \ + "${firmware_not_found_path}" >&2 + exit 66 + fi + done + firmware_not_found_index=$((firmware_not_found_index + 1)) +done + confirm_activation trap - ERR printf 'Deployed %s (%s) to %s.\n' \ diff --git a/tools/web/public/social/pyble-beta-og-277eee8a-1200x630.png b/tools/web/public/social/pyble-beta-og-277eee8a-1200x630.png new file mode 100644 index 0000000..659fd31 Binary files /dev/null and b/tools/web/public/social/pyble-beta-og-277eee8a-1200x630.png differ diff --git a/tools/web/public/social/pyble-beta-og-b47b6d10-1200x630.svg b/tools/web/public/social/pyble-beta-og-b47b6d10-1200x630.svg new file mode 100644 index 0000000..ee4847e --- /dev/null +++ b/tools/web/public/social/pyble-beta-og-b47b6d10-1200x630.svg @@ -0,0 +1,165 @@ + + + + PyBLE public beta + + PyBLE iPad beta with one-time USB firmware setup followed by everyday + MicroPython development over Bluetooth Low Energy, shown with a real Blocks + workspace and generated Python code. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PyBLE + + + PUBLIC BETA + + + MicroPython. + From your iPad. + Over BLE. + + + + One-time USB setup. + Everyday coding over BLE. + + + + + TESTFLIGHT BETA + + + + WEB FLASHING VALIDATED + + pyble.dev + Free · open source · MIT + + + + + + + + + + + + + BLOCKS → GENERATED PYTHON + + diff --git a/tools/web/scripts/prepare-sites-output.js b/tools/web/scripts/prepare-sites-output.js index 0c56fbc..d2e5829 100644 --- a/tools/web/scripts/prepare-sites-output.js +++ b/tools/web/scripts/prepare-sites-output.js @@ -15,8 +15,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { + validateFreshDeploymentBundle, validateStagedFirmwareRelease, - validateWithCanonicalReleaseTool, } from "./stage-firmware-release.js"; /** @typedef {{ route?: string, status?: string }} PrerenderRoute */ @@ -142,13 +142,13 @@ async function requirePrerenderedRoutes(manifestPath) { * * @param {string} packageRoot * @param {string | undefined} stagedFirmwareRoot - * @param {(bundleDirectory: string, deployment: "public" | "candidate") => Promise} releaseValidator + * @param {(bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise} releaseValidator * @returns {Promise} */ export async function prepareSitesOutput( packageRoot, stagedFirmwareRoot = process.env.PYBLE_FIRMWARE_STAGED_ROOT, - releaseValidator = validateWithCanonicalReleaseTool, + releaseValidator = validateFreshDeploymentBundle, ) { const selectionFile = process.env.PYBLE_FLASH_SELECTION_FILE; if (Boolean(stagedFirmwareRoot) !== Boolean(selectionFile)) { diff --git a/tools/web/scripts/stage-firmware-release.js b/tools/web/scripts/stage-firmware-release.js index 36e3eb6..08245bd 100644 --- a/tools/web/scripts/stage-firmware-release.js +++ b/tools/web/scripts/stage-firmware-release.js @@ -29,6 +29,9 @@ import { isDeepStrictEqual, promisify } from "node:util"; const execFile = promisify(execFileCallback); const canonicalSemverPattern = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const publicBetaVersion = "0.4.2"; +const publicBetaReleaseJsonSha256 = + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; function packageDirectory() { try { @@ -181,7 +184,7 @@ function sha256(bytes) { * reviewed repository tool, not an arbitrary substitute. * * @param {string} bundleDirectory - * @param {"public" | "candidate"} deployment + * @param {"public" | "candidate" | "public-beta"} deployment */ export async function validateWithCanonicalReleaseTool( bundleDirectory, @@ -202,12 +205,13 @@ export async function validateWithCanonicalReleaseTool( const mode = deployment === "public" ? "--public" - : deployment === "candidate" + : deployment === "candidate" || deployment === "public-beta" ? "--audited-candidate" : failure("canonical validation deployment is invalid"); const licenseEvidenceDirectory = process.env.PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR; const licenseBuildRoot = process.env.PYBLE_FIRMWARE_LICENSE_BUILD_ROOT; + const firmwareSourceRoot = process.env.PYBLE_FIRMWARE_SOURCE_ROOT; if (!licenseEvidenceDirectory) { failure( `PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR is required for ${deployment} validation`, @@ -218,6 +222,11 @@ export async function validateWithCanonicalReleaseTool( `PYBLE_FIRMWARE_LICENSE_BUILD_ROOT is required for ${deployment} validation`, ); } + if (!firmwareSourceRoot) { + failure( + `PYBLE_FIRMWARE_SOURCE_ROOT is required for ${deployment} validation`, + ); + } arguments_.push( mode, "--license-evidence-dir", @@ -225,7 +234,7 @@ export async function validateWithCanonicalReleaseTool( "--license-build-root", resolve(licenseBuildRoot), "--repo-root", - repositoryRoot, + resolve(firmwareSourceRoot), ); try { await execFile("python3", arguments_, { @@ -243,6 +252,60 @@ export async function validateWithCanonicalReleaseTool( } } +/** + * Validate the identity root and audited-candidate evidence for the one exact + * public-beta bundle. The + * structural, schema, path, size, checksum, and profile checks still run in + * validateReleaseBundle; this function prevents any other self-consistent + * pending bundle from entering the exceptional deployment mode. + * + * @param {string} bundleDirectory + * @param {"public" | "candidate" | "public-beta"} deployment + */ +export async function validateAttestedPublicBetaBundle( + bundleDirectory, + deployment, +) { + if (deployment !== "public-beta") { + failure("attested public-beta validation has an invalid deployment"); + } + const releaseBytes = await readFile( + join(resolve(bundleDirectory), "release.json"), + ); + if (sha256(releaseBytes) !== publicBetaReleaseJsonSha256) { + failure("bundle is not the exact audited v0.4.2 public beta"); + } + await validateWithCanonicalReleaseTool(bundleDirectory, deployment); +} + +/** + * @param {string} bundleDirectory + * @param {"public" | "candidate" | "public-beta"} deployment + */ +export async function validateFreshDeploymentBundle( + bundleDirectory, + deployment, +) { + if (deployment === "public-beta") { + return validateAttestedPublicBetaBundle(bundleDirectory, deployment); + } + return validateWithCanonicalReleaseTool(bundleDirectory, deployment); +} + +/** + * @param {string} bundleDirectory + * @param {"public" | "candidate" | "public-beta"} deployment + */ +async function validatePreservedDeploymentBundle(bundleDirectory, deployment) { + if (deployment === "public-beta") { + return validateAttestedPublicBetaBundle(bundleDirectory, deployment); + } + return validatePreviouslyActivatedPublicWithCanonicalReleaseTool( + bundleDirectory, + deployment, + ); +} + /** * Re-run every self-contained public-release check for bytes that were already * activated through the fresh license-evidence gate. The deployment wrapper @@ -546,9 +609,9 @@ async function verifyManifest(bundleDirectory, version, profile, artifact) { /** * @param {string} bundleDirectory - * @param {"public" | "candidate"} deployment + * @param {"public" | "candidate" | "public-beta"} deployment * @param {boolean} accessControlled - * @param {(bundleDirectory: string, deployment: "public" | "candidate") => Promise} releaseValidator + * @param {(bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise} releaseValidator */ async function validateReleaseBundle( bundleDirectory, @@ -754,6 +817,17 @@ async function validateReleaseBundle( if (deployment === "candidate" && !accessControlled) { failure("candidate bundles require explicit access control"); } + if (deployment === "public-beta") { + if (version !== publicBetaVersion) { + failure("public beta must be the exact v0.4.2 version"); + } + if (accessControlled) { + failure("public beta must be unrestricted, not access-controlled"); + } + if (statuses.some((status) => status !== "pending")) { + failure("public beta HIL status must remain pending on both profiles"); + } + } const hilStatus = statuses.every((status) => status === "passed") ? "passed" : "pending"; @@ -808,9 +882,9 @@ async function validateReleaseBundle( * @param {{ * accessControlled: boolean; * bundleDirectory: string; - * deployment: "public" | "candidate"; + * deployment: "public" | "candidate" | "public-beta"; * outputDirectory: string; - * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate") => Promise; + * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise; * }} options */ export async function stageFirmwareRelease({ @@ -820,8 +894,12 @@ export async function stageFirmwareRelease({ outputDirectory, releaseValidator, }) { - if (deployment !== "public" && deployment !== "candidate") { - failure("deployment must be public or candidate"); + if ( + deployment !== "public" && + deployment !== "candidate" && + deployment !== "public-beta" + ) { + failure("deployment must be public, candidate, or public-beta"); } if (typeof releaseValidator !== "function") { failure("an explicit canonical release validator is required"); @@ -915,7 +993,7 @@ export async function stageFirmwareRelease({ * * @param {string} stagedRoot * @param {{ - * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate") => Promise; + * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise; * }} options */ export async function validateStagedFirmwareRelease( @@ -957,7 +1035,11 @@ export async function validateStagedFirmwareRelease( failure("staged selection version is not canonical SemVer"); } const deployment = selected.deployment; - if (deployment !== "public" && deployment !== "candidate") { + if ( + deployment !== "public" && + deployment !== "candidate" && + deployment !== "public-beta" + ) { failure("staged selection deployment is invalid"); } if (typeof selected.accessControlled !== "boolean") { @@ -996,12 +1078,14 @@ export async function validateStagedFirmwareRelease( /** * Revalidate an exact staged tree recovered from the current managed website * release. Its original activation already supplied the fresh source/build - * license evidence; this path accepts only the same all-HIL-passed public - * bytes. + * license evidence. This path accepts only the same all-HIL-passed public + * bytes or the same exact audited and digest-bound transitional public beta; + * the beta repeats canonical audited-candidate validation with retained + * evidence on every deployment. * * @param {string} stagedRoot * @param {{ - * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate") => Promise; + * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise; * }} options */ export async function validatePreservedPublicFirmwareRelease( @@ -1011,13 +1095,19 @@ export async function validatePreservedPublicFirmwareRelease( const descriptor = await validateStagedFirmwareRelease(stagedRoot, { releaseValidator, }); - if ( - descriptor.deployment !== "public" || - descriptor.hilStatus !== "passed" || - descriptor.accessControlled - ) { + const qualifiedPublic = + descriptor.deployment === "public" && + descriptor.hilStatus === "passed" && + !descriptor.accessControlled; + const publicBeta = + descriptor.deployment === "public-beta" && + descriptor.version === publicBetaVersion && + descriptor.releaseJson.sha256 === publicBetaReleaseJsonSha256 && + descriptor.hilStatus === "pending" && + !descriptor.accessControlled; + if (!qualifiedPublic && !publicBeta) { failure( - "previously activated firmware must remain public, all-HIL-passed, and unrestricted", + "previously activated firmware must remain an unrestricted qualified public release or the exact public beta", ); } return descriptor; @@ -1034,7 +1124,7 @@ export async function validatePreservedPublicFirmwareRelease( * @param {{ * publishedBundleDirectory: string; * packagedRoot?: string; - * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate") => Promise; + * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate" | "public-beta") => Promise; * }} options */ export async function validatePublishedFirmwareRelease( @@ -1160,7 +1250,7 @@ async function run() { const descriptor = await validatePublishedFirmwareRelease(stagedRoot, { publishedBundleDirectory, packagedRoot: process.env.PYBLE_FIRMWARE_PACKAGED_ROOT, - releaseValidator: validateWithCanonicalReleaseTool, + releaseValidator: validateFreshDeploymentBundle, }); process.stdout.write( `Verified published GitHub bytes for PyBLE firmware v${descriptor.version}.\n`, @@ -1173,7 +1263,7 @@ async function run() { failure("PYBLE_FIRMWARE_STAGED_ROOT is required"); } const descriptor = await validateStagedFirmwareRelease(stagedRoot, { - releaseValidator: validateWithCanonicalReleaseTool, + releaseValidator: validateFreshDeploymentBundle, }); process.stdout.write( `Verified staged PyBLE firmware v${descriptor.version} for ${descriptor.deployment}.\n`, @@ -1188,8 +1278,7 @@ async function run() { const descriptor = await validatePreservedPublicFirmwareRelease( stagedRoot, { - releaseValidator: - validatePreviouslyActivatedPublicWithCanonicalReleaseTool, + releaseValidator: validatePreservedDeploymentBundle, }, ); process.stdout.write( @@ -1204,8 +1293,14 @@ async function run() { ); return; } - if (deployment !== "public" && deployment !== "candidate") { - failure("PYBLE_FLASH_DEPLOYMENT must be public, candidate, or disabled"); + if ( + deployment !== "public" && + deployment !== "candidate" && + deployment !== "public-beta" + ) { + failure( + "PYBLE_FLASH_DEPLOYMENT must be public, candidate, public-beta, or disabled", + ); } const bundleDirectory = process.env.PYBLE_FIRMWARE_BUNDLE_DIR; if (!bundleDirectory) { @@ -1221,7 +1316,7 @@ async function run() { bundleDirectory, deployment, outputDirectory, - releaseValidator: validateWithCanonicalReleaseTool, + releaseValidator: validateFreshDeploymentBundle, }); process.stdout.write( `Staged PyBLE firmware v${descriptor.version} for ${descriptor.deployment}.\n`, diff --git a/tools/web/src/app/flash/page.tsx b/tools/web/src/app/flash/page.tsx index c1f6d9a..11dff99 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -10,21 +10,27 @@ import { pageMetadata } from "@/lib/site"; export const metadata = pageMetadata({ title: "Firmware installer", description: - "Release status and requirements for candidate ESP32 and ESP32-S3 profiles, with ESP32-C3 planned.", + "Release status and requirements for installing PyBLE firmware on exact ESP32 and ESP32-S3 profiles, with ESP32-C3 planned.", path: "/flash", }); export default function FlashPage() { const release = firmwareReleaseSelectedAtBuild(); + const publicBeta = release?.deployment === "public-beta"; + const qualifiedPublic = + release?.deployment === "public" && release.hilStatus === "passed"; return (

One-time wired provisioning installs PyBLE-enabled MicroPython. Then - develop over Bluetooth Low Energy from the tablet-first PyBLE app. The - public install action remains unavailable until the final bytes pass - hardware validation on both exact current candidate profiles. + develop over Bluetooth Low Energy from the tablet-first PyBLE app. + {publicBeta + ? " The current v0.4.2 installer is a hardware-tested firmware beta. Production Chrome erase/install and deliberately interrupted-flash recovery passed on both exact profiles. Complete release qualification is still pending; this is not a qualified release." + : qualifiedPublic + ? ` Qualified v${release.version} firmware is available for both exact current release profiles.` + : " The public install action remains unavailable until the final bytes pass hardware validation on both exact current release profiles."}

@@ -99,6 +105,17 @@ export default function FlashPage() {
+ {publicBeta ? ( + + Release evidence and exact hashes + + + ) : null}
@@ -87,11 +104,32 @@ export default function HomePage() {

Code your MicroPython board. Leave the cable behind.

PyBLE is a free, tablet-first IDE designed for boards that run - MicroPython and support Bluetooth Low Energy. Public v0.4.2 - firmware is pending HIL for the exact esp32-4mb and esp32-s3-n16r8 - profiles. The public browser installer stays unavailable until - both exact profiles pass HIL. ESP32-C3 and more microcontroller - families remain planned. + MicroPython and support Bluetooth Low Energy.{" "} + {publicBeta ? ( + <> + Public v{firmwareRelease.version} firmware is a + hardware-tested beta for the exact esp32-4mb and + esp32-s3-n16r8 profiles. Production Chrome install and + interrupted-flash recovery passed on both exact profiles; + complete release qualification continues. + + ) : qualifiedPublic ? ( + <> + Qualified public v{firmwareRelease.version} firmware is + available for the exact esp32-4mb and esp32-s3-n16r8 profiles. + + ) : firmwareRelease ? ( + <> + Protected candidate v{firmwareRelease.version} is staged for + the exact esp32-4mb and esp32-s3-n16r8 profiles. + + ) : ( + <> + The firmware installer is currently unavailable; check its + status before provisioning a board. + + )}{" "} + ESP32-C3 and more microcontroller families remain planned.

@@ -166,6 +204,12 @@ export default function HomePage() { ))} +
+ + Open firmware installer + + +
@@ -246,7 +290,7 @@ export default function HomePage() { className="target-grid" aria-label="Initial beta firmware targets" > - {initialFirmwareTargets.map((target) => ( + {firmwareTargets.map((target) => (
External testing is open

Join the PyBLE beta on TestFlight.

- Install the free iPad beta through Apple TestFlight now. Public - board provisioning will open only after v0.4.2 passes HIL on both - exact release profiles; after that one-time USB setup, everyday - coding runs over Bluetooth Low Energy. + Install the free iPad beta through Apple TestFlight now.{" "} + {publicBeta ? ( + <> + The exact v{firmwareRelease.version} hardware-tested firmware + beta is available for esp32-4mb and esp32-s3-n16r8. Production + Chrome install and interrupted-flash recovery passed on both + exact profiles; complete release qualification continues. + + ) : qualifiedPublic ? ( + <> + Qualified v{firmwareRelease.version} firmware is available for + esp32-4mb and esp32-s3-n16r8. + + ) : ( + <> + The firmware installer is currently unavailable; check its + status before connecting a board. + + )}{" "} + ESP32-C3 is unavailable. After one-time USB setup, everyday coding + runs over Bluetooth Low Energy.

@@ -44,10 +51,29 @@ export default function SupportPage() {

Check firmware status before installing

- The public installer is unavailable while v0.4.2 HIL runs - for esp32-4mb and esp32-s3-n16r8. ESP32-C3 is not currently - available. Wait for the installer status to show an active - release and enabled action; this initial step uses a cable. + {publicBeta ? ( + <> + The v{firmwareRelease.version} hardware-tested beta is + available for the exact esp32-4mb and esp32-s3-n16r8 + profiles. Production Chrome install and + interrupted-flash recovery passed on both exact + profiles; complete release qualification continues. + + ) : qualifiedPublic ? ( + <> + Qualified v{firmwareRelease.version} firmware is + available for the exact esp32-4mb and esp32-s3-n16r8 + profiles. + + ) : ( + <> + The firmware installer is currently unavailable. Check + this status again before provisioning a board. + + )}{" "} + ESP32-C3 is not currently available. Confirm the active + release, exact profile, and enabled action; this initial + step uses a cable.

diff --git a/tools/web/src/components/flash-status.tsx b/tools/web/src/components/flash-status.tsx index ead5674..72492a4 100644 --- a/tools/web/src/components/flash-status.tsx +++ b/tools/web/src/components/flash-status.tsx @@ -9,6 +9,7 @@ import { installReleaseKeyedArtifactFetch } from "@/lib/firmware-fetch-cache"; import { verifyFirmwareProfile } from "@/lib/firmware-integrity"; import { hasExactFirmwareProfileDescriptors, + isExactPublicBetaFirmwareRelease, type FirmwareProfileDescriptor, type FirmwareProfileId, type FirmwareReleaseDescriptor, @@ -122,13 +123,27 @@ function policyFailure(release: FirmwareReleaseDescriptor | null | undefined) { if (!release) { return { heading: "Installer unavailable", - body: "Hardware validation is still required on both exact current candidate profiles before the public installer can be enabled.", + body: "Hardware validation is still required on both exact current release profiles before the public installer can be enabled.", }; } if (release.deployment !== "public" && release.deployment !== "candidate") { + if ( + release.deployment !== "public-beta" || + !isExactPublicBetaFirmwareRelease(release) + ) { + return { + heading: "Installer unavailable", + body: "The selected firmware deployment mode is invalid, so installation remains unavailable.", + }; + } + } + if ( + release.deployment === "public-beta" && + !isExactPublicBetaFirmwareRelease(release) + ) { return { heading: "Installer unavailable", - body: "The selected firmware deployment mode is invalid, so installation remains unavailable.", + body: "The public beta does not match the exact audited v0.4.2 firmware, so installation remains unavailable.", }; } if (!hasExactFirmwareProfileDescriptors(release.version, release.profiles)) { @@ -285,6 +300,7 @@ function FlashStatusForRelease({ ); const everyConsent = consentItems.every(({ id }) => consents[id]); const candidate = activeRelease.deployment === "candidate"; + const publicBeta = activeRelease.deployment === "public-beta"; function chooseProfile(profileId: FirmwareProfileId) { verificationAttempt.current += 1; @@ -372,18 +388,24 @@ function FlashStatusForRelease({

Installer status

- {candidate ? "Protected release candidate" : "Qualified release"} + {candidate + ? "Protected release candidate" + : publicBeta + ? "Hardware-tested firmware beta" + : "Qualified release"}

{candidate ? "Protected release candidate: hardware validation is pending." - : "Select and verify the exact module profile before installation."} + : publicBeta + ? "Hardware-tested firmware beta: exact PyBLE v0.4.2 browser installation and interrupted-flash recovery passed on real esp32-4mb and esp32-s3-n16r8 hardware. Complete release qualification is still pending; this is not a qualified release." + : "Select and verify the exact module profile before installation."}
+ {publicBeta ? ( +

+ Browser installation and interrupted-flash recovery passed on real + hardware for this exact profile. Complete release qualification is + still pending. +

+ ) : null}
{`Version ${verified.version} recovery instructions`} @@ -484,7 +513,9 @@ function FlashStatusForRelease({ type="button" slot="activate" > - Install PyBLE {verified.version} + {publicBeta + ? `Install PyBLE ${verified.version} beta` + : `Install PyBLE ${verified.version}`}
diff --git a/tools/web/src/lib/firmware-integrity.ts b/tools/web/src/lib/firmware-integrity.ts index 954f3fb..30ea103 100644 --- a/tools/web/src/lib/firmware-integrity.ts +++ b/tools/web/src/lib/firmware-integrity.ts @@ -4,6 +4,7 @@ import { firmwareProfileDescriptors, firmwareProfileTable, + isExactPublicBetaFirmwareRelease, type FirmwareProfileDescriptor, type FirmwareProfileId, type FirmwareReleaseDescriptor, @@ -275,7 +276,8 @@ function validateDescriptor( ) { if ( descriptor.deployment !== "public" && - descriptor.deployment !== "candidate" + descriptor.deployment !== "candidate" && + descriptor.deployment !== "public-beta" ) { fail("Selected firmware deployment mode is invalid"); } @@ -294,6 +296,12 @@ function validateDescriptor( if (descriptor.deployment === "public" && descriptor.hilStatus !== "passed") { fail("Public firmware requires passed hardware validation"); } + if ( + descriptor.deployment === "public-beta" && + !isExactPublicBetaFirmwareRelease(descriptor) + ) { + fail("Public beta does not match the exact audited v0.4.2 release"); + } const releasePath = `/firmware/v${descriptor.version}/release.json`; const schemaPath = `/firmware/v${descriptor.version}/release.schema.json`; @@ -623,6 +631,12 @@ function validateRelease( if (descriptor.deployment === "candidate" && !descriptor.accessControlled) { fail("Pending candidate release is not access-controlled"); } + if ( + descriptor.deployment === "public-beta" && + statuses.some((status) => status !== "pending") + ) { + fail("Public beta hardware validation must remain pending"); + } const aggregateStatus = statuses.every((status) => status === "passed") ? "passed" : "pending"; diff --git a/tools/web/src/lib/firmware-release-selection.ts b/tools/web/src/lib/firmware-release-selection.ts index 412d19b..cc18c3c 100644 --- a/tools/web/src/lib/firmware-release-selection.ts +++ b/tools/web/src/lib/firmware-release-selection.ts @@ -6,6 +6,7 @@ import { resolve } from "node:path"; import { hasExactFirmwareProfileDescriptors, + isExactPublicBetaFirmwareRelease, type FirmwareReleaseDescriptor, } from "@/lib/firmware-release"; @@ -30,7 +31,8 @@ export function firmwareReleaseSelectedAtBuild(): FirmwareReleaseDescriptor | nu const descriptor = parsed as FirmwareReleaseDescriptor; if ( descriptor.deployment !== "public" && - descriptor.deployment !== "candidate" + descriptor.deployment !== "candidate" && + descriptor.deployment !== "public-beta" ) { throw new Error( "Build-selected firmware descriptor has an invalid deployment mode", @@ -57,5 +59,13 @@ export function firmwareReleaseSelectedAtBuild(): FirmwareReleaseDescriptor | nu "Build-selected public firmware must have passed all hardware validation", ); } + if ( + descriptor.deployment === "public-beta" && + !isExactPublicBetaFirmwareRelease(descriptor) + ) { + throw new Error( + "Build-selected public beta does not match the exact audited v0.4.2 release", + ); + } return descriptor; } diff --git a/tools/web/src/lib/firmware-release.ts b/tools/web/src/lib/firmware-release.ts index d446177..8f6dc2a 100644 --- a/tools/web/src/lib/firmware-release.ts +++ b/tools/web/src/lib/firmware-release.ts @@ -52,9 +52,13 @@ export const plannedFirmwareProfileTable = [ export type FirmwareProfileId = (typeof firmwareProfileTable)[number]["id"]; export type PlannedFirmwareProfileId = (typeof plannedFirmwareProfileTable)[number]["id"]; -export type FirmwareDeployment = "public" | "candidate"; +export type FirmwareDeployment = "public" | "candidate" | "public-beta"; export type FirmwareHilStatus = "pending" | "passed"; +export const publicBetaFirmwareVersion = "0.4.2"; +export const publicBetaReleaseJsonSha256 = + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; + export interface FirmwareProfileDescriptor { readonly id: FirmwareProfileId; readonly label: string; @@ -98,6 +102,27 @@ export interface VerifiedFirmwareProfile { readonly version: string; } +export function isExactPublicBetaFirmwareRelease( + value: unknown, +): value is FirmwareReleaseDescriptor { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const descriptor = value as Partial; + const version = publicBetaFirmwareVersion; + return ( + descriptor.deployment === "public-beta" && + descriptor.accessControlled === false && + descriptor.version === version && + descriptor.hilStatus === "pending" && + descriptor.releaseJson?.path === `/firmware/v${version}/release.json` && + descriptor.releaseJson?.sha256 === publicBetaReleaseJsonSha256 && + descriptor.schemaPath === `/firmware/v${version}/release.schema.json` && + descriptor.recoveryPath === `/firmware/v${version}/RECOVERY.md` && + hasExactFirmwareProfileDescriptors(version, descriptor.profiles) + ); +} + export function profileDescriptor( version: string, profileId: FirmwareProfileId, diff --git a/tools/web/src/lib/site.ts b/tools/web/src/lib/site.ts index e08e4ba..156abdd 100644 --- a/tools/web/src/lib/site.ts +++ b/tools/web/src/lib/site.ts @@ -3,6 +3,8 @@ import type { Metadata } from "next"; +import type { FirmwareReleaseDescriptor } from "@/lib/firmware-release"; + export const siteConfig = { name: "PyBLE", expandedName: "Python over Bluetooth Low Energy", @@ -24,36 +26,56 @@ export const navigation = [ { label: "Support", href: "/support" }, ] as const; -export const initialFirmwareTargets = [ +const firmwareTargetDefinitions = [ { id: "esp32-4mb", target: "Classic ESP32", constraint: "4 MiB external SPI flash · no PSRAM assumed", - status: "v0.4.2 HIL pending · installer unavailable", planned: false, }, { id: "esp32-s3-n16r8", target: "ESP32-S3 N16R8", constraint: "16 MiB flash · 8 MiB Octal PSRAM", - status: "v0.4.2 HIL pending · installer unavailable", planned: false, }, { id: "esp32-c3-4mb", target: "ESP32-C3", constraint: "4 MiB external SPI flash · no PSRAM assumed", - status: "Planned · installer unavailable pending exact-profile HIL", planned: true, }, ] as const; +export function firmwareTargetsForRelease( + release: FirmwareReleaseDescriptor | null, +) { + return firmwareTargetDefinitions.map((target) => { + let status = "Installer unavailable"; + if (target.planned) { + status = "Planned · installer unavailable pending exact-profile HIL"; + } else if (release?.deployment === "public-beta") { + status = `v${release.version} hardware-tested beta · browser install/recovery passed · release qualification pending`; + } else if ( + release?.deployment === "public" && + release.hilStatus === "passed" + ) { + status = `v${release.version} qualified public release`; + } else if (release?.deployment === "candidate") { + status = `v${release.version} protected candidate · HIL pending`; + } + return { ...target, status }; + }); +} + +export const initialFirmwareTargets = firmwareTargetsForRelease(null); + export function absoluteUrl(path: string): string { return new URL(path, siteConfig.origin).toString(); } export const socialImage = { - url: absoluteUrl("/social/pyble-beta-og-7e7e037d-1200x630.png"), + url: absoluteUrl("/social/pyble-beta-og-277eee8a-1200x630.png"), width: 1200, height: 630, alt: "Actual PyBLE iPad app showing GPIO 48 NeoPixel Blocks and generated MicroPython code", diff --git a/tools/web/src/test/build-contract.test.ts b/tools/web/src/test/build-contract.test.ts index 730d413..f62eb19 100644 --- a/tools/web/src/test/build-contract.test.ts +++ b/tools/web/src/test/build-contract.test.ts @@ -10,7 +10,10 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { firmwareReleaseSelectedAtBuild } from "@/lib/firmware-release-selection"; -import { pendingPublicFirmwareRelease } from "@/test/fixtures/firmware-release"; +import { + pendingPublicFirmwareRelease, + publicBetaFirmwareRelease, +} from "@/test/fixtures/firmware-release"; const execFile = promisify(execFileCallback); @@ -169,6 +172,38 @@ describe("production build contract", () => { } }); + it("accepts only the exact attested public-beta selector at the build boundary", async () => { + const selectionRoot = await mkdtemp( + join(tmpdir(), "pyble-beta-selection-"), + ); + const selectionFile = join(selectionRoot, "selection.json"); + await writeFile( + selectionFile, + JSON.stringify(publicBetaFirmwareRelease), + "utf8", + ); + + const previousSelection = process.env.PYBLE_FLASH_SELECTION_FILE; + process.env.PYBLE_FLASH_SELECTION_FILE = selectionFile; + try { + expect(firmwareReleaseSelectedAtBuild()).toEqual( + publicBetaFirmwareRelease, + ); + + const altered = structuredClone(publicBetaFirmwareRelease); + altered.releaseJson.sha256 = "0".repeat(64); + await writeFile(selectionFile, JSON.stringify(altered), "utf8"); + expect(() => firmwareReleaseSelectedAtBuild()).toThrow(/public beta/i); + } finally { + if (previousSelection === undefined) { + delete process.env.PYBLE_FLASH_SELECTION_FILE; + } else { + process.env.PYBLE_FLASH_SELECTION_FILE = previousSelection; + } + await rm(selectionRoot, { recursive: true, force: true }); + } + }); + it("requires explicit server-side build inputs for protected candidate staging", async () => { const [packageJson, stagingSource] = await Promise.all([ readFile(join(process.cwd(), "package.json"), "utf8").then( @@ -216,4 +251,18 @@ describe("production build contract", () => { /https?:\/\/(?:unpkg\.com|cdn\.jsdelivr\.net|esm\.sh)/i, ); }); + + it("gives an active public beta truthful page-level context", async () => { + const page = await readFile( + join(process.cwd(), "src", "app", "flash", "page.tsx"), + "utf8", + ); + + expect(page).toContain('release?.deployment === "public-beta"'); + expect(page).toMatch(/hardware-tested firmware beta/i); + expect(page).toMatch(/production chrome.*install/i); + expect(page).toMatch(/interrupted-flash recovery.*both exact profiles/i); + expect(page).toMatch(/complete release qualification.*pending/i); + expect(page).not.toMatch(/full HIL pending|use at your own risk/i); + }); }); diff --git a/tools/web/src/test/firmware-fetch-cache.test.ts b/tools/web/src/test/firmware-fetch-cache.test.ts index 8a3ba47..582d0dd 100644 --- a/tools/web/src/test/firmware-fetch-cache.test.ts +++ b/tools/web/src/test/firmware-fetch-cache.test.ts @@ -7,8 +7,8 @@ import { installReleaseKeyedArtifactFetch } from "@/lib/firmware-fetch-cache"; const origin = "https://pyble.dev"; const releaseKey = "a".repeat(64); -const manifestPath = "/firmware/v0.4.1/esp32-4mb/manifest.json"; -const firmwarePath = "/firmware/v0.4.1/esp32-4mb/firmware.bin"; +const manifestPath = "/firmware/v0.4.2/esp32-4mb/manifest.json"; +const firmwarePath = "/firmware/v0.4.2/esp32-4mb/firmware.bin"; function response(url: string) { const result = new Response("bytes"); diff --git a/tools/web/src/test/firmware-integrity.test.ts b/tools/web/src/test/firmware-integrity.test.ts index 70ccd3f..542ad15 100644 --- a/tools/web/src/test/firmware-integrity.test.ts +++ b/tools/web/src/test/firmware-integrity.test.ts @@ -21,7 +21,7 @@ interface FetchOverrides { } function relativeArtifactPath(pathname: string) { - const prefix = "/firmware/v0.4.1/"; + const prefix = "/firmware/v0.4.2/"; return pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined; @@ -103,11 +103,11 @@ describe("firmware integrity verifier", () => { await expect(verify(fixture, fetcher)).resolves.toEqual({ chipFamily: "ESP32-S3", - firmwarePath: "/firmware/v0.4.1/esp32-s3-n16r8/firmware.bin", + firmwarePath: "/firmware/v0.4.2/esp32-s3-n16r8/firmware.bin", manifestBuildCount: 1, - manifestPath: "/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", + manifestPath: "/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", profileId: "esp32-s3-n16r8", - version: "0.4.1", + version: "0.4.2", }); expect( fetcher.mock.calls.map(([input]) => { @@ -119,19 +119,19 @@ describe("firmware integrity verifier", () => { }), ).toEqual([ { - pathname: "/firmware/v0.4.1/release.json", + pathname: "/firmware/v0.4.2/release.json", releaseKey: fixture.descriptor.releaseJson.sha256, }, { - pathname: "/firmware/v0.4.1/release.schema.json", + pathname: "/firmware/v0.4.2/release.schema.json", releaseKey: fixture.descriptor.releaseJson.sha256, }, { - pathname: "/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", + pathname: "/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", releaseKey: fixture.descriptor.releaseJson.sha256, }, { - pathname: "/firmware/v0.4.1/esp32-s3-n16r8/firmware.bin", + pathname: "/firmware/v0.4.2/esp32-s3-n16r8/firmware.bin", releaseKey: fixture.descriptor.releaseJson.sha256, }, ]); @@ -153,7 +153,7 @@ describe("firmware integrity verifier", () => { const schemaCall = fetcher.mock.calls.find(([input]) => { return ( new URL(inputUrl(input), firmwareOrigin).pathname === - "/firmware/v0.4.1/release.schema.json" + "/firmware/v0.4.2/release.schema.json" ); }); expect(schemaCall).toBeDefined(); @@ -195,7 +195,7 @@ describe("firmware integrity verifier", () => { await expect(verify(protectedCandidate)).resolves.toMatchObject({ profileId: "esp32-s3-n16r8", - version: "0.4.1", + version: "0.4.2", }); await expect(verify(publicPending)).rejects.toThrow(); await expect(verify(exposedCandidate)).rejects.toThrow(); @@ -401,7 +401,7 @@ describe("firmware integrity verifier", () => { const fixture = createFirmwareReleaseFixture({ mutateDescriptor: (descriptor) => { descriptor.releaseJson.path = - "https://example.invalid/firmware/v0.4.1/release.json"; + "https://example.invalid/firmware/v0.4.2/release.json"; }, }); const fetcher = mockFetch(fixture); diff --git a/tools/web/src/test/firmware-public-beta-policy.test.ts b/tools/web/src/test/firmware-public-beta-policy.test.ts new file mode 100644 index 0000000..c5e4d3e --- /dev/null +++ b/tools/web/src/test/firmware-public-beta-policy.test.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import { describe, expect, it } from "vitest"; + +import { isExactPublicBetaFirmwareRelease } from "@/lib/firmware-release"; +import { publicBetaFirmwareRelease } from "@/test/fixtures/firmware-release"; + +describe("exact public firmware beta policy", () => { + it("accepts only the audited unrestricted pending v0.4.2 descriptor", () => { + expect(isExactPublicBetaFirmwareRelease(publicBetaFirmwareRelease)).toBe( + true, + ); + + for (const mutate of [ + (descriptor: Record) => { + descriptor.version = "0.4.1"; + }, + (descriptor: Record) => { + descriptor.hilStatus = "passed"; + }, + (descriptor: Record) => { + descriptor.accessControlled = true; + }, + (descriptor: Record) => { + const releaseJson = descriptor.releaseJson as Record; + releaseJson.sha256 = "0".repeat(64); + }, + ]) { + const descriptor = structuredClone( + publicBetaFirmwareRelease, + ) as unknown as Record; + mutate(descriptor); + expect(isExactPublicBetaFirmwareRelease(descriptor)).toBe(false); + } + }); +}); diff --git a/tools/web/src/test/firmware-staging.test.ts b/tools/web/src/test/firmware-staging.test.ts index e560af6..7b0e12f 100644 --- a/tools/web/src/test/firmware-staging.test.ts +++ b/tools/web/src/test/firmware-staging.test.ts @@ -19,7 +19,11 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { stageFirmwareRelease } from "../../scripts/stage-firmware-release"; +import { + stageFirmwareRelease, + validateAttestedPublicBetaBundle, + validatePreservedPublicFirmwareRelease, +} from "../../scripts/stage-firmware-release"; import { bundleFiles, createFirmwareReleaseFixture, @@ -113,7 +117,7 @@ describe("external firmware bundle staging", () => { join( outputDirectory, "firmware", - "v0.4.1", + "v0.4.2", "esp32-s3-n16r8", "manifest.json", ), @@ -124,14 +128,14 @@ describe("external firmware bundle staging", () => { join( outputDirectory, "firmware", - "v0.4.1", + "v0.4.2", "esp32-s3-n16r8", "firmware.bin", ), ).then((value) => Array.from(value)), ).resolves.toEqual(Array.from(fixture.firmwareBytes)); await expect( - readdir(join(outputDirectory, "firmware", "v0.4.1")), + readdir(join(outputDirectory, "firmware", "v0.4.2")), ).resolves.not.toContain("esp32-c3-4mb"); expect(fixture.descriptor.profiles.map(({ id }) => id)).toEqual([ "esp32-4mb", @@ -160,7 +164,7 @@ describe("external firmware bundle staging", () => { accessControlled: true, deployment: "candidate", hilStatus: "pending", - version: "0.4.1", + version: "0.4.2", }); await expect( stageFixture({ @@ -180,6 +184,67 @@ describe("external firmware bundle staging", () => { ).rejects.toThrow(); }); + it("stages an unrestricted pending public beta but rejects false beta status claims", async () => { + const fixture = createFirmwareReleaseFixture({ + deployment: "public-beta", + accessControlled: false, + hilStatus: "pending", + }); + const bundleDirectory = await temporaryDirectory("public-beta-bundle"); + await writeExternalBundle(bundleDirectory, fixture); + + const stagedRoot = await temporaryDirectory("public-beta-staged"); + await expect( + stageFixture({ + accessControlled: false, + bundleDirectory, + deployment: "public-beta", + outputDirectory: stagedRoot, + }), + ).resolves.toMatchObject({ + accessControlled: false, + deployment: "public-beta", + hilStatus: "pending", + version: "0.4.2", + }); + await expect( + validatePreservedPublicFirmwareRelease(stagedRoot, { + releaseValidator: acceptSyntheticFixture, + }), + ).rejects.toThrow(/exact public beta/i); + + await expect( + stageFixture({ + accessControlled: true, + bundleDirectory, + deployment: "public-beta", + outputDirectory: await temporaryDirectory("controlled-public-beta"), + }), + ).rejects.toThrow(/public beta/i); + await expect( + stageFixture({ + accessControlled: false, + bundleDirectory, + deployment: "public", + outputDirectory: await temporaryDirectory("qualified-pending-beta"), + }), + ).rejects.toThrow(); + }); + + it("production beta validation rejects a self-consistent bundle with an unattested release root", async () => { + const fixture = createFirmwareReleaseFixture({ + deployment: "public-beta", + accessControlled: false, + hilStatus: "pending", + }); + const bundleDirectory = await temporaryDirectory("unattested-beta"); + await writeExternalBundle(bundleDirectory, fixture); + + await expect( + validateAttestedPublicBetaBundle(bundleDirectory, "public-beta"), + ).rejects.toThrow(/audited.*v0\.4\.2/i); + }); + it("rejects external bytes that no longer match the generated bundle checksums", async () => { const fixture = createFirmwareReleaseFixture(); const bundleDirectory = await temporaryDirectory("corrupt-bundle"); @@ -458,7 +523,6 @@ describe("external firmware bundle staging", () => { "scripts", "stage-firmware-release.js", ); - const repositoryRoot = join(process.cwd(), "..", ".."); const bundleDirectory = await temporaryDirectory( "candidate-license-validation-bundle", ); @@ -468,6 +532,9 @@ describe("external firmware bundle staging", () => { const licenseBuildRoot = await temporaryDirectory( "candidate-license-build", ); + const firmwareSourceRoot = await temporaryDirectory( + "candidate-source-root", + ); const fakeBin = await temporaryDirectory("candidate-validator-bin"); const fakePython = join(fakeBin, "python3"); await writeFile( @@ -491,6 +558,7 @@ describe("external firmware bundle staging", () => { }; delete validationEnvironment.PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR; delete validationEnvironment.PYBLE_FIRMWARE_LICENSE_BUILD_ROOT; + delete validationEnvironment.PYBLE_FIRMWARE_SOURCE_ROOT; for (const [label, evidenceEnvironment] of [ ["both evidence inputs", {}], @@ -498,12 +566,21 @@ describe("external firmware bundle staging", () => { "the exact build root", { PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR: licenseEvidenceDirectory, + PYBLE_FIRMWARE_SOURCE_ROOT: firmwareSourceRoot, }, ], [ "the retained evidence directory", { PYBLE_FIRMWARE_LICENSE_BUILD_ROOT: licenseBuildRoot, + PYBLE_FIRMWARE_SOURCE_ROOT: firmwareSourceRoot, + }, + ], + [ + "the exact firmware source root", + { + PYBLE_FIRMWARE_LICENSE_BUILD_ROOT: licenseBuildRoot, + PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR: licenseEvidenceDirectory, }, ], ] as const) { @@ -545,6 +622,7 @@ describe("external firmware bundle staging", () => { ...validationEnvironment, PYBLE_FIRMWARE_LICENSE_BUILD_ROOT: licenseBuildRoot, PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR: licenseEvidenceDirectory, + PYBLE_FIRMWARE_SOURCE_ROOT: firmwareSourceRoot, PYBLE_TEST_VALIDATOR_CAPTURE: captureFile, }, }, @@ -565,7 +643,86 @@ describe("external firmware bundle staging", () => { expect.soft(arguments_).toContain("--repo-root"); expect .soft(arguments_[arguments_.indexOf("--repo-root") + 1]) - .toBe(repositoryRoot); + .toBe(firmwareSourceRoot); + }); + + it("runs a public beta through the canonical audited-candidate and license gates", async () => { + const stagingScript = join( + process.cwd(), + "scripts", + "stage-firmware-release.js", + ); + const bundleDirectory = await temporaryDirectory( + "public-beta-audit-bundle", + ); + const licenseEvidenceDirectory = await temporaryDirectory( + "public-beta-license-evidence", + ); + const licenseBuildRoot = await temporaryDirectory( + "public-beta-license-build", + ); + const firmwareSourceRoot = await temporaryDirectory( + "public-beta-source-root", + ); + const fakeBin = await temporaryDirectory("public-beta-validator-bin"); + const fakePython = join(fakeBin, "python3"); + const captureFile = join(fakeBin, "public-beta-arguments.txt"); + await writeFile( + fakePython, + [ + "#!/bin/sh", + 'printf "%s\\n" "$@" > "${PYBLE_TEST_VALIDATOR_CAPTURE}"', + "", + ].join("\n"), + "utf8", + ); + await chmod(fakePython, 0o755); + + const validationProgram = [ + `const staging = await import(${JSON.stringify(pathToFileURL(stagingScript).href)});`, + `await staging.validateWithCanonicalReleaseTool(${JSON.stringify(bundleDirectory)}, "public-beta");`, + ].join("\n"); + const baseEnvironment: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PYBLE_TEST_VALIDATOR_CAPTURE: captureFile, + }; + delete baseEnvironment.PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR; + delete baseEnvironment.PYBLE_FIRMWARE_LICENSE_BUILD_ROOT; + delete baseEnvironment.PYBLE_FIRMWARE_SOURCE_ROOT; + + await expect( + execFile( + process.execPath, + ["--input-type=module", "--eval", validationProgram], + { cwd: process.cwd(), env: baseEnvironment }, + ), + ).rejects.toThrow(); + + await execFile( + process.execPath, + ["--input-type=module", "--eval", validationProgram], + { + cwd: process.cwd(), + env: { + ...baseEnvironment, + PYBLE_FIRMWARE_LICENSE_BUILD_ROOT: licenseBuildRoot, + PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR: licenseEvidenceDirectory, + PYBLE_FIRMWARE_SOURCE_ROOT: firmwareSourceRoot, + }, + }, + ); + const arguments_ = (await readFile(captureFile, "utf8")) + .trimEnd() + .split("\n"); + expect(arguments_).toContain("--audited-candidate"); + expect(arguments_).not.toContain("--public"); + expect(arguments_).toContain("--license-evidence-dir"); + expect(arguments_).toContain("--license-build-root"); + expect(arguments_).toContain("--repo-root"); + expect(arguments_[arguments_.indexOf("--repo-root") + 1]).toBe( + firmwareSourceRoot, + ); }); it("leaves no finalized version or selector when final staged-root validation fails", async () => { @@ -628,7 +785,7 @@ describe("external firmware bundle staging", () => { ).resolves.toEqual(descriptor); await writeFile( - join(stagedRoot, "firmware", "v0.4.1", "release.json"), + join(stagedRoot, "firmware", "v0.4.2", "release.json"), new Uint8Array([0x7b, 0x7d, 0x0a]), ); await expect( diff --git a/tools/web/src/test/fixtures/firmware-release.ts b/tools/web/src/test/fixtures/firmware-release.ts index 8aa8e88..4bcbbe8 100644 --- a/tools/web/src/test/fixtures/firmware-release.ts +++ b/tools/web/src/test/fixtures/firmware-release.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; -export const firmwareVersion = "0.4.1"; +export const firmwareVersion = "0.4.2"; export const firmwareOrigin = "https://pyble.dev"; export const firmwareProfiles = [ @@ -11,8 +11,8 @@ export const firmwareProfiles = [ id: "esp32-4mb", label: "ESP32 · 4 MiB flash", chipFamily: "ESP32", - manifestPath: "/firmware/v0.4.1/esp32-4mb/manifest.json", - firmwarePath: "/firmware/v0.4.1/esp32-4mb/firmware.bin", + manifestPath: "/firmware/v0.4.2/esp32-4mb/manifest.json", + firmwarePath: "/firmware/v0.4.2/esp32-4mb/firmware.bin", offset: 4096, siliconRevision: { minimumFull: 0, @@ -29,8 +29,8 @@ export const firmwareProfiles = [ id: "esp32-s3-n16r8", label: "ESP32-S3 · N16R8", chipFamily: "ESP32-S3", - manifestPath: "/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", - firmwarePath: "/firmware/v0.4.1/esp32-s3-n16r8/firmware.bin", + manifestPath: "/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", + firmwarePath: "/firmware/v0.4.2/esp32-s3-n16r8/firmware.bin", offset: 0, siliconRevision: { minimumFull: 0, @@ -46,7 +46,7 @@ export const firmwareProfiles = [ ] as const; export type FirmwareProfileId = (typeof firmwareProfiles)[number]["id"]; -export type FirmwareDeployment = "public" | "candidate"; +export type FirmwareDeployment = "public" | "candidate" | "public-beta"; export type HilStatus = "pending" | "passed"; interface TestArtifact { @@ -708,6 +708,15 @@ export const passedPublicFirmwareRelease = export const pendingPublicFirmwareRelease = createFirmwareReleaseFixture({ hilStatus: "pending", }).descriptor; +export const publicBetaFirmwareRelease = { + ...structuredClone(pendingPublicFirmwareRelease), + deployment: "public-beta", + accessControlled: false, + releaseJson: { + path: "/firmware/v0.4.2/release.json", + sha256: "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", + }, +} satisfies FirmwareReleaseDescriptor; export const pendingCandidateFirmwareRelease = createFirmwareReleaseFixture({ deployment: "candidate", accessControlled: true, diff --git a/tools/web/src/test/flash-installer.test.tsx b/tools/web/src/test/flash-installer.test.tsx index 53615d6..2612e5c 100644 --- a/tools/web/src/test/flash-installer.test.tsx +++ b/tools/web/src/test/flash-installer.test.tsx @@ -24,6 +24,7 @@ import { passedPublicFirmwareRelease, pendingCandidateFirmwareRelease, pendingPublicFirmwareRelease, + publicBetaFirmwareRelease, type FirmwareProfileId, uncontrolledCandidateFirmwareRelease, verifiedProfile, @@ -113,11 +114,11 @@ function releaseAtVersion( release.recoveryPath = `/firmware/v${version}/RECOVERY.md`; for (const profile of release.profiles) { profile.manifestPath = profile.manifestPath.replace( - "/firmware/v0.4.1/", + "/firmware/v0.4.2/", `/firmware/v${version}/`, ); profile.firmwarePath = profile.firmwarePath.replace( - "/firmware/v0.4.1/", + "/firmware/v0.4.2/", `/firmware/v${version}/`, ); } @@ -157,8 +158,8 @@ function withDeferredC3( id: "esp32-c3-4mb", label: "ESP32-C3 revision v0.3+ · 4 MiB flash", chipFamily: "ESP32-C3", - manifestPath: "/firmware/v0.4.1/esp32-c3-4mb/manifest.json", - firmwarePath: "/firmware/v0.4.1/esp32-c3-4mb/firmware.bin", + manifestPath: "/firmware/v0.4.2/esp32-c3-4mb/manifest.json", + firmwarePath: "/firmware/v0.4.2/esp32-c3-4mb/firmware.bin", offset: 0, }); malformed.profiles.push(deferred); @@ -204,7 +205,7 @@ describe("browser firmware installer states", () => { render(); expect(screen.getByRole("status")).toHaveTextContent( - /installer unavailable.*hardware validation.*both exact current candidate profiles/i, + /installer unavailable.*hardware validation.*both exact current release profiles/i, ); expect( screen.getByRole("button", { name: /installer coming soon/i }), @@ -313,6 +314,43 @@ describe("browser firmware installer states", () => { expect(document.querySelector("esp-web-install-button")).toBeNull(); }); + it("offers the exact v0.4.2 hardware-tested beta with scoped qualification copy", async () => { + renderInstaller({ release: publicBetaFirmwareRelease }); + + expect(screen.getByRole("status")).toHaveTextContent( + /hardware-tested firmware beta.*browser installation.*interrupted-flash recovery.*esp32-4mb.*esp32-s3-n16r8.*complete release qualification.*pending.*not a qualified release/i, + ); + expect(screen.queryByText(/protected release candidate/i)).toBeNull(); + expect( + screen.queryByRole("heading", { name: /^qualified release$/i }), + ).toBeNull(); + expect(screen.queryByText(/full HIL pending/i)).toBeNull(); + expect(screen.queryByText(/use at your own risk/i)).toBeNull(); + + const profileGroup = screen.getByRole("radiogroup", { + name: /select the exact module profile/i, + }); + expect(within(profileGroup).getAllByRole("radio")).toHaveLength(2); + expect(within(profileGroup).queryByText(/esp32-c3/i)).toBeNull(); + + selectS3Profile(); + acceptEveryConsent(); + startVerification(); + await waitFor(() => { + expect(document.querySelector("esp-web-install-button")).not.toBeNull(); + }); + expect( + screen.getByText( + /browser installation.*interrupted-flash recovery.*real hardware.*exact profile.*complete release qualification.*pending/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /install pyble 0\.4\.2 beta/i, + }), + ).toBeInTheDocument(); + }); + it("starts disabled with exactly both qualified profiles and keeps deferred C3 out of the selector", () => { renderInstaller(); @@ -484,15 +522,15 @@ describe("browser firmware installer states", () => { ); expect(installElement).toHaveAttribute( "manifest", - "/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", + "/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", ); expect(installElement).not.toHaveAttribute( "manifest", - "/firmware/v0.4.1/manifest.json", + "/firmware/v0.4.2/manifest.json", ); expect( within(installElement).getByRole("button", { - name: /install pyble 0\.4\.1/i, + name: /install pyble 0\.4\.2/i, }), ).toHaveAttribute("slot", "activate"); expect(screen.getByText(/artifacts verified/i)).toBeInTheDocument(); @@ -502,8 +540,8 @@ describe("browser firmware installer states", () => { screen.getAllByText(/installation erases the device/i).length, ).toBeGreaterThan(0); expect( - screen.getByRole("link", { name: /version 0\.4\.1 recovery/i }), - ).toHaveAttribute("href", "/firmware/v0.4.1/RECOVERY.md"); + screen.getByRole("link", { name: /version 0\.4\.2 recovery/i }), + ).toHaveAttribute("href", "/firmware/v0.4.2/RECOVERY.md"); }); it("removes prior qualification when the selected profile changes", async () => { @@ -636,15 +674,15 @@ describe("browser firmware installer states", () => { ).toEqual([ { id: "esp32-4mb", - manifestPath: "/firmware/v0.4.1/esp32-4mb/manifest.json", + manifestPath: "/firmware/v0.4.2/esp32-4mb/manifest.json", }, { id: "esp32-s3-n16r8", - manifestPath: "/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", + manifestPath: "/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", }, ]); expect(passedPublicFirmwareRelease.releaseJson.path).toBe( - "/firmware/v0.4.1/release.json", + "/firmware/v0.4.2/release.json", ); expect(JSON.stringify(passedPublicFirmwareRelease)).not.toContain( "esp32-c3-4mb", diff --git a/tools/web/src/test/flash-page-release-copy.test.tsx b/tools/web/src/test/flash-page-release-copy.test.tsx new file mode 100644 index 0000000..9ad6447 --- /dev/null +++ b/tools/web/src/test/flash-page-release-copy.test.tsx @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import FlashPage from "@/app/flash/page"; +import { passedPublicFirmwareRelease } from "@/test/fixtures/firmware-release"; + +describe("firmware installer release copy", () => { + it("describes a qualified public selector as available", async () => { + const selectionRoot = await mkdtemp( + join(tmpdir(), "pyble-flash-qualified-selection-"), + ); + const selectionFile = join(selectionRoot, "selection.json"); + const previousSelection = process.env.PYBLE_FLASH_SELECTION_FILE; + process.env.PYBLE_FLASH_SELECTION_FILE = selectionFile; + + try { + await writeFile( + selectionFile, + JSON.stringify(passedPublicFirmwareRelease), + "utf8", + ); + render(); + + expect( + screen.getByText(/qualified v0\.4\.2 firmware is available/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/public install action remains unavailable/i), + ).toBeNull(); + } finally { + if (previousSelection === undefined) { + delete process.env.PYBLE_FLASH_SELECTION_FILE; + } else { + process.env.PYBLE_FLASH_SELECTION_FILE = previousSelection; + } + await rm(selectionRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/web/src/test/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index e07820e..1a385ee 100644 --- a/tools/web/src/test/site-contract.test.tsx +++ b/tools/web/src/test/site-contract.test.tsx @@ -2,7 +2,8 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { render, screen, within } from "@testing-library/react"; @@ -15,7 +16,17 @@ import PrivacyPage, { metadata as privacyMetadata } from "@/app/privacy/page"; import SupportPage, { metadata as supportMetadata } from "@/app/support/page"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; -import { initialFirmwareTargets, navigation, siteConfig } from "@/lib/site"; +import { + firmwareTargetsForRelease, + initialFirmwareTargets, + navigation, + siteConfig, +} from "@/lib/site"; +import { + passedPublicFirmwareRelease, + pendingCandidateFirmwareRelease, + publicBetaFirmwareRelease, +} from "@/test/fixtures/firmware-release"; describe("public-site contract", () => { it("keeps pyble.dev canonical and presents the four launch routes", () => { @@ -77,8 +88,8 @@ describe("public-site contract", () => { }); it("publishes a real-app large social card and local TestFlight card", async () => { - const socialPngName = "pyble-beta-og-7e7e037d-1200x630.png"; - const socialSvgName = "pyble-beta-og-48d458bd-1200x630.svg"; + const socialPngName = "pyble-beta-og-277eee8a-1200x630.png"; + const socialSvgName = "pyble-beta-og-b47b6d10-1200x630.svg"; const socialUrl = `https://pyble.dev/social/${socialPngName}`; expect(rootMetadata.openGraph?.images).toEqual([ { @@ -115,8 +126,8 @@ describe("public-site contract", () => { ]); expect(socialSvg).toContain("One-time USB setup."); expect(socialSvg).toContain("Everyday coding over BLE."); - expect(socialSvg).toContain("FIRMWARE HIL PENDING"); - expect(socialSvg).not.toContain("WEB FLASHER"); + expect(socialSvg).toContain("WEB FLASHING VALIDATED"); + expect(socialSvg).not.toContain("FIRMWARE HIL PENDING"); const socialPngSha256 = createHash("sha256") .update(socialPng) .digest("hex"); @@ -124,10 +135,10 @@ describe("public-site contract", () => { .update(socialSvg) .digest("hex"); expect(socialPngSha256).toBe( - "7e7e037d9bd2e58e2e66f516bfd6f1b753bda472c402b8130f8a8a6dd8f19ba9", + "277eee8ae859c3e26444df830cf1b03f624f2be1f1524bc3246ce2d946332023", ); expect(socialSvgSha256).toBe( - "48d458bd5a6ee1e754bad7d7f9a7261361c0d8a3b13c4fc9cc8a9f5670e04b5d", + "b47b6d10d6de3e16a5687680d8f34f115f9a276d0fdbde06051751de2270ddfd", ); expect(socialPngName).toContain(socialPngSha256.slice(0, 8)); expect(socialSvgName).toContain(socialSvgSha256.slice(0, 8)); @@ -159,7 +170,7 @@ describe("public-site contract", () => { ); }); - it("states the vendor-neutral vision and the truthful pre-activation firmware state", () => { + it("states the vendor-neutral vision without claiming unavailable firmware is active", () => { render(); expect( @@ -173,12 +184,12 @@ describe("public-site contract", () => { /designed for boards that run MicroPython and support Bluetooth Low Energy/i, ), ).toBeInTheDocument(); - expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( - /pending HIL for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, - ); - expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( - /public browser installer stays unavailable until both exact profiles pass HIL/i, - ); + expect( + screen.getAllByText(/firmware installer is currently unavailable/i), + ).toHaveLength(2); + expect( + screen.queryByText(/unqualified firmware beta is available/i), + ).not.toBeInTheDocument(); expect( screen.getByText( /ESP32-C3 and more microcontroller families remain planned/i, @@ -332,14 +343,14 @@ describe("public-site contract", () => { id: "esp32-4mb", target: "Classic ESP32", constraint: "4 MiB external SPI flash · no PSRAM assumed", - status: "v0.4.2 HIL pending · installer unavailable", + status: "Installer unavailable", planned: false, }, { id: "esp32-s3-n16r8", target: "ESP32-S3 N16R8", constraint: "16 MiB flash · 8 MiB Octal PSRAM", - status: "v0.4.2 HIL pending · installer unavailable", + status: "Installer unavailable", planned: false, }, { @@ -350,6 +361,22 @@ describe("public-site contract", () => { planned: true, }, ]); + expect( + firmwareTargetsForRelease(passedPublicFirmwareRelease) + .filter(({ planned }) => !planned) + .map(({ status }) => status), + ).toEqual([ + "v0.4.2 qualified public release", + "v0.4.2 qualified public release", + ]); + expect( + firmwareTargetsForRelease(pendingCandidateFirmwareRelease) + .filter(({ planned }) => !planned) + .map(({ status }) => status), + ).toEqual([ + "v0.4.2 protected candidate · HIL pending", + "v0.4.2 protected candidate · HIL pending", + ]); render(); @@ -359,6 +386,111 @@ describe("public-site contract", () => { expect(targetCard).toHaveTextContent(target.constraint); expect(targetCard).toHaveTextContent(target.status); } + expect(screen.getByText(/provision once/i).closest("li")).toHaveTextContent( + /check firmware status.*installer is currently unavailable/i, + ); + expect( + screen.getByRole("link", { name: /open firmware installer/i }), + ).toHaveAttribute("href", "/flash"); + }); + + it("shows the scoped hardware-tested beta claims only when its build selector is active", async () => { + const selectionRoot = await mkdtemp( + join(tmpdir(), "pyble-site-beta-selection-"), + ); + const selectionFile = join(selectionRoot, "selection.json"); + await writeFile( + selectionFile, + JSON.stringify(publicBetaFirmwareRelease), + "utf8", + ); + const previousSelection = process.env.PYBLE_FLASH_SELECTION_FILE; + process.env.PYBLE_FLASH_SELECTION_FILE = selectionFile; + + try { + const home = render(); + expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( + /hardware-tested beta for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, + ); + expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( + /production chrome install.*interrupted-flash recovery passed on both exact profiles.*complete release qualification continues/i, + ); + for (const target of firmwareTargetsForRelease( + publicBetaFirmwareRelease, + ).filter(({ planned }) => !planned)) { + expect(screen.getByText(target.id).closest("div")).toHaveTextContent( + "v0.4.2 hardware-tested beta · browser install/recovery passed · release qualification pending", + ); + } + home.unmount(); + + render(); + expect( + screen.getByText(/v0\.4\.2 hardware-tested beta is available/i), + ).toHaveTextContent( + /production chrome install.*interrupted-flash recovery passed on both exact profiles.*complete release qualification continues/i, + ); + expect(screen.queryByText(/full HIL pending/i)).toBeNull(); + expect(screen.queryByText(/use at your own risk/i)).toBeNull(); + } finally { + if (previousSelection === undefined) { + delete process.env.PYBLE_FLASH_SELECTION_FILE; + } else { + process.env.PYBLE_FLASH_SELECTION_FILE = previousSelection; + } + await rm(selectionRoot, { recursive: true, force: true }); + } + }); + + it("keeps qualified and protected-candidate copy distinct", async () => { + const selectionRoot = await mkdtemp( + join(tmpdir(), "pyble-site-release-selection-"), + ); + const selectionFile = join(selectionRoot, "selection.json"); + const previousSelection = process.env.PYBLE_FLASH_SELECTION_FILE; + process.env.PYBLE_FLASH_SELECTION_FILE = selectionFile; + + try { + await writeFile( + selectionFile, + JSON.stringify(passedPublicFirmwareRelease), + "utf8", + ); + const qualifiedHome = render(); + expect( + screen.getByText(/qualified public v0\.4\.2 firmware/i), + ).toHaveTextContent( + /available for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, + ); + qualifiedHome.unmount(); + const qualifiedSupport = render(); + expect( + screen.getByText(/qualified v0\.4\.2 firmware is available/i), + ).toBeInTheDocument(); + qualifiedSupport.unmount(); + + await writeFile( + selectionFile, + JSON.stringify(pendingCandidateFirmwareRelease), + "utf8", + ); + const candidateHome = render(); + expect( + screen.getByText(/protected candidate v0\.4\.2 is staged/i), + ).toBeInTheDocument(); + candidateHome.unmount(); + render(); + expect( + screen.getByText(/firmware installer is currently unavailable/i), + ).toBeInTheDocument(); + } finally { + if (previousSelection === undefined) { + delete process.env.PYBLE_FLASH_SELECTION_FILE; + } else { + process.env.PYBLE_FLASH_SELECTION_FILE = previousSelection; + } + await rm(selectionRoot, { recursive: true, force: true }); + } }); it("keeps the public installer unavailable while explaining exact profiles, BLE use, and recovery", () => { @@ -375,7 +507,7 @@ describe("public-site contract", () => { ).toBeInTheDocument(); expect( screen.getByText( - /public install action remains unavailable until the final bytes pass hardware validation on both exact current candidate profiles/i, + /public install action remains unavailable until the final bytes pass hardware validation on both exact current release profiles/i, ), ).toBeInTheDocument(); expect(screen.getByText(/esp32-s3-n16r8/i)).toBeInTheDocument(); @@ -542,9 +674,7 @@ describe("public-site contract", () => { screen.getByText(/iPadOS or Android name\/version/i), ).toBeInTheDocument(); expect( - screen.getByText( - /public installer is unavailable while v0\.4\.2 HIL runs for esp32-4mb and esp32-s3-n16r8/i, - ), + screen.getByText(/firmware installer is currently unavailable/i), ).toBeInTheDocument(); expect( screen.getByText(/ESP32-C3 is not currently available/i), diff --git a/tools/web/src/test/sites-output.test.ts b/tools/web/src/test/sites-output.test.ts index 099c672..a459302 100644 --- a/tools/web/src/test/sites-output.test.ts +++ b/tools/web/src/test/sites-output.test.ts @@ -153,14 +153,14 @@ describe("Sites vinext-output adapter", () => { ); const firmwareManifest = await worker.fetch( new Request( - "https://pyble.dev/firmware/v0.4.1/esp32-s3-n16r8/manifest.json", + "https://pyble.dev/firmware/v0.4.2/esp32-s3-n16r8/manifest.json", ), {}, context, ); const firmwareBinary = await worker.fetch( new Request( - "https://pyble.dev/firmware/v0.4.1/esp32-s3-n16r8/firmware.bin", + "https://pyble.dev/firmware/v0.4.2/esp32-s3-n16r8/firmware.bin", ), {}, context, @@ -209,11 +209,11 @@ describe("Sites vinext-output adapter", () => { }, firmwareManifest: { status: 200, - body: "vinext:/firmware/v0.4.1/esp32-s3-n16r8/manifest.json:execution-context", + body: "vinext:/firmware/v0.4.2/esp32-s3-n16r8/manifest.json:execution-context", }, firmwareBinary: { status: 200, - body: "vinext:/firmware/v0.4.1/esp32-s3-n16r8/firmware.bin:execution-context", + body: "vinext:/firmware/v0.4.2/esp32-s3-n16r8/firmware.bin:execution-context", }, notFound: { status: 404, body: notFoundEntry }, }); @@ -327,7 +327,7 @@ describe("Sites vinext-output adapter", () => { "dist", "client", "firmware", - "v0.4.1", + "v0.4.2", "esp32-s3-n16r8", "firmware.bin", ), @@ -340,7 +340,7 @@ describe("Sites vinext-output adapter", () => { "dist", "client", "firmware", - "v0.4.1", + "v0.4.2", "esp32-c3-4mb", "manifest.json", ), diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index f29f15b..caeb7d7 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -89,23 +89,50 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(headers).toContain("Strict-Transport-Security"); }); - it("quarantines the burned pre-public firmware candidate", async () => { + it("keeps firmware 404 responses non-cacheable through the shared error page", async () => { const config = await readFile( join(deploymentRoot, "nginx", "10-pyble-dev-https.conf"), "utf8", ); - const quarantine = - /location \^~ \/firmware\/v0\.4\.1\/\s*\{([\s\S]*?)\n\s*\}/.exec( - config, - )?.[1]; - const quarantineIndex = config.indexOf("location ^~ /firmware/v0.4.1/"); - const generalFirmwareIndex = config.indexOf("location ^~ /firmware/ {"); - - expect(quarantine).toBeDefined(); - expect.soft(quarantine).toMatch(/Cache-Control\s+"no-store"/); - expect.soft(quarantine).toMatch(/return\s+404/); - expect(quarantineIndex).toBeGreaterThan(-1); - expect(quarantineIndex).toBeLessThan(generalFirmwareIndex); + + expect(config).toMatch( + /map \$request_uri \$pyble_not_found_cache_control\s*\{[\s\S]*?~\^\/firmware\/ "no-store";[\s\S]*?default "no-cache, no-transform";[\s\S]*?\}/, + ); + expect(config).toMatch( + /location = \/404\.html\s*\{[\s\S]*?add_header Cache-Control \$pyble_not_found_cache_control always;/, + ); + }); + + it("keeps retired unversioned social-card 404 responses out of caches", async () => { + const [config, script] = await Promise.all([ + readFile( + join(deploymentRoot, "nginx", "10-pyble-dev-https.conf"), + "utf8", + ), + readFile(join(deploymentRoot, "vps", "deploy.sh"), "utf8"), + ]); + + expect(config).toContain( + '~^/social/pyble-beta-og-1200x630\\.(?:png|svg)(?:\\?|$) "no-store";', + ); + expect(script).toContain("retired_public_asset_paths=("); + expect(script).toContain("/social/pyble-beta-og-1200x630.png"); + expect(script).toContain("/social/pyble-beta-og-1200x630.svg"); + expect(script).toContain("retired_public_asset_methods=( GET HEAD )"); + expect(script).toMatch(/retired_public_asset_status[\s\S]*?!= 404/); + expect(script).toContain("Cache-Control: *no-store"); + }); + + it("routes the exact v0.4.2 public beta through the immutable firmware boundary", async () => { + const config = await readFile( + join(deploymentRoot, "nginx", "10-pyble-dev-https.conf"), + "utf8", + ); + expect(config).not.toContain("location ^~ /firmware/v0.4.2/"); + expect(config).not.toContain("@burned_firmware_candidate"); + expect(config).toMatch( + /location \^~ \/firmware\/\s*\{[\s\S]*?alias \/srv\/pyble\/firmware\//, + ); }); it("preserves path and query while canonicalizing every alternate host", async () => { @@ -160,6 +187,36 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(script).not.toMatch(/BEGIN (?:RSA |OPENSSH )?PRIVATE KEY/); }); + it("accepts only the exact unrestricted pending public beta in the activation path", async () => { + const [script, staging] = await Promise.all([ + readFile(join(deploymentRoot, "vps", "deploy.sh"), "utf8"), + readFile( + join(process.cwd(), "scripts", "stage-firmware-release.js"), + "utf8", + ), + ]); + + expect(script).toContain('descriptor.deployment === "public-beta"'); + expect(script).toContain('descriptor.hilStatus === "pending"'); + expect(script).toContain("descriptor.accessControlled === false"); + expect(script).toContain( + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", + ); + expect(staging).toMatch( + /public-beta[\s\S]*?--audited-candidate[\s\S]*?--license-evidence-dir[\s\S]*?--license-build-root/, + ); + expect(script).toContain("PYBLE_FIRMWARE_LICENSE_EVIDENCE_DIR"); + expect(script).toContain("PYBLE_FIRMWARE_LICENSE_BUILD_ROOT"); + expect(script).toContain("PYBLE_FIRMWARE_SOURCE_ROOT"); + expect(script).toMatch( + /if \[\[ "\$\{firmware_deployment\}" == public-beta \]\]; then\s+require_firmware_release_inputs\s+fi/, + ); + expect(script).toContain("local_firmware_tag_object_before_build"); + expect(script).not.toMatch( + /public-beta[\s\S]{0,180}(?:skip|does not require)[\s\S]{0,180}annotated tag/i, + ); + }); + it("retains deployment-evidence cleanup after installing the smoke-test EXIT trap", async () => { const script = await readFile( join(deploymentRoot, "vps", "deploy.sh"), @@ -393,6 +450,34 @@ describe("Cloudflare-fronted VPS deployment", () => { ); }); + it("requires missing firmware and deferred C3 smoke responses to be 404 no-store", async () => { + const script = await readFile( + join(deploymentRoot, "vps", "deploy.sh"), + "utf8", + ); + const smokeStart = script.indexOf("firmware_not_found_paths=("); + const smokeEnd = script.indexOf("confirm_activation", smokeStart); + const firmwareNotFoundSmoke = script.slice(smokeStart, smokeEnd); + + expect(smokeStart).toBeGreaterThan(-1); + expect(smokeEnd).toBeGreaterThan(smokeStart); + expect(firmwareNotFoundSmoke).toContain("/firmware/not-found-smoke"); + expect(firmwareNotFoundSmoke).toContain("/firmware/v0.4.1/release.json"); + expect(firmwareNotFoundSmoke).toContain( + "/firmware/v0.4.1/esp32-4mb/manifest.json", + ); + expect(firmwareNotFoundSmoke).toContain( + "firmware_not_found_methods=( GET HEAD )", + ); + expect(firmwareNotFoundSmoke).toContain("esp32-c3-4mb/manifest.json"); + expect(firmwareNotFoundSmoke).toContain("--dump-header"); + expect(firmwareNotFoundSmoke).toContain("--write-out '%{http_code}'"); + expect(firmwareNotFoundSmoke).toMatch( + /firmware_not_found_status[\s\S]*?!= 404/, + ); + expect(firmwareNotFoundSmoke).toContain("Cache-Control: *no-store"); + }); + it("freezes one clean full source commit through the completed website build", async () => { const script = await readFile( join(deploymentRoot, "vps", "deploy.sh"), diff --git a/tools/web/src/test/vps-self-hosted-release-contract.test.ts b/tools/web/src/test/vps-self-hosted-release-contract.test.ts index 2b1207a..72022bb 100644 --- a/tools/web/src/test/vps-self-hosted-release-contract.test.ts +++ b/tools/web/src/test/vps-self-hosted-release-contract.test.ts @@ -45,7 +45,10 @@ describe("self-hosted firmware activation", () => { .toContain("PYBLE_FLASH_SELECTION_FILE"); expect .soft(script.slice(stagedBranch, build)) - .toMatch(/deployment\s*!==\s*"public"[\s\S]*hilStatus\s*!==\s*"passed"/); + .toContain("const qualifiedPublic"); + expect + .soft(script.slice(stagedBranch, build)) + .toContain("const exactPublicBeta"); }); it("requires an unambiguous explicit operation to disable an active installer", () => { @@ -90,17 +93,21 @@ describe("self-hosted firmware activation", () => { ); }); - it("accepts only a canonically validated all-HIL-passed public staged release", () => { + it("accepts only a validated qualified release or the exact attested public beta", () => { const stagedBranch = script.indexOf( "if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then", ); const canonicalValidation = script.indexOf("--verify-staged", stagedBranch); const publicGate = script.indexOf( - 'descriptor.deployment !== "public"', + 'descriptor.deployment === "public"', stagedBranch, ); const hilGate = script.indexOf( - 'descriptor.hilStatus !== "passed"', + 'descriptor.hilStatus === "passed"', + stagedBranch, + ); + const betaGate = script.indexOf( + 'descriptor.deployment === "public-beta"', stagedBranch, ); const build = script.indexOf("NEXT_TELEMETRY_DISABLED=1 npm run check"); @@ -109,6 +116,7 @@ describe("self-hosted firmware activation", () => { expect(canonicalValidation).toBeGreaterThan(stagedBranch); expect(publicGate).toBeGreaterThan(canonicalValidation); expect(hilGate).toBeGreaterThan(canonicalValidation); + expect(betaGate).toBeGreaterThan(canonicalValidation); expect(hilGate).toBeLessThan(build); expect(script).not.toContain("PYBLE_GITHUB_REPOSITORY"); expect(script).not.toMatch(/\bgh\s+(?:api|repo|release)\b/);