From c06fa99050bef71dfc94beede6d5b00201c24b67 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:09:41 +0700 Subject: [PATCH 01/28] [docs] Define exact unqualified firmware beta Signed-off-by: Viwat Vchirawongkwin --- .../firmware/browser-flashing.md | 47 ++++++--- docs/specifications/website.md | 97 +++++++++++++------ 2 files changed, 100 insertions(+), 44 deletions(-) diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index 1138f76..d5cdcaa 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,8 +38,10 @@ BLE and PBLE/1. ## 1. Release image profiles -The current pre-v1 public bundle contains exactly these two qualified -**provisioning image profiles**: +The current pre-v1 browser bundle contains exactly these two +**provisioning image profiles**. The exact `v0.4.1` bytes may be offered only as +the unqualified public beta defined in §10; they are not qualified 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 | |---|---|---|---|---|---| @@ -394,7 +397,7 @@ beside their metadata: 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.1` 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`, @@ -408,7 +411,8 @@ 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 digest-bound `v0.4.1` `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 @@ -1436,9 +1440,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 @@ -1480,8 +1485,22 @@ 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 retained `v0.4.1` bundle MAY be +published as an explicitly **unqualified 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 +`8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445`. +The exact profile set is `esp32-4mb` plus `esp32-s3-n16r8`; C3 MUST remain +absent. Before the install control appears, the website MUST say that these +bytes were manually exercised on the maintainer's two boards but have not +passed the complete project HIL matrix, and that installation is at the user's +risk. It MUST NOT call the beta access-controlled, protected, qualified, +validated, or generally available. + +The beta path MUST retain all existing 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/website.md b/docs/specifications/website.md index e639989..0d2c8bc 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 an explicitly unqualified public beta from a +fully HIL-qualified public release. It MUST NOT imply that: @@ -71,12 +73,14 @@ 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 pre-v1 release list is the exact `esp32-4mb` and +- actual support requires a published firmware image for the exact target and a + truthful release state; an unqualified beta is not a validated release; +- 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 qualified memory profiles - in §7, including N16R8-class hardware for the initial ESP32-S3 image; +- 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 an unqualified 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, @@ -123,7 +127,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 and the exact qualified installer profiles. +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 @@ -132,17 +137,22 @@ 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 qualified. The one-time `v0.4.1` public-beta selector MAY +make the exact `esp32-4mb` and `esp32-s3-n16r8` images available under the +exception in §7, but every active installer state MUST visibly say **unqualified +beta**, **HIL pending**, and **use at your own risk** before profile selection and +again beside the install action. It MUST NOT say or imply that the beta is +access-controlled. 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 public-beta/pending-HIL 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. README getting-started instructions MUST gate destructive flashing on `/flash` showing an active version, exact profile, and enabled install action. While the @@ -151,11 +161,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.1` web flasher, it +MUST visibly call it an unqualified beta with HIL pending. 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 @@ -299,9 +309,10 @@ 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.1/` only while the exact attested 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. @@ -357,8 +368,32 @@ 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 already manually tested +`v0.4.1` bytes. A build-time selector MAY use deployment mode `public-beta` only +when all of these facts are true: + +- `version` is exactly `0.4.1`, `releaseJson.sha256` is exactly + `8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445`, + `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; + and +- `/flash` visibly labels the firmware an **unqualified beta**, says full HIL is + pending and installation is at the user's risk, and never calls it protected, + access-controlled, qualified, validated, or generally available. + +This exception attests only the identity and integrity of the retained 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 through the same integrity gate. + +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 release-profile artifact sets from the frozen @@ -385,7 +420,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 @@ -444,9 +480,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 From f8c0919add39add857f917a7f09a4b457524ed3d Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:12:25 +0700 Subject: [PATCH 02/28] [red] Guard exact unqualified firmware beta Signed-off-by: Viwat Vchirawongkwin --- tools/web/src/test/build-contract.test.ts | 35 +++++++++++++++++- .../test/firmware-public-beta-policy.test.ts | 37 +++++++++++++++++++ .../web/src/test/fixtures/firmware-release.ts | 12 +++++- tools/web/src/test/flash-installer.test.tsx | 30 +++++++++++++++ .../src/test/vps-deployment-contract.test.ts | 19 +++------- 5 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 tools/web/src/test/firmware-public-beta-policy.test.ts diff --git a/tools/web/src/test/build-contract.test.ts b/tools/web/src/test/build-contract.test.ts index 730d413..ee0fb20 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,36 @@ 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( 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..b1ca3bc --- /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 attested unrestricted pending v0.4.1 descriptor", () => { + expect(isExactPublicBetaFirmwareRelease(publicBetaFirmwareRelease)).toBe( + true, + ); + + for (const mutate of [ + (descriptor: Record) => { + descriptor.version = "0.4.2"; + }, + (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/fixtures/firmware-release.ts b/tools/web/src/test/fixtures/firmware-release.ts index 8aa8e88..8e9db6e 100644 --- a/tools/web/src/test/fixtures/firmware-release.ts +++ b/tools/web/src/test/fixtures/firmware-release.ts @@ -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,16 @@ 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.1/release.json", + sha256: + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", + }, +} 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 4e61ab9..870245d 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, @@ -313,6 +314,35 @@ describe("browser firmware installer states", () => { expect(document.querySelector("esp-web-install-button")).toBeNull(); }); + it("offers the exact unrestricted v0.4.1 beta with prominent unqualified warnings", async () => { + renderInstaller({ release: publicBetaFirmwareRelease }); + + expect(screen.getByRole("status")).toHaveTextContent( + /unqualified firmware beta.*full hardware-in-the-loop.*pending.*use at your own risk/i, + ); + expect(screen.queryByText(/protected release candidate/i)).toBeNull(); + expect(screen.queryByText(/qualified release/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(/unqualified beta.*use at your own risk/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /install unqualified beta pyble 0\.4\.1/i, + }), + ).toBeInTheDocument(); + }); + it("starts disabled with exactly both qualified profiles and keeps deferred C3 out of the selector", () => { renderInstaller(); diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index f29f15b..9244ed5 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -89,23 +89,16 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(headers).toContain("Strict-Transport-Security"); }); - it("quarantines the burned pre-public firmware candidate", async () => { + it("routes the exact v0.4.1 public beta through the immutable firmware boundary", 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).not.toContain("location ^~ /firmware/v0.4.1/"); + 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 () => { From d701eabced378f71a69a9a39e245b02a3c246e53 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:14:24 +0700 Subject: [PATCH 03/28] [green] Enable exact unqualified firmware beta Signed-off-by: Viwat Vchirawongkwin --- .../web/deploy/nginx/10-pyble-dev-https.conf | 16 -------- tools/web/src/components/flash-status.tsx | 39 ++++++++++++++++--- tools/web/src/lib/firmware-integrity.ts | 16 +++++++- .../web/src/lib/firmware-release-selection.ts | 12 +++++- tools/web/src/lib/firmware-release.ts | 27 ++++++++++++- tools/web/src/test/build-contract.test.ts | 4 +- .../web/src/test/fixtures/firmware-release.ts | 3 +- tools/web/src/test/flash-installer.test.tsx | 4 +- 8 files changed, 93 insertions(+), 28 deletions(-) diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index 21bd825..dd42e49 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -75,22 +75,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; diff --git a/tools/web/src/components/flash-status.tsx b/tools/web/src/components/flash-status.tsx index 06082ec..d4eb6f5 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, @@ -126,9 +127,23 @@ function policyFailure(release: FirmwareReleaseDescriptor | null | undefined) { }; } 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 attested v0.4.1 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 + ? "Unqualified firmware beta" + : "Qualified release"}

