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({
{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.
+
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.
- 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.