diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 796843a..db66ec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index afa0e72..ffea44a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ telemetry. [join with TestFlight](https://testflight.apple.com/join/yU4e8s6d) - License: [MIT](LICENSE) +
+
+
+ Actual PyBLE app: a GPIO 48 NeoPixel Blocks program and its generated MicroPython on the pictured ESP32-S3 board. +
+ ## What works ### App @@ -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 diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index dab5d2c..e9386e0 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -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. diff --git a/docs/specifications/website.md b/docs/specifications/website.md index 4800941..23dc0fb 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/firmware/scripts/release_bundle.py b/firmware/scripts/release_bundle.py index b0b928e..fc2770c 100755 --- a/firmware/scripts/release_bundle.py +++ b/firmware/scripts/release_bundle.py @@ -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, @@ -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(), @@ -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 ) @@ -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" ) @@ -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, @@ -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) @@ -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 = ( @@ -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, diff --git a/tests/firmware_tests/host/test_release_bundle.py b/tests/firmware_tests/host/test_release_bundle.py index ede7418..6a3a2ba 100644 --- a/tests/firmware_tests/host/test_release_bundle.py +++ b/tests/firmware_tests/host/test_release_bundle.py @@ -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: ( diff --git a/tests/firmware_tests/test_dco_signoff.sh b/tests/firmware_tests/test_dco_signoff.sh index f465164..fe29dab 100755 --- a/tests/firmware_tests/test_dco_signoff.sh +++ b/tests/firmware_tests/test_dco_signoff.sh @@ -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" diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index dd42e49..21bd825 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -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; diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index 3136b2d..dde630e 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -26,7 +26,9 @@ upload_evidence_root= firmware_version= firmware_tag= firmware_provenance_commit= +firmware_release_json_path= local_firmware_tag_object_before_build= +staged_validation_flag=--verify-staged cleanup_firmware_evidence() { if [[ -n "${firmware_evidence_root}" && -d "${firmware_evidence_root}" ]]; then @@ -64,6 +66,25 @@ if [[ -n ${PYBLE_FLASH_SELECTION_FILE:-} ]]; then fi unset PYBLE_FLASH_SELECTION_FILE +readonly explicitly_disable_public_installer=$( + printf '%s' "${PYBLE_EXPLICITLY_DISABLE_PUBLIC_INSTALLER:-0}" +) +case "${explicitly_disable_public_installer}" in + 0|1) ;; + *) + printf 'Refusing deployment: PYBLE_EXPLICITLY_DISABLE_PUBLIC_INSTALLER must be 0 or 1.\n' >&2 + exit 65 + ;; +esac +if [[ "${explicitly_disable_public_installer}" == 1 && + -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then + printf 'Refusing deployment: firmware staging and explicit installer disablement are mutually exclusive.\n' >&2 + exit 65 +fi +if [[ "${explicitly_disable_public_installer}" == 1 ]]; then + printf 'The public installer is explicitly disabled; production smoke will confirm flash.html is unavailable.\n' +fi + if [[ -n $(git -C "${repository_root}" status --porcelain --untracked-files=all --ignore-submodules=untracked) ]]; then printf 'Refusing to deploy: the complete source tree has uncommitted changes.\n' >&2 exit 65 @@ -199,6 +220,202 @@ if [[ -n ${PYBLE_FIRMWARE_STAGED_ROOT:-} ]]; then export PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" export PYBLE_FLASH_SELECTION_FILE="${staged_selection}" fi + +if [[ -z "${staged_firmware_root}" && + "${explicitly_disable_public_installer}" == 0 ]]; then + preserved_state=$( + ssh -o BatchMode=yes "${deploy_target}" bash -s -- \ + /srv/pyble/current \ + /srv/pyble/releases \ + .pyble-firmware-release-selection.json <<'REMOTE' +set -Eeuo pipefail + +readonly current_release=$1 +readonly release_root=$2 +readonly selection_marker=$3 + +test -L "${current_release}" +managed_release=$(readlink -f -- "${current_release}") +if [[ "${managed_release}" != "${release_root}/"* ]]; then + printf 'Current release is outside the managed release root.\n' >&2 + exit 68 +fi +test -d "${managed_release}" +selection="${managed_release}/${selection_marker}" +if [[ ! -e "${selection}" ]]; then + printf 'unavailable\n' + exit 0 +fi +if [[ -L "${selection}" || ! -f "${selection}" ]]; then + printf 'Active installer selector is not an ordinary file.\n' >&2 + exit 68 +fi +selection_digest=$(sha256sum -- "${selection}" | awk '{ print $1 }') +printf 'active\t%s\t%s\n' \ + "${managed_release}" \ + "${selection_digest}" +REMOTE + ) + IFS=$'\t' read -r preserved_mode preserved_release \ + preserved_selection_digest preserved_extra <<< "${preserved_state}" + if [[ -n "${preserved_extra:-}" ]]; then + printf 'Refusing deployment: active installer state has an invalid shape.\n' >&2 + exit 65 + fi + case "${preserved_mode}" in + unavailable) + if [[ -n "${preserved_release:-}${preserved_selection_digest:-}" ]]; then + printf 'Refusing deployment: unavailable installer state has extra data.\n' >&2 + exit 65 + fi + ;; + active) + if [[ ! "${preserved_release}" =~ ^/srv/pyble/releases/[A-Za-z0-9._-]+$ || + ! "${preserved_selection_digest}" =~ ^[0-9a-f]{64}$ ]]; then + printf 'Refusing deployment: active installer state is invalid.\n' >&2 + exit 65 + fi + + firmware_evidence_root=$(mktemp -d) + chmod 0700 "${firmware_evidence_root}" + preserved_staged_root="${firmware_evidence_root}/preserved-staged" + mkdir -m 0700 -- \ + "${preserved_staged_root}" \ + "${preserved_staged_root}/firmware" + preserved_selection="${preserved_staged_root}/.pyble-firmware-release-selection.json" + ssh -o BatchMode=yes "${deploy_target}" bash -s -- \ + "${preserved_release}/.pyble-firmware-release-selection.json" <<'REMOTE' \ + > "${preserved_selection}" +set -Eeuo pipefail + +readonly selection=$1 +test ! -L "${selection}" +test -f "${selection}" +cat -- "${selection}" +REMOTE + downloaded_selection_digest=$( + shasum --algorithm 256 -- "${preserved_selection}" | + awk '{ print $1 }' + ) + if [[ "${downloaded_selection_digest}" != \ + "${preserved_selection_digest}" ]]; then + printf 'Refusing deployment: active installer selector changed during retrieval.\n' >&2 + exit 65 + fi + firmware_version=$( + node -e ' + 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-]+)*)?$/; + if ( + descriptor.deployment !== "public" || + descriptor.hilStatus !== "passed" || + descriptor.accessControlled !== false || + typeof descriptor.version !== "string" || + !semver.test(descriptor.version) + ) { + throw new Error("The preserved selector is not an unrestricted passed public release"); + } + process.stdout.write(descriptor.version); + ' "${preserved_selection}" + ) + ( + cd -- "${preserved_staged_root}/firmware" + ssh -o BatchMode=yes "${deploy_target}" \ + tar --create --file=- \ + --directory=/srv/pyble/firmware \ + "v${firmware_version}" | + tar --extract --file=- + ) + ssh -o BatchMode=yes "${deploy_target}" bash -s -- \ + /srv/pyble/current \ + "${preserved_release}" \ + "${preserved_release}/.pyble-firmware-release-selection.json" \ + "${preserved_selection_digest}" \ + "/srv/pyble/firmware/v${firmware_version}" <<'REMOTE' +set -Eeuo pipefail + +readonly current_release=$1 +readonly expected_release=$2 +readonly selection=$3 +readonly expected_selection_digest=$4 +readonly firmware_release=$5 + +test "$(readlink -f -- "${current_release}")" = "${expected_release}" +test ! -L "${selection}" +test -f "${selection}" +actual_selection_digest=$( + sha256sum -- "${selection}" | + awk '{ print $1 }' +) +test "${actual_selection_digest}" = "${expected_selection_digest}" +test ! -L "${firmware_release}" +test -d "${firmware_release}" +( + cd -- "${firmware_release}" + sha256sum --check SHA256SUMS >/dev/null +) +REMOTE + PYBLE_FIRMWARE_STAGED_ROOT="${preserved_staged_root}" \ + node "${web_directory}/scripts/stage-firmware-release.js" \ + --verify-preserved-staged + + staged_firmware_root="${preserved_staged_root}" + staged_selection="${preserved_selection}" + firmware_tag="firmware-v${firmware_version}" + firmware_provenance_commit=$( + node -e ' + const { readFileSync } = require("node:fs"); + const release = JSON.parse(readFileSync(process.argv[1], "utf8")); + const commit = release?.provenance?.pyble?.commit; + if (typeof commit !== "string" || !/^[0-9a-f]{40}$/.test(commit)) { + throw new Error("release.json provenance.pyble.commit is invalid"); + } + process.stdout.write(commit); + ' "${staged_firmware_root}/firmware/v${firmware_version}/release.json" + ) + local_firmware_tag_object_before_build=$(verify_local_firmware_tag) + + trusted_firmware_snapshot="${firmware_evidence_root}/trusted-preserved" + mkdir -m 0700 -- "${trusted_firmware_snapshot}" + cp -a -- "${staged_firmware_root}/." "${trusted_firmware_snapshot}/" + verify_firmware_tree_parity \ + "${staged_firmware_root}" \ + "${trusted_firmware_snapshot}" \ + "trusted preserved firmware snapshot" + PYBLE_FIRMWARE_STAGED_ROOT="${trusted_firmware_snapshot}" \ + node "${web_directory}/scripts/stage-firmware-release.js" \ + --verify-preserved-staged + + staged_firmware_root="${trusted_firmware_snapshot}" + staged_selection="${staged_firmware_root}/.pyble-firmware-release-selection.json" + staged_validation_flag=--verify-preserved-staged + export PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" + export PYBLE_FLASH_SELECTION_FILE="${staged_selection}" + ;; + *) + printf 'Refusing deployment: current installer state is invalid.\n' >&2 + exit 65 + ;; + esac +fi + +if [[ -n "${staged_firmware_root}" ]]; then + firmware_release_json_path=$( + node -e ' + const { readFileSync } = require("node:fs"); + const descriptor = JSON.parse(readFileSync(process.argv[1], "utf8")); + const path = descriptor?.releaseJson?.path; + if ( + typeof path !== "string" || + path !== `/firmware/v${descriptor.version}/release.json` + ) { + throw new Error("selected release.json path is invalid"); + } + process.stdout.write(path); + ' "${staged_selection}" + ) +fi readonly release_timestamp=$(date -u +%Y%m%dT%H%M%SZ) readonly release_name="${release_timestamp}-${commit:0:12}" readonly release_root=/srv/pyble/releases @@ -214,6 +431,7 @@ cd -- "${web_directory}" if [[ -z "${staged_firmware_root}" ]]; then rm -rf -- out/firmware fi +rm -f -- out/.pyble-firmware-release-selection.json npm ci NEXT_TELEMETRY_DISABLED=1 npm run check @@ -228,15 +446,19 @@ if [[ -n $(git -C "${repository_root}" status --porcelain --untracked-files=all fi test ! -e out/firmware +test ! -e out/.pyble-firmware-release-selection.json if [[ -n "${staged_firmware_root}" ]]; then cp -R "${staged_firmware_root}/firmware" out/ PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" \ node "${web_directory}/scripts/stage-firmware-release.js" \ - --verify-staged + "${staged_validation_flag}" verify_firmware_tree_parity \ "${staged_firmware_root}/firmware" \ "${web_directory}/out/firmware" \ "packaged website firmware" + 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}" ) @@ -270,7 +492,7 @@ printf '%s\n' "${commit}" > "out/${source_commit_marker}" if [[ -n "${staged_firmware_root}" ]]; then PYBLE_FIRMWARE_STAGED_ROOT="${staged_firmware_root}" \ node "${web_directory}/scripts/stage-firmware-release.js" \ - --verify-staged + "${staged_validation_flag}" verify_firmware_tree_parity \ "${staged_firmware_root}/firmware" \ "${web_directory}/out/firmware" \ @@ -349,6 +571,19 @@ fi cd -- "${trusted_upload_snapshot}" shasum --algorithm 256 --check "${trusted_site_inventory_name}" >/dev/null ) +if [[ -n "${staged_firmware_root}" ]]; then + readonly expected_installer_state=active + readonly expected_selection_digest=$( + shasum --algorithm 256 -- \ + "${trusted_upload_snapshot}/.pyble-firmware-release-selection.json" | + awk '{ print $1 }' + ) +else + readonly expected_installer_state=disabled + readonly expected_selection_digest=none + test ! -e \ + "${trusted_upload_snapshot}/.pyble-firmware-release-selection.json" +fi find "${trusted_upload_snapshot}" -type d -exec chmod 0500 {} + find "${trusted_upload_snapshot}" -type f -exec chmod 0400 {} + @@ -376,7 +611,9 @@ ssh -o BatchMode=yes "${deploy_target}" bash -s -- \ "${activation_confirmation}" \ "${commit}" \ "${trusted_site_inventory_name}" \ - "${trusted_site_inventory_digest}" <<'REMOTE' + "${trusted_site_inventory_digest}" \ + "${expected_installer_state}" \ + "${expected_selection_digest}" <<'REMOTE' set -Eeuo pipefail readonly incoming_release=$1 @@ -390,6 +627,8 @@ readonly activation_confirmation=$8 readonly expected_source_commit=$9 readonly trusted_site_inventory_name=${10} readonly expected_trusted_site_inventory_digest=${11} +readonly expected_installer_state=${12} +readonly expected_selection_digest=${13} readonly trusted_site_inventory="${incoming_release}/${trusted_site_inventory_name}" readonly next_link="${current_release}.next" readonly release_root=$(dirname -- "${final_release}") @@ -499,6 +738,23 @@ for required_file in \ done test "$(cat "${incoming_release}/.pyble-source-commit")" = \ "${expected_source_commit}" +if [[ "${expected_installer_state}" == active ]]; then + selection="${incoming_release}/.pyble-firmware-release-selection.json" + test ! -L "${selection}" + test -f "${selection}" + actual_selection_digest=$( + sha256sum -- "${selection}" | + awk '{ print $1 }' + ) + test "${actual_selection_digest}" = "${expected_selection_digest}" +elif [[ "${expected_installer_state}" == disabled ]]; then + test "${expected_selection_digest}" = none + test ! -e \ + "${incoming_release}/.pyble-firmware-release-selection.json" +else + printf 'Incoming website installer state is invalid.\n' >&2 + exit 69 +fi for firmware_release in "${incoming_release}"/firmware/v*; do if [[ ! -d "${firmware_release}" ]]; then @@ -775,6 +1031,23 @@ for route in / /privacy /support /flash; do "${normalized_headers}" done +if [[ "${expected_installer_state}" == active ]]; then + test -f out/.pyble-firmware-release-selection.json + actual_selection_digest=$( + shasum --algorithm 256 -- \ + out/.pyble-firmware-release-selection.json | + awk '{ print $1 }' + ) + test "${actual_selection_digest}" = "${expected_selection_digest}" + grep -Fq "${firmware_release_json_path}" out/flash.html + grep -Fq "${firmware_release_json_path}" "${smoke_root}/flash.html" +else + test "${explicitly_disable_public_installer}" = 1 || + test ! -e out/.pyble-firmware-release-selection.json + grep -Fq 'Installer unavailable' out/flash.html + grep -Fq 'Installer unavailable' "${smoke_root}/flash.html" +fi + for firmware_release in out/firmware/v*; do if [[ ! -d "${firmware_release}" ]]; then continue diff --git a/tools/web/public/app/pyble-neopixel-gpio48-ipad-landscape.png b/tools/web/public/app/pyble-neopixel-gpio48-ipad-landscape.png new file mode 100644 index 0000000..701d8dd Binary files /dev/null and b/tools/web/public/app/pyble-neopixel-gpio48-ipad-landscape.png differ diff --git a/tools/web/public/social/pyble-beta-og-1200x630.png b/tools/web/public/social/pyble-beta-og-1200x630.png new file mode 100644 index 0000000..e801659 Binary files /dev/null and b/tools/web/public/social/pyble-beta-og-1200x630.png differ diff --git a/tools/web/public/social/pyble-beta-og-1200x630.svg b/tools/web/public/social/pyble-beta-og-1200x630.svg new file mode 100644 index 0000000..23c229e --- /dev/null +++ b/tools/web/public/social/pyble-beta-og-1200x630.svg @@ -0,0 +1,164 @@ + + + diff --git a/tools/web/public/social/pyble-testflight-qr-1080.png b/tools/web/public/social/pyble-testflight-qr-1080.png new file mode 100644 index 0000000..b95204a Binary files /dev/null and b/tools/web/public/social/pyble-testflight-qr-1080.png differ diff --git a/tools/web/public/social/pyble-testflight-qr-1080.svg b/tools/web/public/social/pyble-testflight-qr-1080.svg new file mode 100644 index 0000000..f346e56 --- /dev/null +++ b/tools/web/public/social/pyble-testflight-qr-1080.svg @@ -0,0 +1,158 @@ + + + diff --git a/tools/web/scripts/stage-firmware-release.js b/tools/web/scripts/stage-firmware-release.js index 10bb93c..36e3eb6 100644 --- a/tools/web/scripts/stage-firmware-release.js +++ b/tools/web/scripts/stage-firmware-release.js @@ -243,6 +243,56 @@ export async function validateWithCanonicalReleaseTool( } } +/** + * Re-run every self-contained public-release check for bytes that were already + * activated through the fresh license-evidence gate. The deployment wrapper + * permits this mode only for an exact selector recovered from its current + * managed release. + * + * @param {string} bundleDirectory + * @param {"public" | "candidate"} deployment + */ +export async function validatePreviouslyActivatedPublicWithCanonicalReleaseTool( + bundleDirectory, + deployment, +) { + if (deployment !== "public") { + failure("preserved activation accepts only a public release"); + } + const repositoryRoot = resolve(packageDirectory(), "..", ".."); + const canonicalReleaseTool = join( + repositoryRoot, + "firmware", + "scripts", + "release_bundle.py", + ); + try { + await execFile( + "python3", + [ + canonicalReleaseTool, + "validate", + resolve(bundleDirectory), + "--previously-activated-public", + "--qualification-repo-root", + repositoryRoot, + ], + { + cwd: repositoryRoot, + maxBuffer: 16 * 1024 * 1024, + }, + ); + } catch (error) { + const detail = + error instanceof Error && "stderr" in error && error.stderr + ? `: ${String(error.stderr).trim()}` + : ""; + failure( + `canonical preserved-public validation rejected the bundle${detail}`, + ); + } +} + /** * @param {unknown} value * @param {string} label @@ -943,6 +993,36 @@ export async function validateStagedFirmwareRelease( return derived; } +/** + * 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. + * + * @param {string} stagedRoot + * @param {{ + * releaseValidator: (bundleDirectory: string, deployment: "public" | "candidate") => PromiseFree and open source
++ The tablet app, board-agent firmware, PBLE/1 protocol, tests, and + documentation are developed in public under the MIT license. + Explore how the pieces fit together, build from source, or help + bring PyBLE to another MicroPython + BLE board. +
+ + Explore PyBLE on GitHub +- The initial beta firmware supports ESP32, ESP32-S3, and - ESP32-C3. Use the reviewed image supplied for your exact - target; this initial step uses a cable. + The public installer supports only esp32-4mb and + esp32-s3-n16r8. ESP32-C3 is not currently available. Use the + reviewed image supplied for your exact target; this initial + step uses a cable.
diff --git a/tools/web/src/components/site-footer.tsx b/tools/web/src/components/site-footer.tsx index a2b45a3..66b7e57 100644 --- a/tools/web/src/components/site-footer.tsx +++ b/tools/web/src/components/site-footer.tsx @@ -16,6 +16,13 @@ export function SiteFooter() {