{candidate ? "Protected release candidate: hardware validation is pending." - : "Select and verify the exact module profile before installation."} + : publicBeta + ? "Unqualified firmware beta: full hardware-in-the-loop qualification is pending. These exact v0.4.1 bytes were manually exercised on the maintainer's ESP32 and ESP32-S3 boards, but they are not qualified. Use at your own risk." + : "Select and verify the exact module profile before installation."}
+ {publicBeta ? ( +

+ This is an unqualified beta with full HIL pending. Use at your own + risk. +

+ ) : null} {`Version ${verified.version} recovery instructions`} @@ -484,7 +512,8 @@ function FlashStatusForRelease({ type="button" slot="activate" > - Install PyBLE {verified.version} + {publicBeta ? "Install unqualified beta PyBLE" : "Install PyBLE"}{" "} + {verified.version}
diff --git a/tools/web/src/lib/firmware-integrity.ts b/tools/web/src/lib/firmware-integrity.ts index 954f3fb..ab949c0 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 attested v0.4.1 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..c04e961 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 attested v0.4.1 release", + ); + } return descriptor; } diff --git a/tools/web/src/lib/firmware-release.ts b/tools/web/src/lib/firmware-release.ts index d446177..3ef3c74 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.1"; +export const publicBetaReleaseJsonSha256 = + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + 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/test/build-contract.test.ts b/tools/web/src/test/build-contract.test.ts index ee0fb20..fbdf049 100644 --- a/tools/web/src/test/build-contract.test.ts +++ b/tools/web/src/test/build-contract.test.ts @@ -173,7 +173,9 @@ 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 selectionRoot = await mkdtemp( + join(tmpdir(), "pyble-beta-selection-"), + ); const selectionFile = join(selectionRoot, "selection.json"); await writeFile( selectionFile, diff --git a/tools/web/src/test/fixtures/firmware-release.ts b/tools/web/src/test/fixtures/firmware-release.ts index 8e9db6e..25229e7 100644 --- a/tools/web/src/test/fixtures/firmware-release.ts +++ b/tools/web/src/test/fixtures/firmware-release.ts @@ -714,8 +714,7 @@ export const publicBetaFirmwareRelease = { accessControlled: false, releaseJson: { path: "/firmware/v0.4.1/release.json", - sha256: - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", + sha256: "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", }, } satisfies FirmwareReleaseDescriptor; export const pendingCandidateFirmwareRelease = createFirmwareReleaseFixture({ diff --git a/tools/web/src/test/flash-installer.test.tsx b/tools/web/src/test/flash-installer.test.tsx index 870245d..12b3dae 100644 --- a/tools/web/src/test/flash-installer.test.tsx +++ b/tools/web/src/test/flash-installer.test.tsx @@ -335,7 +335,9 @@ describe("browser firmware installer states", () => { await waitFor(() => { expect(document.querySelector("esp-web-install-button")).not.toBeNull(); }); - expect(screen.getByText(/unqualified beta.*use at your own risk/i)).toBeInTheDocument(); + expect( + screen.getByText(/unqualified beta.*use at your own risk/i), + ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /install unqualified beta pyble 0\.4\.1/i, From 4f0d0cd0cb60231832431528c6d9b365b44c51b4 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:16:20 +0700 Subject: [PATCH 04/28] [red] Guard public beta staging and activation Signed-off-by: Viwat Vchirawongkwin --- tools/web/src/test/build-contract.test.ts | 11 +++ tools/web/src/test/firmware-staging.test.ts | 67 ++++++++++++++++++- .../src/test/vps-deployment-contract.test.ts | 17 +++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/tools/web/src/test/build-contract.test.ts b/tools/web/src/test/build-contract.test.ts index fbdf049..1a4c95b 100644 --- a/tools/web/src/test/build-contract.test.ts +++ b/tools/web/src/test/build-contract.test.ts @@ -251,4 +251,15 @@ 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(/unqualified beta/i); + expect(page).toMatch(/full hardware.*validation.*pending/i); + }); }); diff --git a/tools/web/src/test/firmware-staging.test.ts b/tools/web/src/test/firmware-staging.test.ts index e560af6..3a693a9 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, @@ -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.1", + }); + await expect( + validatePreservedPublicFirmwareRelease(stagedRoot, { + releaseValidator: acceptSyntheticFixture, + }), + ).resolves.toMatchObject({ deployment: "public-beta" }); + + 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(/attested.*v0\.4\.1/i); + }); + it("rejects external bytes that no longer match the generated bundle checksums", async () => { const fixture = createFirmwareReleaseFixture(); const bundleDirectory = await temporaryDirectory("corrupt-bundle"); diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index 9244ed5..b30e007 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -153,6 +153,23 @@ 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 = await readFile( + join(deploymentRoot, "vps", "deploy.sh"), + "utf8", + ); + + expect(script).toContain('descriptor.deployment === "public-beta"'); + expect(script).toContain('descriptor.hilStatus === "pending"'); + expect(script).toContain("descriptor.accessControlled === false"); + expect(script).toContain( + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", + ); + expect(script).toMatch( + /public-beta[\s\S]*?(?:skip|does not require)[\s\S]*?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"), From 362640065ce2e6fe05bd765105c31da41bbaaccd Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:21:51 +0700 Subject: [PATCH 05/28] [green] Stage and preserve exact firmware beta Signed-off-by: Viwat Vchirawongkwin --- tools/web/deploy/vps/deploy.sh | 99 ++++++++++--- tools/web/scripts/prepare-sites-output.js | 6 +- tools/web/scripts/stage-firmware-release.js | 133 ++++++++++++++---- tools/web/src/app/flash/page.tsx | 10 +- tools/web/src/test/firmware-staging.test.ts | 2 +- .../vps-self-hosted-release-contract.test.ts | 16 ++- 6 files changed, 207 insertions(+), 59 deletions(-) diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index dde630e..46fe346 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() { @@ -153,15 +156,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 +163,45 @@ 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}" + ) + if [[ "${firmware_deployment}" != public-beta ]]; 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 + fi 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.1" && + descriptor.hilStatus === "pending" && + descriptor.accessControlled === false && + descriptor.releaseJson?.sha256 === + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + if (!qualifiedPublic && !exactPublicBeta) { + throw new Error("The public VPS accepts only a qualified public release or the exact attested public beta"); } ' "${staged_selection}" @@ -200,7 +225,11 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then process.stdout.write(commit); ' "${staged_release_bundle}/release.json" ) - local_firmware_tag_object_before_build=$(verify_local_firmware_tag) + if [[ "${firmware_deployment}" == public-beta ]]; then + printf 'The exact public-beta exception does not require an annotated tag; release identity is digest-bound.\n' + else + local_firmware_tag_object_before_build=$(verify_local_firmware_tag) + fi firmware_evidence_root=$(mktemp -d) chmod 0700 "${firmware_evidence_root}" @@ -307,18 +336,34 @@ 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.1" && + descriptor.hilStatus === "pending" && + descriptor.accessControlled === false && + descriptor.releaseJson?.sha256 === + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; 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}" + ) ( cd -- "${preserved_staged_root}/firmware" ssh -o BatchMode=yes "${deploy_target}" \ @@ -374,7 +419,11 @@ REMOTE process.stdout.write(commit); ' "${staged_firmware_root}/firmware/v${firmware_version}/release.json" ) - local_firmware_tag_object_before_build=$(verify_local_firmware_tag) + if [[ "${firmware_deployment}" == public-beta ]]; then + printf 'Preserved public-beta state skips annotated tag validation because its exact release root is digest-bound.\n' + else + local_firmware_tag_object_before_build=$(verify_local_firmware_tag) + fi trusted_firmware_snapshot="${firmware_evidence_root}/trusted-preserved" mkdir -m 0700 -- "${trusted_firmware_snapshot}" @@ -459,9 +508,11 @@ if [[ -n "${staged_firmware_root}" ]]; then install -m 0644 \ "${staged_selection}" \ out/.pyble-firmware-release-selection.json - readonly local_firmware_tag_object_after_build=$( - verify_local_firmware_tag "${local_firmware_tag_object_before_build}" - ) + if [[ "${firmware_deployment}" != public-beta ]]; then + local_firmware_tag_object_after_build=$( + verify_local_firmware_tag "${local_firmware_tag_object_before_build}" + ) + fi fi for firmware_release in out/firmware/v*; do if [[ ! -d "${firmware_release}" ]]; then @@ -497,9 +548,11 @@ 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=$( - verify_local_firmware_tag "${local_firmware_tag_object_after_build}" - ) + if [[ "${firmware_deployment}" != public-beta ]]; then + local_firmware_tag_object_before_upload=$( + verify_local_firmware_tag "${local_firmware_tag_object_after_build}" + ) + fi fi upload_evidence_root=$(mktemp -d) chmod 0700 "${upload_evidence_root}" 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..b2c03ac 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.1"; +const publicBetaReleaseJsonSha256 = + "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; function packageDirectory() { try { @@ -243,6 +246,58 @@ export async function validateWithCanonicalReleaseTool( } } +/** + * Validate the identity root for the one retained 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 attested v0.4.1 public beta"); + } +} + +/** + * @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 +601,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 +809,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.1 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 +874,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 +886,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 +985,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 +1027,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") { @@ -997,11 +1071,11 @@ 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. + * bytes or the same exact digest-bound transitional public beta. * * @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 +1085,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 +1114,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 +1240,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 +1253,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 +1268,7 @@ async function run() { const descriptor = await validatePreservedPublicFirmwareRelease( stagedRoot, { - releaseValidator: - validatePreviouslyActivatedPublicWithCanonicalReleaseTool, + releaseValidator: validatePreservedDeploymentBundle, }, ); process.stdout.write( @@ -1204,8 +1283,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 +1306,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 ac17a56..56f3999 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -10,21 +10,23 @@ import { pageMetadata } from "@/lib/site"; export const metadata = pageMetadata({ title: "Firmware installer", description: - "Release status and requirements for installing PyBLE firmware on qualified 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"; 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 release profiles. + develop over Bluetooth Low Energy from the tablet-first PyBLE app. + {publicBeta + ? " The current v0.4.1 installer is an unqualified beta. Full hardware-in-the-loop validation is pending; use it at your own risk." + : " The public install action remains unavailable until the final bytes pass hardware validation on both exact current release profiles."}

diff --git a/tools/web/src/test/firmware-staging.test.ts b/tools/web/src/test/firmware-staging.test.ts index 3a693a9..ecabc81 100644 --- a/tools/web/src/test/firmware-staging.test.ts +++ b/tools/web/src/test/firmware-staging.test.ts @@ -211,7 +211,7 @@ describe("external firmware bundle staging", () => { validatePreservedPublicFirmwareRelease(stagedRoot, { releaseValidator: acceptSyntheticFixture, }), - ).resolves.toMatchObject({ deployment: "public-beta" }); + ).rejects.toThrow(/exact public beta/i); await expect( stageFixture({ 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/); From 346a76a3d50a2e96e12a038992a4bcd2112e123c Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:24:01 +0700 Subject: [PATCH 06/28] [docs] Align current public beta claims Signed-off-by: Viwat Vchirawongkwin --- docs/specifications/website.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/specifications/website.md b/docs/specifications/website.md index 0d2c8bc..1ada891 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -154,6 +154,17 @@ the truthful public-beta/pending-HIL state while that selector is active. 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 that current state. Each current-profile status MUST name +`v0.4.1`, **unqualified beta**, and **HIL pending**; installation instructions +MUST direct users to the enabled `/flash` action while preserving the exact +profile, backup, erase, cable/power, and port acknowledgements. The home and +support surfaces MUST include **use at your own risk**. The roadmap MAY shorten +the warning, but MUST put complete HIL qualification for the same two exact +profiles in near-term work rather than claim they are qualified now. 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 installer is unavailable, they MUST NOT instruct a reader to select or flash a From a9b54e98a6a2226a83e877d2352bec4532778e14 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:26:23 +0700 Subject: [PATCH 07/28] [red] Guard coherent public beta claims Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_public_claims.py | 69 ++++++++++++++++++++--- tools/web/src/test/site-contract.test.tsx | 25 +++++--- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index e2b0529..e3c581d 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -23,20 +23,41 @@ 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") - def test_readme_is_truthful_before_v042_hil_completes(self) -> None: + def test_readme_identifies_the_exact_unqualified_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") self.assertIn( - "public browser installer is currently unavailable pending v0.4.2 HIL", + "public browser installer currently offers the exact v0.4.1 unqualified beta", firmware, ) + self.assertIn("full project HIL is pending", 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("Unqualified v0.4.1 beta; HIL pending", firmware) + self.assertIn("Planned; unavailable", firmware) self.assertNotIn("currently offers qualified images", firmware) - self.assertNotIn("| Available", firmware) + self.assertNotIn("v0.4.2 HIL pending; installer unavailable", firmware) def test_readme_caption_describes_only_the_visible_app(self) -> None: caption_start = self.readme.index("Actual PyBLE app") @@ -48,13 +69,47 @@ 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_active_beta_safely(self) -> None: try_section = markdown_section(self.readme, "Try PyBLE") - self.assertIn("currently unavailable pending v0.4.2 HIL", try_section) - self.assertIn("active release version", try_section) + self.assertIn("v0.4.1 unqualified beta", try_section) + self.assertIn("full HIL is pending", try_section) + self.assertIn("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_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 ( + "v0.4.1", + "unqualified beta", + "HIL pending", + "esp32-4mb", + "esp32-s3-n16r8", + ): + self.assertIn(wording, combined) + self.assertIn("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) + + near_term = markdown_section(self.roadmap, "Near term") + self.assertIn( + "Complete full HIL qualification for the exact `esp32-4mb` and", + near_term, + ) def test_bug_template_collects_the_exact_installer_diagnostics(self) -> None: for field_id in ( diff --git a/tools/web/src/test/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index d35da57..f358343 100644 --- a/tools/web/src/test/site-contract.test.tsx +++ b/tools/web/src/test/site-contract.test.tsx @@ -159,7 +159,7 @@ describe("public-site contract", () => { ); }); - it("states the vendor-neutral vision and the truthful pre-activation firmware state", () => { + it("states the vendor-neutral vision and the truthful public-beta firmware state", () => { render(); expect( @@ -173,11 +173,11 @@ 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\.1 firmware/i)).toHaveTextContent( + /unqualified beta 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.getByText(/public v0\.4\.1 firmware/i)).toHaveTextContent( + /full hardware-in-the-loop qualification is pending.*use it at your own risk/i, ); expect( screen.getByText( @@ -332,14 +332,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: "v0.4.1 unqualified beta · HIL pending", 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: "v0.4.1 unqualified beta · HIL pending", planned: false, }, { @@ -359,6 +359,12 @@ describe("public-site contract", () => { expect(targetCard).toHaveTextContent(target.constraint); expect(targetCard).toHaveTextContent(target.status); } + expect(screen.getByText(/provision once/i).closest("li")).toHaveTextContent( + /v0\.4\.1 unqualified beta.*full HIL is pending.*use it at your own risk/i, + ); + expect( + screen.getByRole("link", { name: /open firmware installer/i }), + ).toHaveAttribute("href", "/flash"); }); it("keeps the public installer unavailable while explaining exact profiles, BLE use, and recovery", () => { @@ -543,9 +549,12 @@ describe("public-site contract", () => { ).toBeInTheDocument(); expect( screen.getByText( - /public installer is unavailable while v0\.4\.2 HIL runs for esp32-4mb and esp32-s3-n16r8/i, + /v0\.4\.1 unqualified beta is available for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, ), ).toBeInTheDocument(); + expect( + screen.getByText(/full HIL remains pending.*use it at your own risk/i), + ).toBeInTheDocument(); expect( screen.getByText(/ESP32-C3 is not currently available/i), ).toBeInTheDocument(); From ab6a29386d2c9c55651818121166fd892e9996ba Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:52:25 +0700 Subject: [PATCH 08/28] [red] Require exact firmware source for audited beta Signed-off-by: Viwat Vchirawongkwin --- tools/web/src/test/firmware-staging.test.ts | 93 +++++++++++++++++-- .../src/test/vps-deployment-contract.test.ts | 28 ++++-- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/tools/web/src/test/firmware-staging.test.ts b/tools/web/src/test/firmware-staging.test.ts index ecabc81..948e9cf 100644 --- a/tools/web/src/test/firmware-staging.test.ts +++ b/tools/web/src/test/firmware-staging.test.ts @@ -117,7 +117,7 @@ describe("external firmware bundle staging", () => { join( outputDirectory, "firmware", - "v0.4.1", + "v0.4.2", "esp32-s3-n16r8", "manifest.json", ), @@ -128,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", @@ -164,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({ @@ -205,7 +205,7 @@ describe("external firmware bundle staging", () => { accessControlled: false, deployment: "public-beta", hilStatus: "pending", - version: "0.4.1", + version: "0.4.2", }); await expect( validatePreservedPublicFirmwareRelease(stagedRoot, { @@ -242,7 +242,7 @@ describe("external firmware bundle staging", () => { await expect( validateAttestedPublicBetaBundle(bundleDirectory, "public-beta"), - ).rejects.toThrow(/attested.*v0\.4\.1/i); + ).rejects.toThrow(/audited.*v0\.4\.2/i); }); it("rejects external bytes that no longer match the generated bundle checksums", async () => { @@ -633,6 +633,85 @@ describe("external firmware bundle staging", () => { .toBe(repositoryRoot); }); + 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 () => { const fixture = createFirmwareReleaseFixture(); const realBundleDirectory = await temporaryDirectory("real-release-bundle"); @@ -693,7 +772,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/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index b30e007..3a33440 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -89,12 +89,12 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(headers).toContain("Strict-Transport-Security"); }); - it("routes the exact v0.4.1 public beta through the immutable firmware boundary", async () => { + 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.1/"); + 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\//, @@ -154,19 +154,29 @@ describe("Cloudflare-fronted VPS deployment", () => { }); it("accepts only the exact unrestricted pending public beta in the activation path", async () => { - const script = await readFile( - join(deploymentRoot, "vps", "deploy.sh"), - "utf8", - ); + 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( - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", ); - expect(script).toMatch( - /public-beta[\s\S]*?(?:skip|does not require)[\s\S]*?annotated tag/i, + 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).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, ); }); From e9d222a64c0e4ca2bfecb597a2cec6c14aaca011 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:52:56 +0700 Subject: [PATCH 09/28] [red] Preserve qualified installer without beta evidence Signed-off-by: Viwat Vchirawongkwin --- tools/web/src/test/vps-deployment-contract.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index 3a33440..c7ed125 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -174,6 +174,9 @@ describe("Cloudflare-fronted VPS deployment", () => { 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, From 7c3777cf7ca73374c374e99b41c93b4537a224ba Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:56:02 +0700 Subject: [PATCH 10/28] [red] Keep firmware availability copy selector-driven Signed-off-by: Viwat Vchirawongkwin --- tools/web/src/test/site-contract.test.tsx | 77 +++++++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/tools/web/src/test/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index f358343..1db61e3 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,13 @@ 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 { publicBetaFirmwareRelease } from "@/test/fixtures/firmware-release"; describe("public-site contract", () => { it("keeps pyble.dev canonical and presents the four launch routes", () => { @@ -159,7 +166,7 @@ describe("public-site contract", () => { ); }); - it("states the vendor-neutral vision and the truthful public-beta firmware state", () => { + it("states the vendor-neutral vision without claiming unavailable firmware is active", () => { render(); expect( @@ -173,12 +180,12 @@ describe("public-site contract", () => { /designed for boards that run MicroPython and support Bluetooth Low Energy/i, ), ).toBeInTheDocument(); - expect(screen.getByText(/public v0\.4\.1 firmware/i)).toHaveTextContent( - /unqualified beta for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, - ); - expect(screen.getByText(/public v0\.4\.1 firmware/i)).toHaveTextContent( - /full hardware-in-the-loop qualification is pending.*use it at your own risk/i, - ); + expect( + screen.getByText(/firmware installer is currently unavailable/i), + ).toBeInTheDocument(); + 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 +339,14 @@ describe("public-site contract", () => { id: "esp32-4mb", target: "Classic ESP32", constraint: "4 MiB external SPI flash · no PSRAM assumed", - status: "v0.4.1 unqualified beta · HIL pending", + status: "Installer unavailable", planned: false, }, { id: "esp32-s3-n16r8", target: "ESP32-S3 N16R8", constraint: "16 MiB flash · 8 MiB Octal PSRAM", - status: "v0.4.1 unqualified beta · HIL pending", + status: "Installer unavailable", planned: false, }, { @@ -360,13 +367,57 @@ describe("public-site contract", () => { expect(targetCard).toHaveTextContent(target.status); } expect(screen.getByText(/provision once/i).closest("li")).toHaveTextContent( - /v0\.4\.1 unqualified beta.*full HIL is pending.*use it at your own risk/i, + /check firmware status.*installer is currently unavailable/i, ); expect( screen.getByRole("link", { name: /open firmware installer/i }), ).toHaveAttribute("href", "/flash"); }); + it("shows the exact 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( + /unqualified beta for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, + ); + expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( + /full hardware-in-the-loop qualification is pending.*use it at your own risk/i, + ); + for (const target of firmwareTargetsForRelease( + publicBetaFirmwareRelease, + ).filter(({ planned }) => !planned)) { + expect(screen.getByText(target.id).closest("div")).toHaveTextContent( + "v0.4.2 unqualified beta · HIL pending", + ); + } + home.unmount(); + + render(); + expect( + screen.getByText(/v0\.4\.2 unqualified beta is available/i), + ).toHaveTextContent(/full HIL remains pending.*use it at your own risk/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("keeps the public installer unavailable while explaining exact profiles, BLE use, and recovery", () => { render(); @@ -549,7 +600,7 @@ describe("public-site contract", () => { ).toBeInTheDocument(); expect( screen.getByText( - /v0\.4\.1 unqualified beta is available for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, + /v0\.4\.2 unqualified beta is available for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, ), ).toBeInTheDocument(); expect( From ccc5941c63708619d8d14149ae5b93cea1f1eba8 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 20:59:50 +0700 Subject: [PATCH 11/28] [red] Require stable repo-local IDF path mapping Signed-off-by: Viwat Vchirawongkwin --- tests/firmware_tests/host/test_release_hardening.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/firmware_tests/host/test_release_hardening.py b/tests/firmware_tests/host/test_release_hardening.py index 8027ed1..3046f41 100644 --- a/tests/firmware_tests/host/test_release_hardening.py +++ b/tests/firmware_tests/host/test_release_hardening.py @@ -968,6 +968,7 @@ 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() From c13548de3cb181b46dcdf8879a64f8cac4343544 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:01:04 +0700 Subject: [PATCH 12/28] [green] Stabilize repo-local IDF build paths Signed-off-by: Viwat Vchirawongkwin --- firmware/scripts/build.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/firmware/scripts/build.sh b/firmware/scripts/build.sh index 50dbba2..34d9dbe 100755 --- a/firmware/scripts/build.sh +++ b/firmware/scripts/build.sh @@ -161,6 +161,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 +263,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 \ From e4b6106984d204820486662ad6cb7add04600ea4 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:24:45 +0700 Subject: [PATCH 13/28] [red] Reject Python cache contaminated source evidence Signed-off-by: Viwat Vchirawongkwin --- docs/specifications/firmware/TDD.md | 5 +++- .../host/test_release_hardening.py | 24 ++++++++++++++--- ...est_release_license_policy_v2_hardening.py | 27 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/specifications/firmware/TDD.md b/docs/specifications/firmware/TDD.md index c002112..979e052 100644 --- a/docs/specifications/firmware/TDD.md +++ b/docs/specifications/firmware/TDD.md @@ -1155,7 +1155,10 @@ 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 diff --git a/tests/firmware_tests/host/test_release_hardening.py b/tests/firmware_tests/host/test_release_hardening.py index 3046f41..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="" @@ -973,7 +974,7 @@ def test_port_build_uses_only_stable_source_prefix_maps(self): ) environments = fixture.make_environments() self.assertEqual( - environments[0], + environments[0][:-1], ("", "", "", "", "", "", "", "", "", "", ""), "ambient compiler flags must not influence the rebuilt compiler", ) @@ -1000,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 " @@ -1009,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) From c41bc48475f8437f682be30564352147d77a47a0 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:26:34 +0700 Subject: [PATCH 14/28] [red] Reject cacheable firmware error responses Signed-off-by: Viwat Vchirawongkwin --- .../src/test/vps-deployment-contract.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index c7ed125..b136534 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -89,6 +89,20 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(headers).toContain("Strict-Transport-Security"); }); + 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", + ); + + 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("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"), @@ -416,6 +430,27 @@ 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("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"), From ce02b68ab73da903035aa9f992c1f7e8eb2a3691 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:28:16 +0700 Subject: [PATCH 15/28] [green] Stabilize supplemental source evidence Signed-off-by: Viwat Vchirawongkwin --- firmware/licenses/license-policy.json | 2 +- firmware/release-tools.lock | 2 +- firmware/scripts/build.sh | 6 +++++ firmware/scripts/release_bundle.py | 39 ++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) 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 34d9dbe..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)" 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) From 9924f71bb4dd790534d4f3336a0a93abbda466c4 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:43:31 +0700 Subject: [PATCH 16/28] [red] Keep qualified flash copy actionable Signed-off-by: Viwat Vchirawongkwin --- .../src/test/flash-page-release-copy.test.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tools/web/src/test/flash-page-release-copy.test.tsx 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 }); + } + }); +}); From 454a270767baafb5fd2b61e7c7eff9b109475cd7 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 21:57:49 +0700 Subject: [PATCH 17/28] [green] Activate exact audited v0.4.2 beta Signed-off-by: Viwat Vchirawongkwin --- README.md | 24 ++-- docs/ROADMAP.md | 7 +- .../firmware/browser-flashing.md | 60 ++++++---- docs/specifications/website.md | 76 ++++++++----- tests/publication/test_public_claims.py | 8 +- tools/web/DEPLOYMENT.md | 52 +++++++-- tools/web/README.md | 27 +++-- .../web/deploy/nginx/10-pyble-dev-https.conf | 10 +- tools/web/deploy/vps/deploy.sh | 104 ++++++++++++------ tools/web/scripts/stage-firmware-release.js | 30 +++-- tools/web/src/app/flash/page.tsx | 8 +- tools/web/src/app/page.tsx | 93 +++++++++++++--- tools/web/src/app/support/page.tsx | 33 +++++- tools/web/src/components/flash-status.tsx | 4 +- tools/web/src/lib/firmware-integrity.ts | 2 +- .../web/src/lib/firmware-release-selection.ts | 2 +- tools/web/src/lib/firmware-release.ts | 4 +- tools/web/src/lib/site.ts | 30 ++++- .../web/src/test/firmware-fetch-cache.test.ts | 4 +- tools/web/src/test/firmware-integrity.test.ts | 22 ++-- .../test/firmware-public-beta-policy.test.ts | 4 +- tools/web/src/test/firmware-staging.test.ts | 17 ++- .../web/src/test/fixtures/firmware-release.ts | 14 +-- tools/web/src/test/flash-installer.test.tsx | 28 ++--- tools/web/src/test/site-contract.test.tsx | 84 ++++++++++++-- tools/web/src/test/sites-output.test.ts | 12 +- .../src/test/vps-deployment-contract.test.ts | 2 +- 27 files changed, 541 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index d508b3c..944cd69 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,15 @@ 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: +The public browser installer currently offers the exact v0.4.2 unqualified beta +for both current profiles. The exact bytes pass the audited-candidate release +gate, but full project HIL is pending: | 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 | +| `esp32-4mb` | Classic ESP32, 4 MiB external SPI flash; no PSRAM assumed | Unqualified v0.4.2 beta; HIL pending | +| `esp32-s3-n16r8` | ESP32-S3, 16 MiB flash / 8 MiB Octal PSRAM; N16R8-class only | Unqualified v0.4.2 beta; HIL pending | +| `esp32-c3-4mb` | ESP32-C3, 4 MiB external SPI flash; no PSRAM assumed | Planned; unavailable; no public image | 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 +107,13 @@ 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 unqualified beta is active while full HIL is pending; please + use it at your own risk. 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..b6b0c6c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,13 +8,14 @@ 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 unqualified beta; HIL pending - MIT-licensed app, agent firmware, protocol, website, tests, and release tools ## Near term -- Re-establish firmware release provenance from the canonical public history +- Complete full HIL qualification for the exact `esp32-4mb` and + `esp32-s3-n16r8` 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/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index d5cdcaa..510a555 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -39,8 +39,9 @@ BLE and PBLE/1. ## 1. Release image profiles The current pre-v1 browser bundle contains exactly these two -**provisioning image profiles**. The exact `v0.4.1` bytes may be offered only as -the unqualified public beta defined in §10; they are not qualified profiles. +**provisioning image profiles**. The exact audited `v0.4.2` bytes may be offered +only as the unqualified public beta defined in §10; they are not qualified +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 | @@ -397,7 +398,7 @@ beside their metadata: 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 - the exact `v0.4.1` public beta, or `passed` for a qualified 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`, @@ -411,7 +412,8 @@ 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 or the exact digest-bound `v0.4.1` `public-beta` exception in §10; an HIL +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 @@ -1469,38 +1471,50 @@ 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. -As a one-time transitional exception, the retained `v0.4.1` bundle MAY be -published as an explicitly **unqualified public beta**. The selector deployment +As a one-time transitional exception, the fresh audited `v0.4.2` candidate MAY +be published as an explicitly **unqualified 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 -`8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445`. +`5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde`. The exact profile set is `esp32-4mb` plus `esp32-s3-n16r8`; C3 MUST remain -absent. Before the install control appears, the website MUST say that these -bytes were manually exercised on the maintainer's two boards but have not -passed the complete project HIL matrix, and that installation is at the user's -risk. It MUST NOT call the beta access-controlled, protected, qualified, -validated, or generally available. - -The beta path MUST retain all existing 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. +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. Before the install control +appears, the website MUST say that the exact bytes passed the audited-candidate +release gate but have not passed the complete project HIL matrix, and that +installation is at the user's risk. It MUST NOT call the beta access-controlled, +protected, qualified, fully validated, 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/website.md b/docs/specifications/website.md index 1ada891..e2fe68e 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -141,12 +141,13 @@ instructions. 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 qualified. The one-time `v0.4.1` public-beta selector MAY -make the exact `esp32-4mb` and `esp32-s3-n16r8` images available under the -exception in §7, but every active installer state MUST visibly say **unqualified -beta**, **HIL pending**, and **use at your own risk** before profile selection and -again beside the install action. It MUST NOT say or imply that the beta is -access-controlled. The home-page target cards MUST identify the constraints as +or its browser image as qualified. 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, but every active installer state MUST visibly say +**unqualified beta**, **HIL pending**, and **use at your own risk** before +profile selection and again beside the install action. It MUST NOT say or imply +that the beta is access-controlled. 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 public-beta/pending-HIL state while that selector is active. @@ -156,14 +157,18 @@ 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 that current state. Each current-profile status MUST name -`v0.4.1`, **unqualified beta**, and **HIL pending**; installation instructions -MUST direct users to the enabled `/flash` action while preserving the exact -profile, backup, erase, cable/power, and port acknowledgements. The home and -support surfaces MUST include **use at your own risk**. The roadmap MAY shorten -the warning, but MUST put complete HIL qualification for the same two exact -profiles in near-term work rather than claim they are qualified now. Every one -of those surfaces MUST keep C3 explicitly unavailable. +MUST agree with the build-selected installer state. While the exact beta +selector is active, each current-profile status MUST name `v0.4.2`, +**unqualified beta**, and **HIL pending**; installation instructions MUST direct +users to the enabled `/flash` action while preserving the exact profile, backup, +erase, cable/power, and port acknowledgements. The home and support surfaces +MUST include **use at your own risk**. 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 MAY shorten the warning, but MUST put complete +HIL qualification for the same two exact profiles in near-term work rather than +claim they are qualified now. 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 @@ -173,7 +178,7 @@ what is visible; it MUST NOT claim that a physical board is pictured when the capture shows only the app. 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.1` web flasher, it +by everyday use over BLE. If it advertises the enabled `v0.4.2` web flasher, it MUST visibly call it an unqualified beta with HIL pending. Its mechanically rendered PNG and authored SVG MUST remain paired by reviewed content and exact-dimension tests. @@ -320,7 +325,7 @@ 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; -- serve `/firmware/v0.4.1/` only while the exact attested public-beta 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`; @@ -328,15 +333,22 @@ VPS origin. The origin MUST: 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. @@ -379,28 +391,34 @@ 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. -One narrow pre-qualification exception exists for the already manually tested -`v0.4.1` bytes. A build-time selector MAY use deployment mode `public-beta` only +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.1`, `releaseJson.sha256` is exactly - `8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445`, +- `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; - and +- 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 an **unqualified beta**, says full HIL is pending and installation is at the user's risk, and never calls it protected, access-controlled, qualified, validated, or generally available. -This exception attests only the identity and integrity of the retained bytes; -it does not manufacture HIL evidence. It MUST NOT accept another version or +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 through the same integrity gate. +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 diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index e3c581d..204866e 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -46,7 +46,7 @@ def test_readme_identifies_the_exact_unqualified_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") self.assertIn( - "public browser installer currently offers the exact v0.4.1 unqualified beta", + "public browser installer currently offers the exact v0.4.2 unqualified beta", firmware, ) self.assertIn("full project HIL is pending", firmware) @@ -54,7 +54,7 @@ def test_readme_identifies_the_exact_unqualified_public_beta(self) -> None: 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("Unqualified v0.4.1 beta; HIL pending", firmware) + self.assertIn("Unqualified v0.4.2 beta; HIL pending", firmware) self.assertIn("Planned; unavailable", firmware) self.assertNotIn("currently offers qualified images", firmware) self.assertNotIn("v0.4.2 HIL pending; installer unavailable", firmware) @@ -72,7 +72,7 @@ def test_readme_caption_describes_only_the_visible_app(self) -> None: def test_readme_try_steps_use_the_active_beta_safely(self) -> None: try_section = markdown_section(self.readme, "Try PyBLE") - self.assertIn("v0.4.1 unqualified beta", try_section) + self.assertIn("v0.4.2 unqualified beta", try_section) self.assertIn("full HIL is pending", try_section) self.assertIn("use it at your own risk", try_section) self.assertIn("exact profile", try_section) @@ -87,7 +87,7 @@ def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: ) for wording in ( - "v0.4.1", + "v0.4.2", "unqualified beta", "HIL pending", "esp32-4mb", diff --git a/tools/web/DEPLOYMENT.md b/tools/web/DEPLOYMENT.md index e542bdb..c70befd 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,30 @@ 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 unqualified 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 +label these bytes unqualified, HIL-pending, and at the user's risk. + For a pending release candidate, stage with both explicit controls: ```sh @@ -115,6 +142,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 +156,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 +168,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 +259,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 18fdc3c..906c9eb 100644 --- a/tools/web/README.md +++ b/tools/web/README.md @@ -87,10 +87,12 @@ 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. It remains visibly unqualified while complete real-board HIL is +pending. 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. @@ -104,13 +106,17 @@ only with both `PYBLE_FLASH_DEPLOYMENT=candidate` and 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 @@ -119,7 +125,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 dd42e49..c4f2d01 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -9,6 +9,14 @@ 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 non-cacheable while retaining +# the normal no-cache/no-transform policy for the shared website 404 page. +map $request_uri $pyble_not_found_cache_control { + ~^/firmware/ "no-store"; + default "no-cache, no-transform"; +} + server { listen 443 ssl http2; listen [::]:443 ssl http2; @@ -126,7 +134,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 46fe346..9b0ffe3 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -107,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}" @@ -173,16 +188,7 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then process.stdout.write(descriptor.deployment); ' "${staged_selection}" ) - if [[ "${firmware_deployment}" != public-beta ]]; 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 - fi + require_firmware_release_inputs PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" \ node "${web_directory}/scripts/stage-firmware-release.js" \ --verify-staged @@ -195,11 +201,11 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then descriptor.accessControlled === false; const exactPublicBeta = descriptor.deployment === "public-beta" && - descriptor.version === "0.4.1" && + descriptor.version === "0.4.2" && descriptor.hilStatus === "pending" && descriptor.accessControlled === false && descriptor.releaseJson?.sha256 === - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; if (!qualifiedPublic && !exactPublicBeta) { throw new Error("The public VPS accepts only a qualified public release or the exact attested public beta"); } @@ -225,11 +231,7 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then process.stdout.write(commit); ' "${staged_release_bundle}/release.json" ) - if [[ "${firmware_deployment}" == public-beta ]]; then - printf 'The exact public-beta exception does not require an annotated tag; release identity is digest-bound.\n' - else - local_firmware_tag_object_before_build=$(verify_local_firmware_tag) - fi + local_firmware_tag_object_before_build=$(verify_local_firmware_tag) firmware_evidence_root=$(mktemp -d) chmod 0700 "${firmware_evidence_root}" @@ -342,11 +344,11 @@ REMOTE descriptor.accessControlled === false; const exactPublicBeta = descriptor.deployment === "public-beta" && - descriptor.version === "0.4.1" && + descriptor.version === "0.4.2" && descriptor.hilStatus === "pending" && descriptor.accessControlled === false && descriptor.releaseJson?.sha256 === - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; if ( (!qualifiedPublic && !exactPublicBeta) || typeof descriptor.version !== "string" || @@ -364,6 +366,9 @@ REMOTE 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}" \ @@ -419,11 +424,7 @@ REMOTE process.stdout.write(commit); ' "${staged_firmware_root}/firmware/v${firmware_version}/release.json" ) - if [[ "${firmware_deployment}" == public-beta ]]; then - printf 'Preserved public-beta state skips annotated tag validation because its exact release root is digest-bound.\n' - else - local_firmware_tag_object_before_build=$(verify_local_firmware_tag) - fi + local_firmware_tag_object_before_build=$(verify_local_firmware_tag) trusted_firmware_snapshot="${firmware_evidence_root}/trusted-preserved" mkdir -m 0700 -- "${trusted_firmware_snapshot}" @@ -508,11 +509,9 @@ if [[ -n "${staged_firmware_root}" ]]; then install -m 0644 \ "${staged_selection}" \ out/.pyble-firmware-release-selection.json - if [[ "${firmware_deployment}" != public-beta ]]; then - local_firmware_tag_object_after_build=$( - verify_local_firmware_tag "${local_firmware_tag_object_before_build}" - ) - fi + local_firmware_tag_object_after_build=$( + verify_local_firmware_tag "${local_firmware_tag_object_before_build}" + ) fi for firmware_release in out/firmware/v*; do if [[ ! -d "${firmware_release}" ]]; then @@ -548,11 +547,9 @@ if [[ -n "${staged_firmware_root}" ]]; then "${staged_firmware_root}/firmware" \ "${web_directory}/out/firmware" \ "final packaged website firmware" - if [[ "${firmware_deployment}" != public-beta ]]; then - local_firmware_tag_object_before_upload=$( - verify_local_firmware_tag "${local_firmware_tag_object_after_build}" - ) - fi + local_firmware_tag_object_before_upload=$( + verify_local_firmware_tag "${local_firmware_tag_object_after_build}" + ) fi upload_evidence_root=$(mktemp -d) chmod 0700 "${upload_evidence_root}" @@ -1160,6 +1157,45 @@ if [[ "${not_found_status}" != 404 ]]; then exit 66 fi +firmware_not_found_paths=( + /firmware/not-found-smoke +) +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_index=0 +for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do + firmware_not_found_headers="${smoke_root}/firmware-not-found-${firmware_not_found_index}.headers" + firmware_not_found_status=$( + curl --silent --show-error --max-time 30 \ + --location --max-redirs 0 --proto '=https' \ + --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: expected 404, received %s.\n' \ + "${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: Cache-Control is not no-store.\n' \ + "${firmware_not_found_path}" >&2 + exit 66 + fi + 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/scripts/stage-firmware-release.js b/tools/web/scripts/stage-firmware-release.js index b2c03ac..08245bd 100644 --- a/tools/web/scripts/stage-firmware-release.js +++ b/tools/web/scripts/stage-firmware-release.js @@ -29,9 +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.1"; +const publicBetaVersion = "0.4.2"; const publicBetaReleaseJsonSha256 = - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; function packageDirectory() { try { @@ -184,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, @@ -205,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`, @@ -221,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", @@ -228,7 +234,7 @@ export async function validateWithCanonicalReleaseTool( "--license-build-root", resolve(licenseBuildRoot), "--repo-root", - repositoryRoot, + resolve(firmwareSourceRoot), ); try { await execFile("python3", arguments_, { @@ -247,7 +253,8 @@ export async function validateWithCanonicalReleaseTool( } /** - * Validate the identity root for the one retained public-beta bundle. The + * 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. @@ -266,8 +273,9 @@ export async function validateAttestedPublicBetaBundle( join(resolve(bundleDirectory), "release.json"), ); if (sha256(releaseBytes) !== publicBetaReleaseJsonSha256) { - failure("bundle is not the exact attested v0.4.1 public beta"); + failure("bundle is not the exact audited v0.4.2 public beta"); } + await validateWithCanonicalReleaseTool(bundleDirectory, deployment); } /** @@ -811,7 +819,7 @@ async function validateReleaseBundle( } if (deployment === "public-beta") { if (version !== publicBetaVersion) { - failure("public beta must be the exact v0.4.1 version"); + failure("public beta must be the exact v0.4.2 version"); } if (accessControlled) { failure("public beta must be unrestricted, not access-controlled"); @@ -1070,8 +1078,10 @@ 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 or the same exact digest-bound transitional public beta. + * 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 {{ diff --git a/tools/web/src/app/flash/page.tsx b/tools/web/src/app/flash/page.tsx index 56f3999..298d4ab 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -17,6 +17,8 @@ export const metadata = pageMetadata({ export default function FlashPage() { const release = firmwareReleaseSelectedAtBuild(); const publicBeta = release?.deployment === "public-beta"; + const qualifiedPublic = + release?.deployment === "public" && release.hilStatus === "passed"; return (
@@ -25,8 +27,10 @@ export default function FlashPage() { One-time wired provisioning installs PyBLE-enabled MicroPython. Then develop over Bluetooth Low Energy from the tablet-first PyBLE app. {publicBeta - ? " The current v0.4.1 installer is an unqualified beta. Full hardware-in-the-loop validation is pending; use it at your own risk." - : " The public install action remains unavailable until the final bytes pass hardware validation on both exact current release profiles."} + ? " The current v0.4.2 installer is an unqualified beta. It passed the audited-candidate release gate, but full hardware-in-the-loop validation is pending; use it at your own risk." + : 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."}

diff --git a/tools/web/src/app/page.tsx b/tools/web/src/app/page.tsx index 0fce271..be30d23 100644 --- a/tools/web/src/app/page.tsx +++ b/tools/web/src/app/page.tsx @@ -18,7 +18,8 @@ import { RadioIcon, ShieldIcon, } from "@/components/icons"; -import { initialFirmwareTargets, siteConfig } from "@/lib/site"; +import { firmwareReleaseSelectedAtBuild } from "@/lib/firmware-release-selection"; +import { firmwareTargetsForRelease, siteConfig } from "@/lib/site"; export const metadata: Metadata = { title: { @@ -53,12 +54,7 @@ const features = [ }, ] as const; -const steps = [ - { - number: "01", - title: "Provision once", - body: "After v0.4.2 passes HIL, use USB once to install the exact matching PyBLE-enabled MicroPython profile. The public installer is unavailable during qualification.", - }, +const workflowStepsAfterProvision = [ { number: "02", title: "Connect from your tablet", @@ -72,6 +68,27 @@ const steps = [ ] as const; export default function HomePage() { + const firmwareRelease = firmwareReleaseSelectedAtBuild(); + const publicBeta = firmwareRelease?.deployment === "public-beta"; + const qualifiedPublic = + firmwareRelease?.deployment === "public" && + firmwareRelease.hilStatus === "passed"; + const firmwareTargets = firmwareTargetsForRelease(firmwareRelease); + const steps = [ + { + number: "01", + title: "Provision once", + body: publicBeta + ? `Use USB once to install the exact matching v${firmwareRelease.version} unqualified beta. Full HIL is pending; use it at your own risk.` + : qualifiedPublic + ? `Use USB once to install the exact matching qualified v${firmwareRelease.version} firmware.` + : firmwareRelease + ? `Check the protected candidate instructions before provisioning v${firmwareRelease.version}.` + : "Check firmware status before provisioning; the installer is currently unavailable.", + }, + ...workflowStepsAfterProvision, + ]; + return (
@@ -87,11 +104,31 @@ 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 an unqualified + beta for the exact esp32-4mb and esp32-s3-n16r8 profiles. Full + hardware-in-the-loop qualification is pending; use it at your + own risk. + + ) : 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 +203,12 @@ export default function HomePage() { ))} +
+ + Open firmware installer + + +
@@ -246,7 +289,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} unqualified firmware beta + is available for esp32-4mb and esp32-s3-n16r8 while full HIL + remains pending; use it at your own risk. + + ) : 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,28 @@ 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} unqualified beta is + available for the exact esp32-4mb and esp32-s3-n16r8 + profiles. Full HIL remains pending; use it at your own + risk. + + ) : 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 d4eb6f5..34690b9 100644 --- a/tools/web/src/components/flash-status.tsx +++ b/tools/web/src/components/flash-status.tsx @@ -143,7 +143,7 @@ function policyFailure(release: FirmwareReleaseDescriptor | null | undefined) { ) { return { heading: "Installer unavailable", - body: "The public beta does not match the exact attested v0.4.1 firmware, 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)) { @@ -404,7 +404,7 @@ function FlashStatusForRelease({ {candidate ? "Protected release candidate: hardware validation is pending." : publicBeta - ? "Unqualified firmware beta: full hardware-in-the-loop qualification is pending. These exact v0.4.1 bytes were manually exercised on the maintainer's ESP32 and ESP32-S3 boards, but they are not qualified. Use at your own risk." + ? "Unqualified firmware beta: these exact v0.4.2 bytes passed the audited-candidate release gate, but full hardware-in-the-loop qualification is pending. They are not qualified. Use at your own risk." : "Select and verify the exact module profile before installation."}
diff --git a/tools/web/src/lib/firmware-integrity.ts b/tools/web/src/lib/firmware-integrity.ts index ab949c0..30ea103 100644 --- a/tools/web/src/lib/firmware-integrity.ts +++ b/tools/web/src/lib/firmware-integrity.ts @@ -300,7 +300,7 @@ function validateDescriptor( descriptor.deployment === "public-beta" && !isExactPublicBetaFirmwareRelease(descriptor) ) { - fail("Public beta does not match the exact attested v0.4.1 release"); + fail("Public beta does not match the exact audited v0.4.2 release"); } const releasePath = `/firmware/v${descriptor.version}/release.json`; diff --git a/tools/web/src/lib/firmware-release-selection.ts b/tools/web/src/lib/firmware-release-selection.ts index c04e961..cc18c3c 100644 --- a/tools/web/src/lib/firmware-release-selection.ts +++ b/tools/web/src/lib/firmware-release-selection.ts @@ -64,7 +64,7 @@ export function firmwareReleaseSelectedAtBuild(): FirmwareReleaseDescriptor | nu !isExactPublicBetaFirmwareRelease(descriptor) ) { throw new Error( - "Build-selected public beta does not match the exact attested v0.4.1 release", + "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 3ef3c74..8f6dc2a 100644 --- a/tools/web/src/lib/firmware-release.ts +++ b/tools/web/src/lib/firmware-release.ts @@ -55,9 +55,9 @@ export type PlannedFirmwareProfileId = export type FirmwareDeployment = "public" | "candidate" | "public-beta"; export type FirmwareHilStatus = "pending" | "passed"; -export const publicBetaFirmwareVersion = "0.4.1"; +export const publicBetaFirmwareVersion = "0.4.2"; export const publicBetaReleaseJsonSha256 = - "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445"; + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde"; export interface FirmwareProfileDescriptor { readonly id: FirmwareProfileId; diff --git a/tools/web/src/lib/site.ts b/tools/web/src/lib/site.ts index e08e4ba..5859429 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,30 +26,50 @@ 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} unqualified beta · HIL 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(); } 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 index b1ca3bc..c5e4d3e 100644 --- a/tools/web/src/test/firmware-public-beta-policy.test.ts +++ b/tools/web/src/test/firmware-public-beta-policy.test.ts @@ -7,14 +7,14 @@ import { isExactPublicBetaFirmwareRelease } from "@/lib/firmware-release"; import { publicBetaFirmwareRelease } from "@/test/fixtures/firmware-release"; describe("exact public firmware beta policy", () => { - it("accepts only the attested unrestricted pending v0.4.1 descriptor", () => { + 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.2"; + descriptor.version = "0.4.1"; }, (descriptor: Record) => { descriptor.hilStatus = "passed"; diff --git a/tools/web/src/test/firmware-staging.test.ts b/tools/web/src/test/firmware-staging.test.ts index 948e9cf..7b0e12f 100644 --- a/tools/web/src/test/firmware-staging.test.ts +++ b/tools/web/src/test/firmware-staging.test.ts @@ -523,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", ); @@ -533,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( @@ -556,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", {}], @@ -563,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) { @@ -610,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, }, }, @@ -630,7 +643,7 @@ 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 () => { diff --git a/tools/web/src/test/fixtures/firmware-release.ts b/tools/web/src/test/fixtures/firmware-release.ts index 25229e7..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, @@ -713,8 +713,8 @@ export const publicBetaFirmwareRelease = { deployment: "public-beta", accessControlled: false, releaseJson: { - path: "/firmware/v0.4.1/release.json", - sha256: "8b84fbb65a0463d20369e1d86dac566ca7a2039ebc30f9186f55c05421962445", + path: "/firmware/v0.4.2/release.json", + sha256: "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", }, } satisfies FirmwareReleaseDescriptor; export const pendingCandidateFirmwareRelease = createFirmwareReleaseFixture({ diff --git a/tools/web/src/test/flash-installer.test.tsx b/tools/web/src/test/flash-installer.test.tsx index 12b3dae..bbd9af8 100644 --- a/tools/web/src/test/flash-installer.test.tsx +++ b/tools/web/src/test/flash-installer.test.tsx @@ -114,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}/`, ); } @@ -158,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); @@ -314,7 +314,7 @@ describe("browser firmware installer states", () => { expect(document.querySelector("esp-web-install-button")).toBeNull(); }); - it("offers the exact unrestricted v0.4.1 beta with prominent unqualified warnings", async () => { + it("offers the exact audited unrestricted v0.4.2 beta with prominent unqualified warnings", async () => { renderInstaller({ release: publicBetaFirmwareRelease }); expect(screen.getByRole("status")).toHaveTextContent( @@ -340,7 +340,7 @@ describe("browser firmware installer states", () => { ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: /install unqualified beta pyble 0\.4\.1/i, + name: /install unqualified beta pyble 0\.4\.2/i, }), ).toBeInTheDocument(); }); @@ -516,15 +516,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(); @@ -534,8 +534,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 () => { @@ -668,15 +668,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/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index 1db61e3..1139f68 100644 --- a/tools/web/src/test/site-contract.test.tsx +++ b/tools/web/src/test/site-contract.test.tsx @@ -22,7 +22,11 @@ import { navigation, siteConfig, } from "@/lib/site"; -import { publicBetaFirmwareRelease } from "@/test/fixtures/firmware-release"; +import { + passedPublicFirmwareRelease, + pendingCandidateFirmwareRelease, + publicBetaFirmwareRelease, +} from "@/test/fixtures/firmware-release"; describe("public-site contract", () => { it("keeps pyble.dev canonical and presents the four launch routes", () => { @@ -181,8 +185,8 @@ describe("public-site contract", () => { ), ).toBeInTheDocument(); expect( - screen.getByText(/firmware installer is currently unavailable/i), - ).toBeInTheDocument(); + screen.getAllByText(/firmware installer is currently unavailable/i), + ).toHaveLength(2); expect( screen.queryByText(/unqualified firmware beta is available/i), ).not.toBeInTheDocument(); @@ -357,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(); @@ -418,6 +438,57 @@ describe("public-site contract", () => { } }); + 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", () => { render(); @@ -599,12 +670,7 @@ describe("public-site contract", () => { screen.getByText(/iPadOS or Android name\/version/i), ).toBeInTheDocument(); expect( - screen.getByText( - /v0\.4\.2 unqualified beta is available for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, - ), - ).toBeInTheDocument(); - expect( - screen.getByText(/full HIL remains pending.*use it at your own risk/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 b136534..c0a7cc7 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -180,7 +180,7 @@ describe("Cloudflare-fronted VPS deployment", () => { expect(script).toContain('descriptor.hilStatus === "pending"'); expect(script).toContain("descriptor.accessControlled === false"); expect(script).toContain( - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde", ); expect(staging).toMatch( /public-beta[\s\S]*?--audited-candidate[\s\S]*?--license-evidence-dir[\s\S]*?--license-build-root/, From 17c7266c7d59b80f9241496c70c42c83cd3050bd Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 23:22:23 +0700 Subject: [PATCH 18/28] [docs] Record v0.4.2 production browser HIL Signed-off-by: Viwat Vchirawongkwin --- .../firmware/browser-flashing.md | 83 ++++++++++--------- docs/specifications/website.md | 66 ++++++++------- docs/validation/README.md | 6 ++ .../browser-flashing/v0.4.2-production.json | 71 ++++++++++++++++ 4 files changed, 161 insertions(+), 65 deletions(-) create mode 100644 docs/validation/browser-flashing/v0.4.2-production.json diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index 510a555..79199ee 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -40,21 +40,21 @@ BLE and PBLE/1. 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 unqualified public beta defined in §10; they are not qualified -profiles. +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 @@ -246,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 @@ -330,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 }] } ] } @@ -567,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` @@ -683,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 @@ -732,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, @@ -773,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` @@ -815,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 @@ -900,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`; @@ -1114,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: @@ -1493,7 +1498,7 @@ and a production smoke test of the disabled state; absence of staging input alone is never authorization to disable it. As a one-time transitional exception, the fresh audited `v0.4.2` candidate MAY -be published as an explicitly **unqualified public beta**. The selector deployment +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 @@ -1505,11 +1510,15 @@ 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. Before the install control -appears, the website MUST say that the exact bytes passed the audited-candidate -release gate but have not passed the complete project HIL matrix, and that -installation is at the user's risk. It MUST NOT call the beta access-controlled, -protected, qualified, fully validated, or generally available. +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, diff --git a/docs/specifications/website.md b/docs/specifications/website.md index e2fe68e..2485c05 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -12,8 +12,8 @@ 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 exact initial ESP32-family image profiles. The -installer MUST distinguish an explicitly unqualified public beta from a -fully HIL-qualified public release. +installer MUST distinguish a hardware-tested public beta from a fully +qualified public release. It MUST NOT imply that: @@ -74,13 +74,14 @@ 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 published firmware image for the exact target and a - truthful release state; an unqualified beta is not a validated release; + 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 an unqualified beta or a qualified release; + 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, @@ -141,16 +142,22 @@ instructions. 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 qualified. 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, but every active installer state MUST visibly say -**unqualified beta**, **HIL pending**, and **use at your own risk** before -profile selection and again beside the install action. It MUST NOT say or imply -that the beta is access-controlled. The home-page target cards MUST identify -the constraints as +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 public-beta/pending-HIL state while that selector is active. +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. @@ -159,16 +166,17 @@ 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`, -**unqualified beta**, and **HIL pending**; installation instructions MUST direct -users to the enabled `/flash` action while preserving the exact profile, backup, -erase, cable/power, and port acknowledgements. The home and support surfaces -MUST include **use at your own risk**. 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 MAY shorten the warning, but MUST put complete -HIL qualification for the same two exact profiles in near-term work rather than -claim they are qualified now. Every one of those surfaces MUST keep C3 -explicitly unavailable. +**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 @@ -179,9 +187,9 @@ capture shows only the app. 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 visibly call it an unqualified beta with HIL pending. Its mechanically -rendered PNG and authored SVG MUST remain paired by reviewed content and -exact-dimension tests. +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 @@ -407,9 +415,11 @@ when all of these facts are true: 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 an **unqualified beta**, says full HIL is - pending and installation is at the user's risk, and never calls it protected, - access-controlled, qualified, validated, or generally available. +- `/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 diff --git a/docs/validation/README.md b/docs/validation/README.md index c2c7614..cd25803 100644 --- a/docs/validation/README.md +++ b/docs/validation/README.md @@ -8,6 +8,12 @@ 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.json` is a public, redacted summary of the +supplemental production-browser installation and interrupted-flash recovery run +for the two enabled v0.4.2 profiles. It supports only the scope stated in that +record and does 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..652606b --- /dev/null +++ b/docs/validation/browser-flashing/v0.4.2-production.json @@ -0,0 +1,71 @@ +{ + "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, + "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, + "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." + ] +} From f331967dddb7a4cc0f44044b4b124c95d0797b3a Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 23:24:26 +0700 Subject: [PATCH 19/28] [red] Require scoped hardware-tested beta copy Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_public_claims.py | 74 +++++++++++++++++---- tools/web/src/test/build-contract.test.ts | 7 +- tools/web/src/test/flash-installer.test.tsx | 12 ++-- tools/web/src/test/site-contract.test.tsx | 20 +++--- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index 204866e..6d04db3 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 @@ -41,23 +42,37 @@ def setUpClass(cls) -> None: / "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") + ) - def test_readme_identifies_the_exact_unqualified_public_beta(self) -> None: + def test_readme_identifies_the_exact_hardware_tested_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") self.assertIn( - "public browser installer currently offers the exact v0.4.2 unqualified beta", + "public browser installer currently offers the exact v0.4.2 hardware-tested beta", firmware, ) - self.assertIn("full project HIL is pending", firmware) + 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("Unqualified v0.4.2 beta; HIL pending", 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("v0.4.2 HIL pending; installer unavailable", 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") @@ -69,12 +84,14 @@ 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_use_the_active_beta_safely(self) -> None: + def test_readme_try_steps_use_the_hardware_tested_beta_safely(self) -> None: try_section = markdown_section(self.readme, "Try PyBLE") - self.assertIn("v0.4.2 unqualified beta", try_section) - self.assertIn("full HIL is pending", try_section) - self.assertIn("use it at your own risk", try_section) + self.assertIn("v0.4.2 hardware-tested beta", try_section) + self.assertIn("Browser installation and interrupted-flash recovery passed", try_section) + self.assertIn("complete release qualification continues", try_section) + 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) @@ -88,13 +105,17 @@ def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: for wording in ( "v0.4.2", - "unqualified beta", - "HIL pending", + "hardware-tested beta", + "browser install/recovery passed", + "release qualification pending", "esp32-4mb", "esp32-s3-n16r8", ): self.assertIn(wording, combined) - self.assertIn("use it at your own risk", combined.lower()) + 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 ( @@ -107,10 +128,37 @@ def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: near_term = markdown_section(self.roadmap, "Near term") self.assertIn( - "Complete full HIL qualification for the exact `esp32-4mb` and", + "Complete the app, PBLE/1, resource, and remaining firmware release", near_term, ) + 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.assertEqual( + [profile["profile_id"] for profile in evidence["profiles"]], + ["esp32-4mb", "esp32-s3-n16r8"], + ) + 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.assertTrue( + any("not the formal" in limitation for limitation in evidence["limitations"]) + ) + def test_bug_template_collects_the_exact_installer_diagnostics(self) -> None: for field_id in ( "profile", diff --git a/tools/web/src/test/build-contract.test.ts b/tools/web/src/test/build-contract.test.ts index 1a4c95b..f62eb19 100644 --- a/tools/web/src/test/build-contract.test.ts +++ b/tools/web/src/test/build-contract.test.ts @@ -259,7 +259,10 @@ describe("production build contract", () => { ); expect(page).toContain('release?.deployment === "public-beta"'); - expect(page).toMatch(/unqualified beta/i); - expect(page).toMatch(/full hardware.*validation.*pending/i); + 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/flash-installer.test.tsx b/tools/web/src/test/flash-installer.test.tsx index bbd9af8..cf37af1 100644 --- a/tools/web/src/test/flash-installer.test.tsx +++ b/tools/web/src/test/flash-installer.test.tsx @@ -314,14 +314,16 @@ describe("browser firmware installer states", () => { expect(document.querySelector("esp-web-install-button")).toBeNull(); }); - it("offers the exact audited unrestricted v0.4.2 beta with prominent unqualified warnings", async () => { + it("offers the exact v0.4.2 hardware-tested beta with scoped qualification copy", async () => { renderInstaller({ release: publicBetaFirmwareRelease }); expect(screen.getByRole("status")).toHaveTextContent( - /unqualified firmware beta.*full hardware-in-the-loop.*pending.*use at your own risk/i, + /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.queryByText(/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, @@ -336,11 +338,13 @@ describe("browser firmware installer states", () => { expect(document.querySelector("esp-web-install-button")).not.toBeNull(); }); expect( - screen.getByText(/unqualified beta.*use at your own risk/i), + screen.getByText( + /browser installation.*interrupted-flash recovery.*real hardware.*exact profile.*complete release qualification.*pending/i, + ), ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: /install unqualified beta pyble 0\.4\.2/i, + name: /install pyble 0\.4\.2 beta/i, }), ).toBeInTheDocument(); }); diff --git a/tools/web/src/test/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index 1139f68..f4daf83 100644 --- a/tools/web/src/test/site-contract.test.tsx +++ b/tools/web/src/test/site-contract.test.tsx @@ -126,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"); @@ -394,7 +394,7 @@ describe("public-site contract", () => { ).toHaveAttribute("href", "/flash"); }); - it("shows the exact beta claims only when its build selector is active", async () => { + 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-"), ); @@ -410,24 +410,28 @@ describe("public-site contract", () => { try { const home = render(); expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( - /unqualified beta for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, + /hardware-tested beta for the exact esp32-4mb and esp32-s3-n16r8 profiles/i, ); expect(screen.getByText(/public v0\.4\.2 firmware/i)).toHaveTextContent( - /full hardware-in-the-loop qualification is pending.*use it at your own risk/i, + /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 unqualified beta · HIL pending", + "v0.4.2 hardware-tested beta · browser install/recovery passed · release qualification pending", ); } home.unmount(); render(); expect( - screen.getByText(/v0\.4\.2 unqualified beta is available/i), - ).toHaveTextContent(/full HIL remains pending.*use it at your own risk/i); + 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; From 88b649c03c8ba80135c617332b4c3b6b028a8f60 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 31 Jul 2026 23:30:05 +0700 Subject: [PATCH 20/28] [green] Publish hardware-tested beta status Signed-off-by: Viwat Vchirawongkwin --- CHANGELOG.md | 3 + README.md | 27 +-- docs/ROADMAP.md | 7 +- tests/publication/test_public_claims.py | 18 +- tools/web/DEPLOYMENT.md | 6 +- tools/web/README.md | 6 +- .../pyble-beta-og-277eee8a-1200x630.png | Bin 0 -> 221378 bytes .../pyble-beta-og-b47b6d10-1200x630.svg | 165 ++++++++++++++++++ tools/web/src/app/flash/page.tsx | 2 +- tools/web/src/app/page.tsx | 18 +- tools/web/src/app/support/page.tsx | 7 +- tools/web/src/components/flash-status.tsx | 14 +- tools/web/src/lib/site.ts | 4 +- tools/web/src/test/flash-installer.test.tsx | 4 +- tools/web/src/test/site-contract.test.tsx | 8 +- 15 files changed, 240 insertions(+), 49 deletions(-) create mode 100644 tools/web/public/social/pyble-beta-og-277eee8a-1200x630.png create mode 100644 tools/web/public/social/pyble-beta-og-b47b6d10-1200x630.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 232f50e..d8aaca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ are released independently from this monorepo. documentation. - Selected firmware agent `0.4.2` for fresh reproducible builds and two-profile qualification from the canonical public history. +- Validated production Chrome installation, deliberate interruption, recovery, + and reset on real `esp32-4mb` and `esp32-s3-n16r8` hardware, and corrected + public beta copy to describe that completed scope. ## App 0.1.0-beta — 2026-07-30 diff --git a/README.md b/README.md index 944cd69..6557188 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,17 @@ BLE GATT peripheral. It supports: - board naming and identify support; and - upstream MicroPython’s standard `neopixel` module. -The public browser installer currently offers the exact v0.4.2 unqualified beta -for both current profiles. The exact bytes pass the audited-candidate release -gate, but full project HIL is pending: - -| Installer profile | Exact target constraint | Public status | -| ----------------- | ------------------------------------------------------------ | --------------------------------------------------- | -| `esp32-4mb` | Classic ESP32, 4 MiB external SPI flash; no PSRAM assumed | Unqualified v0.4.2 beta; HIL pending | -| `esp32-s3-n16r8` | ESP32-S3, 16 MiB flash / 8 MiB Octal PSRAM; N16R8-class only | Unqualified v0.4.2 beta; HIL pending | -| `esp32-c3-4mb` | ESP32-C3, 4 MiB external SPI flash; no PSRAM assumed | Planned; unavailable; no public image | +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 | 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 @@ -108,9 +110,10 @@ shared conformance corpus, documentation, and CI atomically. [TestFlight](https://testflight.apple.com/join/yU4e8s6d), or build the Flutter app locally. 2. Open [pyble.dev/flash](https://pyble.dev/flash) in desktop Chrome or Edge. - The exact v0.4.2 unqualified beta is active while full HIL is pending; please - use it at your own risk. Confirm the active version, your exact profile, and - the enabled install action. + 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b6b0c6c..571e313 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,13 +9,14 @@ promising dates; accepted work is tracked through GitHub issues and milestones. - PBLE/1 editing, run/stop, console, and file workflows over BLE - Offline Blockly with beginner GPIO and NeoPixel examples - Browser installation for the exact `esp32-4mb` and `esp32-s3-n16r8` profiles - as the v0.4.2 unqualified beta; HIL pending + 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 -- Complete full HIL qualification for the exact `esp32-4mb` and - `esp32-s3-n16r8` v0.4.2 bytes +- 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/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index 6d04db3..a427975 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -54,10 +54,11 @@ def setUpClass(cls) -> 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 currently offers the exact v0.4.2 hardware-tested beta", - firmware, + normalized, ) self.assertIn("Production Chrome erase/install", firmware) self.assertIn("interrupted-flash recovery passed", firmware) @@ -86,10 +87,14 @@ def test_readme_caption_describes_only_the_visible_app(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("v0.4.2 hardware-tested beta", try_section) - self.assertIn("Browser installation and interrupted-flash recovery passed", try_section) - self.assertIn("complete release qualification continues", 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) @@ -156,7 +161,10 @@ def test_production_browser_claim_is_bound_to_public_evidence(self) -> None: self.assertEqual(profile["interruption_fetch_rounds"]["firmware"], 2) self.assertEqual(profile["recovery_fetch_rounds"]["firmware"], 2) self.assertTrue( - any("not the formal" in limitation for limitation in evidence["limitations"]) + any( + "not the formal" in limitation + for limitation in evidence["limitations"] + ) ) def test_bug_template_collects_the_exact_installer_diagnostics(self) -> None: diff --git a/tools/web/DEPLOYMENT.md b/tools/web/DEPLOYMENT.md index c70befd..d656199 100644 --- a/tools/web/DEPLOYMENT.md +++ b/tools/web/DEPLOYMENT.md @@ -111,7 +111,7 @@ 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 unqualified beta, use the same retained +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: @@ -133,7 +133,9 @@ 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 -label these bytes unqualified, HIL-pending, and at the user's risk. +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: diff --git a/tools/web/README.md b/tools/web/README.md index 906c9eb..e770c86 100644 --- a/tools/web/README.md +++ b/tools/web/README.md @@ -91,8 +91,10 @@ Enablement requires the release gate frozen in 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. It remains visibly unqualified while complete real-board HIL is -pending. The S3 profile specifically requires an N16R8 module. +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. 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 0000000000000000000000000000000000000000..659fd31d59da3eea59f920749f0ce43be36378bc GIT binary patch literal 221378 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%0D&oI*;aGV{03mFwM%owao{Hhf!61sp`O&2u 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�KWLUB}b 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 + + + 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/src/app/flash/page.tsx b/tools/web/src/app/flash/page.tsx index 298d4ab..4ae76bf 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -27,7 +27,7 @@ export default function FlashPage() { One-time wired provisioning installs PyBLE-enabled MicroPython. Then develop over Bluetooth Low Energy from the tablet-first PyBLE app. {publicBeta - ? " The current v0.4.2 installer is an unqualified beta. It passed the audited-candidate release gate, but full hardware-in-the-loop validation is pending; use it at your own risk." + ? " 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."} diff --git a/tools/web/src/app/page.tsx b/tools/web/src/app/page.tsx index be30d23..6d6a4dc 100644 --- a/tools/web/src/app/page.tsx +++ b/tools/web/src/app/page.tsx @@ -79,7 +79,7 @@ export default function HomePage() { number: "01", title: "Provision once", body: publicBeta - ? `Use USB once to install the exact matching v${firmwareRelease.version} unqualified beta. Full HIL is pending; use it at your own risk.` + ? `Use USB once to install the exact matching v${firmwareRelease.version} hardware-tested beta. Production Chrome install and interrupted-flash recovery passed on both exact profiles; complete release qualification continues.` : qualifiedPublic ? `Use USB once to install the exact matching qualified v${firmwareRelease.version} firmware.` : firmwareRelease @@ -107,10 +107,11 @@ export default function HomePage() { MicroPython and support Bluetooth Low Energy.{" "} {publicBeta ? ( <> - Public v{firmwareRelease.version} firmware is an unqualified - beta for the exact esp32-4mb and esp32-s3-n16r8 profiles. Full - hardware-in-the-loop qualification is pending; use it at your - own risk. + 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 ? ( <> @@ -392,9 +393,10 @@ export default function HomePage() { Install the free iPad beta through Apple TestFlight now.{" "} {publicBeta ? ( <> - The exact v{firmwareRelease.version} unqualified firmware beta - is available for esp32-4mb and esp32-s3-n16r8 while full HIL - remains pending; use it at your own risk. + 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 ? ( <> diff --git a/tools/web/src/app/support/page.tsx b/tools/web/src/app/support/page.tsx index b0773d9..c02660a 100644 --- a/tools/web/src/app/support/page.tsx +++ b/tools/web/src/app/support/page.tsx @@ -53,10 +53,11 @@ export default function SupportPage() {

{publicBeta ? ( <> - The v{firmwareRelease.version} unqualified beta is + The v{firmwareRelease.version} hardware-tested beta is available for the exact esp32-4mb and esp32-s3-n16r8 - profiles. Full HIL remains pending; use it at your own - risk. + profiles. Production Chrome install and + interrupted-flash recovery passed on both exact + profiles; complete release qualification continues. ) : qualifiedPublic ? ( <> diff --git a/tools/web/src/components/flash-status.tsx b/tools/web/src/components/flash-status.tsx index 34690b9..72492a4 100644 --- a/tools/web/src/components/flash-status.tsx +++ b/tools/web/src/components/flash-status.tsx @@ -391,7 +391,7 @@ function FlashStatusForRelease({ {candidate ? "Protected release candidate" : publicBeta - ? "Unqualified firmware beta" + ? "Hardware-tested firmware beta" : "Qualified release"}

@@ -404,7 +404,7 @@ function FlashStatusForRelease({ {candidate ? "Protected release candidate: hardware validation is pending." : publicBeta - ? "Unqualified firmware beta: these exact v0.4.2 bytes passed the audited-candidate release gate, but full hardware-in-the-loop qualification is pending. They are not qualified. Use at your own risk." + ? "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."} @@ -499,8 +499,9 @@ function FlashStatusForRelease({

{publicBeta ? (

- This is an unqualified beta with full HIL pending. Use at your own - risk. + Browser installation and interrupted-flash recovery passed on real + hardware for this exact profile. Complete release qualification is + still pending.

) : null}
@@ -512,8 +513,9 @@ function FlashStatusForRelease({ type="button" slot="activate" > - {publicBeta ? "Install unqualified beta PyBLE" : "Install PyBLE"}{" "} - {verified.version} + {publicBeta + ? `Install PyBLE ${verified.version} beta` + : `Install PyBLE ${verified.version}`} diff --git a/tools/web/src/lib/site.ts b/tools/web/src/lib/site.ts index 5859429..156abdd 100644 --- a/tools/web/src/lib/site.ts +++ b/tools/web/src/lib/site.ts @@ -55,7 +55,7 @@ export function firmwareTargetsForRelease( if (target.planned) { status = "Planned · installer unavailable pending exact-profile HIL"; } else if (release?.deployment === "public-beta") { - status = `v${release.version} unqualified beta · HIL pending`; + status = `v${release.version} hardware-tested beta · browser install/recovery passed · release qualification pending`; } else if ( release?.deployment === "public" && release.hilStatus === "passed" @@ -75,7 +75,7 @@ export function absoluteUrl(path: string): string { } 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/flash-installer.test.tsx b/tools/web/src/test/flash-installer.test.tsx index cf37af1..2612e5c 100644 --- a/tools/web/src/test/flash-installer.test.tsx +++ b/tools/web/src/test/flash-installer.test.tsx @@ -321,7 +321,9 @@ describe("browser firmware installer states", () => { /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.queryByText(/qualified release/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(); diff --git a/tools/web/src/test/site-contract.test.tsx b/tools/web/src/test/site-contract.test.tsx index f4daf83..1a385ee 100644 --- a/tools/web/src/test/site-contract.test.tsx +++ b/tools/web/src/test/site-contract.test.tsx @@ -88,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([ { @@ -135,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)); From 5268b3c35f8e4ad8275b85b7b8abd8036efe48f2 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 08:37:43 +0700 Subject: [PATCH 21/28] [red] Guard v0.4.2 production attestation Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_public_claims.py | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index a427975..ec96728 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -51,6 +51,19 @@ def setUpClass(cls) -> None: / "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") def test_readme_identifies_the_exact_hardware_tested_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") @@ -160,6 +173,13 @@ def test_production_browser_claim_is_bound_to_public_evidence(self) -> None: 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.assertTrue( any( "not the formal" in limitation @@ -167,6 +187,50 @@ def test_production_browser_claim_is_bound_to_public_evidence(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( + "(docs/validation/browser-flashing/v0.4.2-production.md)", + self.readme, + ) + self.assertIn( + "https://github.com/PyBLE-dev/PyBLE/releases/tag/firmware-v0.4.2", + self.flash_page, + ) + self.assertIn("Release evidence and exact hashes", self.flash_page) + + 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_bug_template_collects_the_exact_installer_diagnostics(self) -> None: for field_id in ( "profile", From ad2ed8679e41112cc5c72f2ad6d489bb4e645c1a Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 08:39:22 +0700 Subject: [PATCH 22/28] [green] Publish v0.4.2 production attestation Signed-off-by: Viwat Vchirawongkwin --- CHANGELOG.md | 17 +++-- README.md | 4 ++ docs/validation/README.md | 12 ++-- .../browser-flashing/v0.4.2-production.json | 2 + .../browser-flashing/v0.4.2-production.md | 62 +++++++++++++++++++ tools/web/src/app/flash/page.tsx | 11 ++++ 6 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 docs/validation/browser-flashing/v0.4.2-production.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d8aaca6..630e277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +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. -- Validated production Chrome installation, deliberate interruption, recovery, - and reset on real `esp32-4mb` and `esp32-s3-n16r8` hardware, and corrected - public beta copy to describe that completed scope. + +## 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 6557188..7db7112 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ remaining firmware matrices: | `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 peripheral support, adequate resources, PBLE/1 conformance, recovery testing, diff --git a/docs/validation/README.md b/docs/validation/README.md index cd25803..9f42999 100644 --- a/docs/validation/README.md +++ b/docs/validation/README.md @@ -8,11 +8,13 @@ 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.json` is a public, redacted summary of the -supplemental production-browser installation and interrupted-flash recovery run -for the two enabled v0.4.2 profiles. It supports only the scope stated in that -record and does not replace the formal final-candidate HIL, OI-1 resource, app, -or PBLE/1 qualification matrices. +`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 diff --git a/docs/validation/browser-flashing/v0.4.2-production.json b/docs/validation/browser-flashing/v0.4.2-production.json index 652606b..66f879c 100644 --- a/docs/validation/browser-flashing/v0.4.2-production.json +++ b/docs/validation/browser-flashing/v0.4.2-production.json @@ -19,6 +19,7 @@ "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": { @@ -42,6 +43,7 @@ "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": { 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..d45884f --- /dev/null +++ b/docs/validation/browser-flashing/v0.4.2-production.md @@ -0,0 +1,62 @@ + + + +# 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` | +| Annotated source tag | `firmware-v0.4.2` | +| PyBLE source commit | `ce02b68ab73da903035aa9f992c1f7e8eb2a3691` | +| `release.json` SHA-256 | `5d1b0db8c4b90cccf054cd244530afb3b9112d489aa02f7c5da650e92161acde` | +| `esp32-4mb/firmware.bin` SHA-256 | `3bd148df6163d21dd6ee86eecdff47820f3b20323e7cc39a3253937c60af1245` | +| `esp32-s3-n16r8/firmware.bin` SHA-256 | `7cb73313b7108d9ee7bcd34780ecc25f6fef1590dfeee49bb08c424e58f741ff` | + +## 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. + +## 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/tools/web/src/app/flash/page.tsx b/tools/web/src/app/flash/page.tsx index 4ae76bf..11dff99 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -105,6 +105,17 @@ export default function FlashPage() {
+ {publicBeta ? ( + + Release evidence and exact hashes + + + ) : null} Date: Sat, 1 Aug 2026 08:41:50 +0700 Subject: [PATCH 23/28] [red] Guard current firmware beta terminology Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_public_claims.py | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index ec96728..8f643a9 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -64,6 +64,24 @@ def setUpClass(cls) -> None: 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") + cls.hardware_overview = ( + REPO_ROOT / "docs" / "specifications" / "hardware.md" + ).read_text(encoding="utf-8") + cls.product_requirements = ( + REPO_ROOT / "docs" / "specifications" / "prd.md" + ).read_text(encoding="utf-8") + cls.firmware_requirements = ( + REPO_ROOT / "docs" / "specifications" / "firmware" / "specs.md" + ).read_text(encoding="utf-8") + cls.firmware_tdd = ( + REPO_ROOT / "docs" / "specifications" / "firmware" / "TDD.md" + ).read_text(encoding="utf-8") + cls.website_readme = ( + REPO_ROOT / "tools" / "web" / "README.md" + ).read_text(encoding="utf-8") def test_readme_identifies_the_exact_hardware_tested_public_beta(self) -> None: firmware = markdown_section(self.readme, "What works") @@ -231,6 +249,56 @@ def test_public_surfaces_link_the_release_evidence_and_changelog(self) -> None: 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: + combined = "\n".join( + ( + self.firmware_overview, + self.hardware_overview, + self.product_requirements, + self.firmware_requirements, + self.firmware_tdd, + self.website_readme, + ) + ) + + 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", + ): + 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( + "The exact v0.4.2 public-beta bundle covers exactly the two enabled, " + "not-yet-qualified profiles", + self.firmware_requirements, + ) + self.assertIn( + "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", From 0648801faf8cba7ce5b2e1ebab0582bc68443cd8 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 08:47:36 +0700 Subject: [PATCH 24/28] [docs] Align specifications with v0.4.2 hardware beta Signed-off-by: Viwat Vchirawongkwin --- docs/specifications/firmware.md | 25 ++--- docs/specifications/firmware/TDD.md | 42 +++++---- docs/specifications/firmware/specs.md | 71 +++++++------- docs/specifications/hardware.md | 22 +++-- docs/specifications/prd.md | 93 ++++++++++--------- .../browser-flashing/v0.4.2-production.md | 15 ++- tools/web/README.md | 21 +++-- 7 files changed, 165 insertions(+), 124 deletions(-) diff --git a/docs/specifications/firmware.md b/docs/specifications/firmware.md index a297f3a..9c69794 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,12 +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 -current pre-v1 release qualifies `esp32-4mb` (classic ESP32, 4 MiB flash) and -`esp32-s3-n16r8` (ESP32-S3, 16 MiB flash plus 8 MiB Octal PSRAM). -`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 first validated firmware family, not the product @@ -116,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 current pre-v1 qualification 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 for the current pre-v1 installer | -| `esp32-s3-n16r8` | Measure, derive, freeze, and verify on the owned exact N16R8 profile | Required for the current pre-v1 installer | -| `esp32-c3-4mb` | Deferred; no current threshold or HIL row | Blocks C3 enablement and v1.0, but not the two-profile pre-v1 release | +| `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 979e052..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. @@ -1165,15 +1169,17 @@ PBLE/1 **conformance** tests run against an **in-memory fake transport** shared 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 @@ -1243,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/specs.md b/docs/specifications/firmware/specs.md index c634da7..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 release 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 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 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 release - 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,8 +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 bundle MUST cover exactly - the two qualified profiles; 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)* @@ -664,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 release MUST expose 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 @@ -705,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); @@ -769,16 +775,15 @@ These are tracked, release-blocking where noted; they MUST be closed before the 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 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 49ba98f..3e462a6 100644 --- a/docs/specifications/hardware.md +++ b/docs/specifications/hardware.md @@ -1,6 +1,6 @@ # 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 @@ -12,8 +12,8 @@ hardware-in-the-loop gates. 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` | Current pre-v1 release | Only boards whose module documentation confirms this flash layout | -| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM | `ESP32-S3` | Current pre-v1 release | 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 419fae3..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 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 release 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; @@ -864,8 +867,10 @@ rounding formulas, and evidence contract are normative in Requirements: -- The current pre-v1 public 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 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 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,22 +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 validated firmware targets + 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 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. @@ -1563,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 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/validation/browser-flashing/v0.4.2-production.md b/docs/validation/browser-flashing/v0.4.2-production.md index d45884f..67f2d4a 100644 --- a/docs/validation/browser-flashing/v0.4.2-production.md +++ b/docs/validation/browser-flashing/v0.4.2-production.md @@ -21,11 +21,22 @@ redacted record. | 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 @@ -39,7 +50,9 @@ origin. Each board used an isolated serial-port grant. 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. +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 diff --git a/tools/web/README.md b/tools/web/README.md index e770c86..edec06a 100644 --- a/tools/web/README.md +++ b/tools/web/README.md @@ -5,9 +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 validated ESP32 / ESP32-S3 / ESP32-C3 firmware targets, publishes -privacy and support information, and stages the future browser firmware -installer without claiming that release artifacts are ready. +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 @@ -79,9 +81,11 @@ 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`. @@ -101,8 +105,9 @@ 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 From dff344083fb5d824f48ea792c2d720221a030a6e Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 08:48:19 +0700 Subject: [PATCH 25/28] [red] Guard capability-defined firmware target wording Signed-off-by: Viwat Vchirawongkwin --- tests/publication/test_public_claims.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index 8f643a9..22460a5 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -281,6 +281,7 @@ def test_public_specifications_describe_the_exact_beta_without_overclaim( "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) From c26fed997b6ae164345c0933249da2727ce60313 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 08:48:38 +0700 Subject: [PATCH 26/28] [green] Correct capability-defined target wording Signed-off-by: Viwat Vchirawongkwin --- docs/specifications/firmware.md | 4 ++-- docs/specifications/hardware.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specifications/firmware.md b/docs/specifications/firmware.md index 9c69794..99c225b 100644 --- a/docs/specifications/firmware.md +++ b/docs/specifications/firmware.md @@ -78,8 +78,8 @@ 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 first validated firmware family, not the product -boundary. A future port MAY use another upstream MicroPython port, CPU +These targets are the initial reference/build family, not the product boundary. +A future port MAY use another upstream MicroPython port, CPU architecture, BLE host, native integration mechanism, build system, storage backend, or provisioning tool. It MUST preserve PBLE/1, the protected control-plane boundary, capability negotiation, workspace safety, and the diff --git a/docs/specifications/hardware.md b/docs/specifications/hardware.md index 3e462a6..34b61d5 100644 --- a/docs/specifications/hardware.md +++ b/docs/specifications/hardware.md @@ -6,9 +6,9 @@ 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 From 148c712a0146b40223a0ee6e9dfc8daf75721352 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 09:06:36 +0700 Subject: [PATCH 27/28] [red] Guard retired public release artifacts Signed-off-by: Viwat Vchirawongkwin --- .../src/test/vps-deployment-contract.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index c0a7cc7..246f05f 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -103,6 +103,28 @@ describe("Cloudflare-fronted VPS deployment", () => { ); }); + 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"), @@ -442,6 +464,15 @@ describe("Cloudflare-fronted VPS deployment", () => { 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}'"); From 2863c94795090f176644bd07aa65b3dc48196973 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Sat, 1 Aug 2026 09:08:17 +0700 Subject: [PATCH 28/28] [green] Enforce retirement of pre-public artifacts Signed-off-by: Viwat Vchirawongkwin --- .../web/deploy/nginx/10-pyble-dev-https.conf | 6 +- tools/web/deploy/vps/deploy.sh | 103 ++++++++++++++---- .../src/test/vps-deployment-contract.test.ts | 8 +- 3 files changed, 85 insertions(+), 32 deletions(-) diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index c4f2d01..26d2c92 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -10,9 +10,11 @@ map $status $pyble_firmware_cache_control { } # $request_uri retains the original public path across the internal 404-page -# redirect. Keep missing immutable firmware paths non-cacheable while retaining -# the normal no-cache/no-transform policy for the shared website 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"; } diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index 9b0ffe3..c39af24 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -1157,8 +1157,53 @@ 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} @@ -1167,32 +1212,42 @@ if [[ "${expected_installer_state}" == active ]]; then "${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 - firmware_not_found_headers="${smoke_root}/firmware-not-found-${firmware_not_found_index}.headers" - firmware_not_found_status=$( - curl --silent --show-error --max-time 30 \ - --location --max-redirs 0 --proto '=https' \ - --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: expected 404, received %s.\n' \ - "${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: Cache-Control is not no-store.\n' \ - "${firmware_not_found_path}" >&2 - exit 66 - fi + 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 diff --git a/tools/web/src/test/vps-deployment-contract.test.ts b/tools/web/src/test/vps-deployment-contract.test.ts index 246f05f..caeb7d7 100644 --- a/tools/web/src/test/vps-deployment-contract.test.ts +++ b/tools/web/src/test/vps-deployment-contract.test.ts @@ -119,9 +119,7 @@ describe("Cloudflare-fronted VPS deployment", () => { 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).toMatch(/retired_public_asset_status[\s\S]*?!= 404/); expect(script).toContain("Cache-Control: *no-store"); }); @@ -464,9 +462,7 @@ describe("Cloudflare-fronted VPS deployment", () => { 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/release.json"); expect(firmwareNotFoundSmoke).toContain( "/firmware/v0.4.1/esp32-4mb/manifest.json", );