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, whereNis 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/andflash-plan.json: individual segments, generated offsets and flash settings;metadata.json: profile, chip, IMU, flash layout, source SHA, SDK and content hashes;LICENSE.mdandSOURCE_NOTICES.txt: project terms and retained vendored firmware notices;FIRMWARE_NOTICES.txtandFIRMWARE_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:
- 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.
- 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 withrelease-signing.jsonbefore distributing. - Keep the release signing identity aligned across GitHub and Play. Signing GitHub
APKs with the upload key would prevent interchangeable updates. Increment
versionCodeabove 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:
- Enable Pages with GitHub Actions as its source. Private-source Pages requires an eligible GitHub plan; normal project Pages content is public.
- Configure the
github-pagesenvironment to permit only the default branch (main). Release tags do not need deployment permission. - 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.