Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
run: |
if [ -n "${{ github.base_ref }}" ]; then
git fetch --no-tags origin "${{ github.base_ref }}"
PYBLE_DCO_RANGE="origin/${{ github.base_ref }}..HEAD" tools/ci/dco_check.sh
PYBLE_DCO_RANGE="origin/${{ github.base_ref }}..${{ github.event.pull_request.head.sha }}" tools/ci/dco_check.sh
else
PYBLE_DCO_RANGE="${{ github.event.before }}..${{ github.sha }}" tools/ci/dco_check.sh
fi
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ telemetry.
[join with TestFlight](https://testflight.apple.com/join/yU4e8s6d)
- License: [MIT](LICENSE)

<p align="center">
<img
src="tools/web/public/app/pyble-neopixel-gpio48-ipad-landscape.png"
alt="Actual PyBLE iPad app showing GPIO 48 NeoPixel Blocks beside generated MicroPython code"
width="960"
>
</p>

<p align="center">
<em>Actual PyBLE app: a GPIO 48 NeoPixel Blocks program and its generated MicroPython on the pictured ESP32-S3 board.</em>
</p>

## What works

### App
Expand Down Expand Up @@ -52,11 +64,11 @@ BLE GATT peripheral. It supports:

The public browser installer currently offers qualified images for:

| Installer profile | Typical target | Availability |
|---|---|---|
| `esp32-4mb` | Classic ESP32, 4 MB flash | Available |
| `esp32-s3-n16r8` | ESP32-S3, 16 MB flash / 8 MB PSRAM | Available |
| `esp32-c3-4mb` | ESP32-C3, 4 MB flash | Source target; public installer pending HIL |
| Installer profile | Typical target | Availability |
| ----------------- | ---------------------------------- | ------------------------------------------- |
| `esp32-4mb` | Classic ESP32, 4 MB flash | Available |
| `esp32-s3-n16r8` | ESP32-S3, 16 MB flash / 8 MB PSRAM | Available |
| `esp32-c3-4mb` | ESP32-C3, 4 MB flash | Source target; public installer pending HIL |

These are the initial validated ports, not a chip-family allowlist. A future
board is compatible when it has a maintained PyBLE agent port, BLE GATT
Expand Down
22 changes: 22 additions & 0 deletions docs/specifications/firmware/browser-flashing.md
Original file line number Diff line number Diff line change
Expand Up @@ -1418,3 +1418,25 @@ status; availability MUST never be inferred from the mere presence of a
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
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
deployment MUST fail before the build if that state cannot be retrieved or
validated. Transitioning an active public installer to unavailable is a
separate reviewed operation requiring an explicit truth-valued disable flag
and a production smoke test of the disabled state; absence of staging input
alone is never authorization to disable it.

The pre-public `v0.4.1` candidate path was exposed without the required access
control and is permanently burned. The origin MUST quarantine
`/firmware/v0.4.1/` with a non-cacheable not-found response, MUST NOT select or
promote those bytes, and MUST retain any forensic copy outside public routing.
A qualified public release therefore starts at a new immutable version.
67 changes: 66 additions & 1 deletion docs/specifications/website.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# PyBLE — Public Website Specification

Status: **FROZEN (pre-v1 and v1 launch surface)** · Owner: project maintainer · Last updated:
2026-07-30
2026-07-31

This document is the source of truth for the first public website at
`pyble.dev`. It specifies only the public site; the Flutter app, PBLE/1, and
Expand Down Expand Up @@ -102,6 +102,36 @@ it is an additional path, never the only way to open the invitation. A changed
or withdrawn invitation requires the specification, content contract, and QR
asset to change together before deployment.

### 3.2 Public source repository

The canonical public source repository is
`https://github.com/PyBLE-dev/PyBLE`. The home page MUST identify the app,
board-agent firmware, PBLE/1 protocol, tests, and documentation as public,
MIT-licensed source and provide a prominent, accessible link to that exact
repository. The global footer MUST provide the same repository link so it
remains available from every launch route.

Repository links MUST be ordinary HTTPS links and MUST NOT use a tracking
redirect, embedded GitHub widget, remote badge, or client-side network request
to present their primary information. If a link opens a new browsing context,
it MUST use `noopener noreferrer`.

### 3.3 Public-beta social metadata

The home page MUST publish a local `1200 × 630` social image and large-card
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 social image MUST have useful alternative text, remain legible under
common center crops, and make no third-party runtime request. A QR code MUST
NOT be the only invitation path or be embedded in the wide social card, where
platform cropping can make it unreliable. A separate local square TestFlight
card MAY include the exact invitation QR, visible destination, and plain-text
instructions.

## 4. Brand and visual contract

The canonical prompt-chip SVG in `app/assets/branding/` is the source asset. A
Expand Down Expand Up @@ -228,10 +258,36 @@ 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;
- use a valid origin certificate with Cloudflare **Full (strict)** TLS, never
Flexible mode; and
- expose only the required web and key-authenticated administration ports.

After the first qualified public installer is activated, an ordinary
website-only deployment MUST preserve that exact immutable selected release.
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.
It MUST NOT infer availability from the mere presence of a firmware directory
or accept a caller-supplied selector.

Each release carrying an active selector MUST retain a hidden, unserved copy of
the canonically validated selector as deployment state. A replacement staged
public release supersedes this carry-forward state only after the full §7
activation gate passes. Intentionally disabling an active installer requires a
dedicated, truth-valued deployment flag; the deployment MUST reject ambiguous
values and any invocation that both stages firmware and requests disablement.
The public smoke test MUST prove whether the resulting `/flash` page embeds the
expected active release or the explicitly disabled state. A missing selector,
failed carry-forward validation, or unreviewed implicit transition from active
to unavailable aborts before the website build or activation.

The public edge MUST redirect `www.pyble.dev`, `pyble.org`, and
`www.pyble.org` to the same path and query at `https://pyble.dev`. Origin
configuration SHOULD mirror those redirects as defense in depth once their DNS
Expand Down Expand Up @@ -333,6 +389,12 @@ The v1 site is releasable when:
pictured Blocks workspace or generated Python;
- the current external iPad beta invitation is exposed as an operable link and
a locally served, exact-URL QR code without claiming production availability;
- the home page publishes a local `1200 × 630` real-app social card with useful
alternative text and large-card metadata, plus a separate local square
TestFlight invitation card;
- the canonical public source repository is explained in a visible
MIT-licensed-source section and linked from both that section and the global
footer;
- lint, strict type checking, tests, static export, Sites vinext build, SPDX, and
no-leak gates pass;
- the Sites artifact contains `dist/server/index.js` and the exact project
Expand All @@ -352,6 +414,9 @@ The v1 site is releasable when:
- the VPS deployment contract is tested, the exact checked `out/` release is
active through the atomic release symlink, and an origin configuration test
passes before reload;
- a website-only deployment after public installer activation carries the
exact selected release forward, while an intentional transition to the
unavailable state requires the explicit disable flag and production smoke;
- rollback confirmation survives loss of its initiating SSH transport and
executes its embedded shell variables without service-manager rewriting;
- every public launch-route HTML response is byte-identical to the checked
Expand Down
47 changes: 37 additions & 10 deletions firmware/scripts/release_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -13732,14 +13732,20 @@ def validate_bundle(
bundle_dir: Path,
public: bool = False,
*,
previously_activated_public: bool = False,
license_evidence_dir: Path | None = None,
license_build_root: Path | None = None,
repo_root: Path | None = None,
qualification_repo_root: Path | None = None,
) -> dict[str, Any]:
"""Validate a local candidate, audited candidate, or public release tree."""
"""Validate a candidate, public release, or exact activated-public replay."""

bundle = Path(bundle_dir)
_require(
not (public and previously_activated_public),
"fresh and previously activated public validation are mutually exclusive",
)
public_bundle = public or previously_activated_public
license_inputs = (
license_evidence_dir,
license_build_root,
Expand All @@ -13756,7 +13762,13 @@ def validate_bundle(
"public release validation requires fresh license evidence, build root, "
"and repository root",
)
audited_candidate = not public and supplied_license_inputs == len(license_inputs)
_require(
not previously_activated_public or supplied_license_inputs == 0,
"previously activated public validation does not accept fresh license inputs",
)
audited_candidate = (
not public_bundle and supplied_license_inputs == len(license_inputs)
)
if repo_root is not None and qualification_repo_root is not None:
_require(
Path(repo_root).resolve() == Path(qualification_repo_root).resolve(),
Expand Down Expand Up @@ -13942,7 +13954,7 @@ def validate_bundle(
item["hil_status"] in ("pending", "passed"),
"%s HIL status invalid" % profile_id,
)
if public:
if public_bundle:
_require(
item["hil_status"] == "passed", "%s is not HIL-passed" % profile_id
)
Expand Down Expand Up @@ -14033,10 +14045,10 @@ def validate_bundle(
document_paths["hil_report"],
profiles,
identity_with_source,
public,
public_bundle,
repo_root=effective_qualification_root,
)
if public or audited_candidate:
if public_bundle or audited_candidate:
notices = document_paths["third_party_licenses"].read_text(
encoding="utf-8", errors="strict"
)
Expand All @@ -14045,6 +14057,7 @@ def validate_bundle(
"candidate-only linked-input notices cannot qualify an audited release; "
"run the pinned esp-idf-sbom/policy audit",
)
if public or audited_candidate:
_audit_verify_release_evidence(
notice=notices,
evidence_dir=license_evidence_dir,
Expand Down Expand Up @@ -15168,6 +15181,10 @@ def _main(argv: list[str] | None = None) -> int:
validate_mode = validate_parser.add_mutually_exclusive_group()
validate_mode.add_argument("--public", action="store_true")
validate_mode.add_argument("--audited-candidate", action="store_true")
validate_mode.add_argument(
"--previously-activated-public",
action="store_true",
)
validate_parser.add_argument("--license-evidence-dir", type=Path)
validate_parser.add_argument("--license-build-root", type=Path)
validate_parser.add_argument("--repo-root", type=Path)
Expand Down Expand Up @@ -15255,9 +15272,17 @@ def _main(argv: list[str] | None = None) -> int:
validation_mode = (
"--public"
if args.public
else "--audited-candidate" if args.audited_candidate else None
else (
"--audited-candidate"
if args.audited_candidate
else (
"--previously-activated-public"
if args.previously_activated_public
else None
)
)
)
if validation_mode is not None and any(
if validation_mode in ("--public", "--audited-candidate") and any(
value is None for value in evidence_arguments
):
option_names = (
Expand All @@ -15271,22 +15296,24 @@ def _main(argv: list[str] | None = None) -> int:
if value is None
]
parser.error("%s requires %s" % (validation_mode, ", ".join(missing)))
if validation_mode is None and any(
if validation_mode not in ("--public", "--audited-candidate") and any(
value is not None for value in evidence_arguments
):
parser.error(
"license evidence options require --public or --audited-candidate"
)
if (
validation_mode is None
validation_mode in (None, "--previously-activated-public")
and args.qualification_repo_root is None
):
parser.error(
"plain candidate validation requires --qualification-repo-root"
"%s requires --qualification-repo-root"
% (validation_mode or "plain candidate validation")
)
validate_bundle(
args.bundle,
public=args.public,
previously_activated_public=args.previously_activated_public,
license_evidence_dir=args.license_evidence_dir,
license_build_root=args.license_build_root,
repo_root=args.repo_root,
Expand Down
27 changes: 27 additions & 0 deletions tests/firmware_tests/host/test_release_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,33 @@ def test_complete_passed_hil_bundle_passes_public_validation(self):
finally:
license_fixture.close()

def test_previously_activated_public_bundle_revalidates_without_build_evidence(
self,
):
bundle = self.fixture.make_bundle(public=True)
result = RELEASE.validate_bundle(
bundle,
previously_activated_public=True,
qualification_repo_root=self.fixture.repo,
)
self.assertEqual(result["identity"]["version"], "0.4.1")

with self.assertRaises(RELEASE.ReleaseError):
RELEASE.validate_bundle(
bundle,
public=True,
previously_activated_public=True,
qualification_repo_root=self.fixture.repo,
)

pending = self.fixture.make_bundle(public=False)
with self.assertRaises(RELEASE.ReleaseError):
RELEASE.validate_bundle(
pending,
previously_activated_public=True,
qualification_repo_root=self.fixture.repo,
)

def test_corrupt_truncated_missing_and_swapped_parts_fail_closed(self):
mutations = {
"corrupt": lambda bundle: (
Expand Down
7 changes: 7 additions & 0 deletions tests/firmware_tests/test_dco_signoff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ _ASSERT_NAME="test_dco_signoff"
run() {
require_gate "$DCO_CHECK" "build-smith · tools/ci/dco_check.sh (or confirm GitHub DCO app)" || return 0

# GitHub checks out a synthetic merge commit for pull_request events. The DCO
# range must stop at the contributor-owned PR head, otherwise the unsigned
# GitHub-generated merge commit is incorrectly treated as contributor work.
check "PR DCO range ends at the contributor-owned head SHA" \
grep -Fq 'PYBLE_DCO_RANGE="origin/${{ github.base_ref }}..${{ github.event.pull_request.head.sha }}"' \
"$REPO_ROOT/.github/workflows/ci.yml"

# Build a throwaway repo with one signed and one unsigned commit.
local tmp; tmp="$(mk_tmp)"
git init -q "$tmp"
Expand Down
16 changes: 16 additions & 0 deletions tools/web/deploy/nginx/10-pyble-dev-https.conf
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ 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;
Expand Down
Loading
Loading