Skip to content

CI, releases and the USB installer

The workflows build existing presets without changing their defaults. Application and hardware repositories remain independent; nothing here reads or writes espurr-hw. GitHub Actions and the Pages site do not establish physical acceptance.

Ordinary CI

CI runs on PRs and pushes to main, and is reused by the three independent release tracks. The job planner selects the consumers of changed source: Apple-only changes run Apple checks, Android-only changes run Android checks, and firmware configuration/main/ changes run firmware and host checks. Shared C components, previews, protocol definitions, fixtures, generators, workflows and unknown paths conservatively run all consumers. Shared Apple/Android resources run both apps. Documentation/installer-only changes run the tools, browser and documentation checks. Diffs include deletions and both sides of renames; unavailable ancestry falls back to all consumers.

The firmware job builds every entry in CMakePresets.json's buildPresets, including dedicated recovery profiles. Firmware uses the official ESP-IDF v6.1 container, pinned by manifest digest, and the committed target dependency locks. Changed locks fail the build. Per-profile ccache entries accelerate compilation; generated local SDK configurations, credentials and managed source directories are not cached or uploaded.

Independent jobs run native C/CTest/browser checks, release-tool and installer tests, Swift package tests and unsigned phone/watch/Catalyst builds, and Android JVM tests, lint and debug compilation. Tools, workflow and documentation checks always run. Native UI interaction, physical BLE/OTA, signing and store distribution are separate checks.

Use CI passed as the ordinary required status check. It requires successful planning/tools and every selected consumer. Only jobs explicitly excluded by the plan may be skipped; failures, cancellation and unexpected skips fail the gate. Avoid requiring individual dynamic matrix names, since the list follows the existing presets. All PRs still start CI, including documentation-only PRs.

The large hardware-profile matrix

Hardware profile matrix is separate from ordinary CI and releases. Use Run workflow and enter a branch, tag or commit in ref; empty uses the default branch. Launch the manual workflow from the default branch and enter the target in ref. The approval helper comes from the default branch, while the requested source is resolved to an exact commit before building. cases accepts space-separated names from tools/test_hardware_profiles.py; empty runs every case.

PR review submission/dismissal also starts a cheap eligibility check. The expensive build runs only when the current PR head has an approval from someone with write, maintain or admin permission and no outstanding change request from such a reviewer. When GitHub supplies a reviewDecision, it must also be APPROVED, so pending required reviews are not replaced by the one-writer check. A null review decision still requires explicit fresh-writer approval. Comment-only review events are ignored, with a separate concurrency group and check name: they cannot cancel an approved build or create a successful required check. Manual runs also use a separate concurrency namespace from PR reviews. Approvals of older commits do not qualify. The build checks the exact PR merge revision, and approval/head/merge revision are checked again after completion. A new commit therefore needs fresh approval and another run. Failure logs and case definitions are retained for seven days. Both approval checks use the same pinned revision of the default-branch helper. Non-collaborator and deleted reviewers cannot approve a build; permission API failures other than a non-collaborator's 404 fail closed.

To enforce this before merge, add Hardware matrix passed as a required check in the main branch rule, require PR reviews, dismiss stale approvals and require branches to be up to date. Before review, this status remains expected; it does not run the expensive matrix merely because a PR opened. Review events that do not qualify fail the cheap gate. Manually dispatched runs report Hardware matrix (manual), so a subset run cannot satisfy the required PR check.

This eligibility check is an additional build-cost gate, not a replacement for the repository's merge rules. An eligible review runs code from the PR with read-only permissions, no persisted checkout token and no release/deployment secrets. A public fork cannot publish through this workflow. The check is optional until the repository rule is configured. No rule or environment is created by adding these files.

Version tags and publishing

The repository has independent firmware, Apple and Android release versions:

Tag Track Version source
vX.Y.Z, vX.Y.Z-rcN Firmware Root CMakeLists.txt PROJECT_VER
ios/vX.Y.Z, ios/vX.Y.Z-rcN iPhone/iPad, Watch and macOS Catalyst together ios/ReleaseVersion.xcconfig
android/vX.Y.Z, android/vX.Y.Z-rcN Android android/app/build.gradle.kts

The tag's numeric base must match its track's committed version. Release workflows select that track's complete checks rather than using changed paths. Apple and Android versions need not match each other or firmware; an app fix does not require a firmware release. Protocol, catalogue and persistence versions remain separate contracts. A release number or a link to an app is not a compatibility guarantee.

Firmware

Tag release remains the firmware workflow and accepts exactly:

  • vX.Y.Z: a normal published release;
  • vX.Y.Z-rcN: a published prerelease, where N is a positive integer.

Leading zeroes and other suffixes are rejected. The numeric base must match PROJECT_VER in the root CMake file. For example, v0.6.3-rc2 requires set(PROJECT_VER "0.6.3"). The image's version remains 0.6.3; the tag/metadata identify the RC channel. Application, protocol and catalogue versions remain distinct.

After deliberately updating and committing the version, create and push the desired tag. Pushing a tag is the publishing operation; creating a local tag alone does not run Actions. Firmware releases run tools, firmware packaging and host checks without depending on Apple or Android builds. The large optional matrix is not rerun automatically for tags.

Each build produces espurr-<tag>-<preset>.zip. All packages include:

  • app.bin: the unmerged app-only OTA image, retaining its native image family;
  • parts/ and flash-plan.json: individual segments, generated offsets and flash settings;
  • metadata.json: profile, chip, IMU, flash layout, source SHA, SDK and content hashes;
  • LICENSE.md and SOURCE_NOTICES.txt: project terms and retained vendored firmware notices;
  • FIRMWARE_NOTICES.txt and FIRMWARE_NOTICES.json: app, bootloader and compiler-runtime notices and input hashes;
  • SHA256SUMS.txt: checksums of the package contents.

The source-notice bundle includes optional repository drivers as well as effects. The firmware bundle adds the linked SDK/managed libraries and selected compiler runtimes, including Picolibc/Newlib notices and GCC's runtime exception. Collection uses both app and bootloader GNU maps, Ninja archive/object dependencies, upstream licence/NOTICE files and retained source/header copyright notices. Standard licence texts supplement SPDX-only headers; alternative terms remain as upstream states. Missing/stale dependency records, missing licence files and unreviewed SPDX or runtime inputs fail packaging. Run packaging immediately after building in the same SDK environment; copying only the .bin files is insufficient. No extra Python dependency or network access is needed. The source inventory describes provenance and update expectations.

The generated site retains the checksummed notices beside the binaries and links the USB and app-only notices separately when they come from different packages. Older published archives without these files remain readable; publication does not retroactively add notices to their unchanged assets.

Classic ESP32 packages additionally include merged firmware.bin at offset zero and its ESP Web Tools manifest.json. Offsets/settings come from IDF's generated plan. Normal plans must fit flash/app capacity and cannot write NVS/model/recovery reserves. The classic merged image pads intervening regions including NVS; it is an initial/reinstallation image, not a settings-preserving update.

Normal S3 packages retain app-only payloads and native flash-plan parts without a redundant merged USB image. The public installer instead uses the matching recovery package for each supported S3 family/layout. Recovery bootstrap presets enable the native GPIO41 low hold for five seconds by default. Packaging validates exact partition geometry, chip/family/capacity, complete recovery checksum/digest and rollback/test-app configuration. A manifest.json sparse plan explicitly clears A/B and OTA metadata to start recovery; manifest-preserve.json contains only the bootloader, table and recovery. Both exclude NVS, PHY and model storage. The S3 installer uses the pinned ESP Web Tools flash API with whole-chip erase disabled; it does not use merged image padding or the generic erase-choice dialog. bootstrap-plan.json records both plans. The site offers the complete install.zip bundle and links the matching normal package's app.bin for subsequent upload. It also generates an optional manifest-app.json using the validated matching normal image and a fresh CRC-checked ESP_OTA_IMG_NEW selection record for slot A. The manifest writes bootloader/table/recovery, normal app at 0x20000, then the 8 KiB OTA metadata at 0xf000 last. Slot B and NVS/PHY/models are untouched; previous OTA selection/rollback history is replaced. No extra firmware build is needed. The pinned flash API/esptool-js preserve manifest part order.

Standalone custom recovery packages retain their single-app plan and are excluded from the generic installer. Separate browser storage tools initialise Wi-Fi or explicitly reset NVS. Both phone updaters can select normal app images from the same Pages release catalogue, with local file import retained. See recovery bootstrap details.

Custom S3 default-map builds are downloadable release packages but are also excluded from the generic installer. A custom board needs explicit pin/sensor configuration.

Publishing requires exactly one package per existing build preset, all from the same commit and SDK. The workflow uploads every package plus release-index.json and top-level SHA256SUMS.txt to a draft before making it public. Failed draft uploads can be rerun. Server-reported asset names, sizes and SHA-256 digests must match the complete local set before publication. An already published release is never overwritten: an exact matching retry succeeds without writing, while a different asset set or channel fails. This also handles a lost response after publication. Firmware publication jobs share a concurrency group while their builds can run in parallel. The publisher explicitly assigns Latest only to a stable firmware version with no numerically newer published stable firmware. RCs and older backports opt out; higher-numbered app versions cannot influence the decision. Serial publication prevents concurrent tags from racing this selection. GitHub retains only one pending job per concurrency group; if a burst of tags supersedes a pending publication, rerun that cancelled workflow to publish its packages. Firmware owns the repository-wide Latest release; app release drafts explicitly use --latest=false, which must also be retained when publishing them manually. New firmware release descriptions link the newest published stable Apple and Android releases, selected numerically within each track. Drafts and RCs are excluded. These links are a snapshot at firmware publication; the adjacent track-filtered release links also find subsequent app releases. Already published firmware is not edited when a new app ships. If no stable app release exists, the notes say so. Release notes use up to 40 recent commits affecting their own track since its previous ancestral tag, with a full comparison link. An intervening app tag cannot become the firmware changelog baseline, or vice versa. Firmware notes exclude Apple/Android-only paths; app notes include their shared source dependencies. Stable release notes compare against the previous stable tag so changes made during RC testing remain visible; RC notes may compare against an earlier RC. Release artifacts remain available independently of the shorter-lived Actions artifacts.

The schema-1 catalogue exposes each installable variant's normal image_family, app and checksums. Bootstrap entries pair the normal app with app_checksums, while checksums refers to the recovery installation bundle. The native phone pickers consume these same published files; they do not call the GitHub API.

Apple and Android

Apple app release and Android app release run on their respective tag prefixes. Mobile release tooling checks the tag against the committed app version and requires a positive build number. Apple uses the shared MARKETING_VERSION with a committed local/CI build baseline; Android uses versionName/versionCode. Increment Android's code for each Play upload, including additional RCs of the same marketing version. Cloud distribution uses Apple's build counter as described below. Neither app derives versions from firmware or the latest remote release.

Apple tags test the Swift package and compile phone, Watch and Catalyst. They also archive unsigned Release iOS (with its embedded Watch app) and Catalyst builds. The Apple job uses GitHub's xcode-27 arm64 image and selects /Applications/Xcode_27.2.app/Contents/Developer through DEVELOPER_DIR. The shared toolchain preflight requires Xcode 27.0+; native app/extension build phases and the Cloud hook enforce the same minimum. Deployment targets remain iOS/macOS/watchOS 26. GitHub's runner inventory lists installed versions; the major-version runner label's default can differ from the explicitly selected version. Android tags run JVM/debug checks and release lint, then build the minified release APK/AAB and retain the R8 mapping. Native build output stays under ios/DerivedData/ or android/app/build/. Each package records the track, version, build number, source SHA, signing status and asset hashes in mobile-release.json, with a SHA256SUMS.txt covering assets and metadata.

After successful checks, the app workflow uploads these assets to a draft GitHub release, with changes scoped to that app and its shared dependencies since its previous ancestral tag. Firmware is not rebuilt and the installer Pages workflow is not triggered. Actions artifacts are retained for 14 days; draft release assets are independent of that retention. Retries can finish a draft, but never overwrite a published release. App release drafts never become repository Latest.

Apple's GitHub archives are unsigned build evidence. Xcode Cloud separately builds, signs and uploads Apple distribution builds. Android's draft contains a verified release-signed APK and upload-signed AAB. Review an app draft before publishing it with Set as latest release disabled. Physical acceptance, store metadata and public submission remain store release tasks. Android tags also upload the verified AAB to Play's existing internal test track. Apple distribution still uses the configured Xcode Cloud TestFlight post-action. Ordinary CI never receives signing credentials or Play access tokens.

Apple signing with Xcode Cloud

Connect ios/espurr.xcodeproj, product/scheme espurr, to the existing App Store Connect app and primary repository. The shared Cloud manifest and workspace Package.resolved are committed; don't ignore the workspace dependency lock or force automatic dependency resolution. The existing preview build phase generates the shared C catalogue without an ESP-IDF installation.

Configure an Apple release workflow:

  • Start on tag changes matching ios/v*, with automatic cancellation disabled. Remove automatic branch-change conditions. Keep manual starts for branch/tag validation builds.
  • Use a pinned, compatible Xcode/macOS pair. Add an iOS Archive action for espurr, Release, App Store Connect distribution. The companion Watch app and complication are embedded. Add a separate Mac Catalyst archive action for the same scheme, Release and App Store distribution.
  • Add a TestFlight internal testing post-action for archive outputs. Keep public release manual. External TestFlight can require Apple's beta review.
  • Set the next Cloud build number above the highest uploaded build and at least the committed CURRENT_PROJECT_VERSION. All embedded targets receive that counter.

Catalyst must enable App Sandbox for Mac App Store distribution. Its separate entitlements permit existing Bluetooth, outgoing network and user-selected file access; an unsigned archive alone doesn't validate distribution entitlements.

The executable Cloud pre-build hook checks the selected Xcode version, checked-out SHA, release tag/marketing version and notices. It generates ignored Cloud.xcconfig with the Cloud build number for all three Apple bundles. Signing identity/provisioning remain managed by Xcode Cloud; CI_TEAM_ID is an App Store Connect team UUID and must not be used as the ten-character DEVELOPMENT_TEAM. The hook rejects unexpected tag namespaces, automatic untagged archives, a stale build counter and accidental private Local.xcconfig. Explicit manual branch archives are allowed for setup validation. GitHub's unsigned evidence keeps its committed build baseline; Cloud's distribution build number is authoritative for store uploads.

Initial source access and the first product setup use Xcode/App Store Connect. GitHub app checks remain independent of Cloud's archive/upload result. Retain Cloud archives and dSYMs for distributed builds separately from short-lived service artifacts. See Apple's custom scripts, dependency requirements and build counter.

Android signing

The tag workflow first runs the existing checks and produces an unsigned package. A separate signing job receives only the four secrets from the android-release environment. Restrict that environment to tags matching android/v*; do not add branch policies, pass secrets to the reusable CI workflow, or enable signing for PRs.

Environment secret Value
ANDROID_KEYSTORE_BASE64 Base64 of the production JKS containing separate release/upload aliases
ANDROID_KEYSTORE_PASSWORD Keystore password
ANDROID_RELEASE_KEY_PASSWORD Release-key password
ANDROID_UPLOAD_KEY_PASSWORD Upload-key password

The signing helper validates source SHA, exact unsigned assets and checksums before signing. It imports the keystore into a temporary private directory, checks both certificate hashes against public pinned identities, signs the APK with the release alias using apksigner and the AAB with the upload alias using jarsigner. It verifies the resulting signatures before writing signed metadata/checksums. A wrong identity, damaged package or missing key fails the job; the draft requires a complete signed package. It retains the R8 mapping and never uploads keys/passwords as build/release artifacts. Local verification can use --config with ignored JSON containing keystore, store_password, release_alias, release_password, upload_alias and upload_password. Relative keystore paths resolve beside that JSON.

The production keys are RSA 4096 with 40-year certificates. Keep a private 1Password Document containing the encrypted keystore, its passwords/aliases, public certificates and restore notes. A public .pem certificate cannot restore the private key. The backup ZIP contains both keys and passwords and must never be committed or attached to a release. The previous local-build keystore stays separate.

Before using Play:

  1. Register/verify the appropriate Play developer account. Registration has a one-time US$25 fee and requires accepting Google's agreement. New personal accounts also have device verification and closed-testing requirements. Android Studio is optional; signing here uses the existing SDK and Java CLI tools.
  2. Create the app with the permanent application ID dev.neggles.espurr. Before open-testing/production rollout, replace Google's default app-signing identity with the release key using the Play Console's encrypted PEPK import flow. Register the separate upload certificate for AAB uploads. Compare the console fingerprints with release-signing.json before distributing.
  3. Keep the release signing identity aligned across GitHub and Play. Signing GitHub APKs with the upload key would prevent interchangeable updates. Increment versionCode above previous Play uploads, then upload the signed AAB and retain its mapping. Key enrollment and public store release remain manual; the tag workflow performs internal-testing uploads as described below.

Google documents the custom-key import and separate upload key in Play App Signing. Coordinate any future key upgrade across distribution channels before changing the pinned identities or distributing newly signed APKs.

Google Play internal testing uploads

Every android/vX.Y.Z[-rcN] tag runs a separate Play job after signing succeeds, in parallel with the GitHub draft job. It downloads mobile-android-signed, checks its exact asset set, source SHA, tag, checksums and pinned signing identities, then uploads the AAB and publishes it with status completed to internal. Stable Android tags also go to internal testing; production promotion remains manual. The bundle already contains its R8 mapping and native debug symbols. Existing tester selections are preserved. The join link is espurr internal testing.

The publisher uses Google's REST API and a short-lived OAuth token. The package and internal track are fixed in the tool; it cannot select production or edit listings, declarations, prices or testers. It compares Play's returned bundle SHA-256 and version code with the signed package before updating the track. It validates the edit and commits with ERROR_IF_IN_REVIEW, so it cannot cancel an existing store review as a side effect. As with any Play API edit, avoid concurrent manual console changes while it runs.

Play uploads share a concurrency group across Android tags. A stale version code fails rather than rolling internal testing back. Increment the committed versionCode for every new tag, including RCs and a stable tag after an RC; the first manual internal release used code 1. A retry is a no-op only when Play already has the same AAB hash, release name and completed internal release, with no newer version code. Network failures do not blindly repeat mutations; rerunning the job reconciles an already committed release. Success produces mobile-android-play JSON evidence and a workflow summary containing the join link. The local act harness cannot authenticate or publish.

Authentication uses Workload Identity Federation through a service account. There is no service-account JSON key in GitHub. Only the Play job has id-token: write; signing secrets stay in the signing job. Set these non-secret variables on the existing tag-restricted android-release environment:

Environment variable Value
PLAY_WORKLOAD_IDENTITY_PROVIDER Full projects/NUMBER/locations/global/workloadIdentityPools/github/providers/espurr-android-tags resource name
PLAY_SERVICE_ACCOUNT Dedicated espurr-play-internal@PROJECT_ID.iam.gserviceaccount.com email

One-time setup uses a dedicated Google Cloud project. The Cloud CLI can run locally or in Cloud Shell. Choose the permanent project ID before running these owner setup commands:

ESPURR_CI_PROJECT_ID='YOUR_NEW_PROJECT_ID'
gcloud projects create "$ESPURR_CI_PROJECT_ID" --name='ESPurr CI'
gcloud services enable androidpublisher.googleapis.com iam.googleapis.com \
  iamcredentials.googleapis.com sts.googleapis.com --project="$ESPURR_CI_PROJECT_ID"
ESPURR_CI_SETUP_ACCOUNT="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')"
gcloud projects add-iam-policy-binding "$ESPURR_CI_PROJECT_ID" \
  --member="user:${ESPURR_CI_SETUP_ACCOUNT}" --role=roles/iam.workloadIdentityPoolAdmin --condition=None
gcloud iam service-accounts create espurr-play-internal \
  --display-name='ESPurr internal testing uploads' --project="$ESPURR_CI_PROJECT_ID"
gcloud iam workload-identity-pools create github --location=global \
  --display-name='ESPurr GitHub Actions' --project="$ESPURR_CI_PROJECT_ID"
gcloud iam workload-identity-pools providers create-oidc espurr-android-tags \
  --project="$ESPURR_CI_PROJECT_ID" --location=global --workload-identity-pool=github \
  --issuer-uri=https://token.actions.githubusercontent.com \
  --attribute-mapping='google.subject=assertion.sub,attribute.repository_id=assertion.repository_id' \
  --attribute-condition="assertion.repository_id == '1406840770' && assertion.repository_owner_id == '4232981' && assertion.event_name == 'push' && assertion.ref.startsWith('refs/tags/android/v') && assertion.sub == 'repo:neggles/espurr:environment:android-release' && assertion.workflow_ref == 'neggles/espurr/.github/workflows/release-android.yml@' + assertion.ref"
ESPURR_CI_PROJECT_NUMBER="$(gcloud projects describe "$ESPURR_CI_PROJECT_ID" --format='value(projectNumber)')"
gcloud iam service-accounts add-iam-policy-binding \
  "espurr-play-internal@${ESPURR_CI_PROJECT_ID}.iam.gserviceaccount.com" \
  --project="$ESPURR_CI_PROJECT_ID" --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/${ESPURR_CI_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github/attribute.repository_id/1406840770"
gcloud projects remove-iam-policy-binding "$ESPURR_CI_PROJECT_ID" \
  --member="user:${ESPURR_CI_SETUP_ACCOUNT}" --role=roles/iam.workloadIdentityPoolAdmin --condition=None

The provider pins GitHub's numeric repository/owner identities, the release workflow, tag namespace, push event and android-release environment. Do not replace its condition with an unrestricted GitHub issuer. The service account needs no broad Google Cloud project role and no downloaded private key. The commands temporarily grant the setup owner Workload Identity Pool Admin because the basic Owner role does not include pool/provider creation, then remove that setup grant. Allow the grant to propagate before creating the pool. For an existing project, preserve any pre-existing setup role instead of removing it.

In Play Console → Users and permissions, add that service-account email with access to espurr only. Grant View app information (read-only) and Release apps to testing tracks. Do not grant administration, financial data, production releases, listing management or tester-list management. Play's testing permission covers testing tracks generally; the publisher restricts itself to internal. After the first manual bundle/release exists, the Developer API setup allows this service account to upload future builds. Set the GitHub variables:

gh variable set PLAY_SERVICE_ACCOUNT --repo neggles/espurr --env android-release \
  --body "espurr-play-internal@${ESPURR_CI_PROJECT_ID}.iam.gserviceaccount.com"
gh variable set PLAY_WORKLOAD_IDENTITY_PROVIDER --repo neggles/espurr --env android-release \
  --body "projects/${ESPURR_CI_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github/providers/espurr-android-tags"

Allow IAM changes to propagate before the first run. Verify an actual tag job and its Play result before treating local tests as hosted authentication/upload acceptance. Missing variables, disabled APIs or denied Play permissions fail the Play job; they do not silently skip it or stop the independent GitHub draft from being prepared.

For example, after updating and committing the Apple version/build number:

git tag ios/v0.2.0
git push origin ios/v0.2.0

Use android/v0.1.0 analogously after checking Android's committed version/code. Keep existing v* firmware tags unchanged: installer and native updater schemas continue to accept the existing firmware tag format and ignore app releases.

Pages and retained releases

The same Pages artifact includes the MkDocs Material documentation at /espurr/docs/. The installer stays at /espurr/, and the hardware link stays /espurr-hw/. Site maintenance describes selected sources, archive exclusion, local preview and strict link validation. There is one upload/deployment; firmware release selection does not select a documentation version.

Installer Pages runs after successful tag publishing, on installer source changes to main, and manually from the default branch. It listens for a successful completion of Tag release using workflow_run, verifies that the originating push belongs to this repository, and checks out the default-branch SHA captured for the Pages event. It does not execute source from the tag or download workflow artifacts from the upstream run. Its published firmware comes from verified GitHub Release assets.

Pages deployment therefore always uses the default branch's OIDC/ref context. Failed, foreign and non-push upstream runs have separate concurrency groups, so their skipped runs cannot replace an eligible pending deployment. A GITHUB_TOKEN-created release would not fire another release-event workflow, but workflow completion does. A failed release run does not deploy. Pages failures can be retried independently of firmware publication. Build attempts have distinct Pages artifact names; a retried deployment consumes the name recorded by its successful build, so both whole-run and deployment-only retries work. Firmware and failure-log artifacts are explicitly replaceable on retries of their respective jobs.

The build authenticates with its read-only Actions token, lists published GitHub Releases, and sorts valid tags numerically. Defaults in site/config.json retain the newest three major versions, with the newest three minor lines within each major. Stable and eligible RC tags share this window: a new major or minor with only RCs counts towards its limit. Each retained line contributes its newest stable patch and, if available, its highest-version RC newer than the newest published stable on that same line. This retains at most nine stable tags and nine RC tags with the defaults. For example, stable v1.2.4 excludes v1.2.4-rc9, while v1.2.5-rc3 remains eligible even if stable v1.3.0 exists. Between v1.2.5-rc9 and v1.2.5-rc10, only rc10 is retained; v1.2.6-rc1 supersedes both. A line without a stable release can contribute its newest RC. Drafts and unrelated tag formats are excluded. A tag/prerelease mismatch fails the build. GitHub publication time does not affect version ordering.

Override the window with repository variables INSTALLER_MAJOR_COUNT and INSTALLER_MINOR_COUNT (0–50 each), then manually rerun Pages. Setting either to zero retains no firmware tags. The local site command accepts --major-count and --minor-count overrides. Each RC cutoff uses all published stable tags on that line before applying the retention window. The deployment directory is built afresh, so dropped tags leave no stale binaries.

Only selected releases' installer-enabled packages are downloaded. ZIP and member hashes, metadata and safe archive paths are checked before copying to Pages. A selected release without valid packaging fails the build and leaves the deployed site unchanged; older hand-created releases need compatible packages or removal from the retained set. The browser fetches a local releases.json and same-origin firmware/manifests. It needs no GitHub API token and works while this source repository is private. The public page and the copied firmware assets themselves are public.

Stable releases appear by default; users can include retained RCs. Hardware must be selected explicitly. Its IMU and flash size are shown from build metadata. The install confirmation resets when release/hardware changes, and a stale module download cannot restore the previous selection's button. ESP Web Tools is pinned to 10.4.0 and loads only after confirmation in a secure browser with Web Serial support. App-only OTA and prepared USB-image downloads remain available in unsupported browsers. iOS/Android cards are placeholders. The site's primary colour is violet, with bright orange accents, following the apps' colour direction.

Before the first actual deployment:

  1. Enable Pages with GitHub Actions as its source. Private-source Pages requires an eligible GitHub plan; normal project Pages content is public.
  2. Configure the github-pages environment to permit only the default branch (main). Release tags do not need deployment permission.
  3. Enable the desired required checks/review rules separately. Consider a tag rule restricting who can create v* tags; tags execute publishing code from that commit.

No repository visibility, Pages/environment settings, branch rules or tags are changed by local implementation/validation. The workflows deploy only when later enabled/pushed and triggered. A push of installer changes to main triggers Pages.

Local checks

python3 -m unittest discover -s tests -p 'test_release*.py' -v
node --test tests/test_installer.mjs tests/test_workflow_guards.mjs
actionlint .github/workflows/*.yml

With the pinned IDF environment active, package a clean, known build without flashing:

python tools/release.py package --profile normal --build build/firmware/normal \
  --output build/releases --tag "ci-$(git rev-parse --short=12 HEAD)" \
  --commit "$(git rev-parse HEAD)"

Only use a clean build as release provenance. Do not package private local configurations for publication; a firmware image can contain build-time credentials even though metadata omits the SDK configuration. CI uses fresh hosted checkouts. The site command uses authenticated read-only gh calls and an empty output path:

python3 tools/release.py site --output build/site --repo neggles/espurr
python3 -m http.server --directory build/site 8080

After inspecting the local page, stop that server. USB writing, release publication, Pages deployment and signed mobile distribution require their own actual validation.