ADR-0022: Published firmware — signed in CI, a web flasher on GitHub Pages, stable and beta channels¶
Status: Accepted (2026-09-27) Date: 2026-09-27 (lot 6c of the « ouverture » audit)
Context¶
Since ADR-0020 the firmware holds no secret and every build is the same for everyone, except for the display revision, which ESPHome chooses at compile time (three binaries: ST7123, ST7121, ILI9881C). Nobody could install it without a Python toolchain and a compile of about ten minutes.
Checked on 2026-09-27 (ESPHome 2026.9.0 installed on the dev PC, esphome/build-action v8.1.0, ESP Web Tools 10.4.0):
- ESP Web Tools flashes from Chrome or Edge (Web Serial), accepts
chipFamily: "ESP32-P4", offers Wi-Fi through Improv right after flashing (the firmware hasimprov_serial) and links to Home Assistant (home_assistant_domain: esphome). The page must be served over HTTPS, and the binaries with CORS headers: files attached to a GitHub release have none, GitHub Pages has them. update: platform: http_requestreads the same manifest:name,version, and for the build whosechipFamilyequalsESPHOME_VARIANT(« ESP32-P4 »),ota.pathandota.md5. A relative path is resolved from the manifest's folder. It needsota: platform: http_request, which goes through the same OTA backend asota: esphome:signed_ota_verificationapplies, so an image not signed with the project key is refused after download.esphome/build-actionwithcomplete-manifest: truewrites the manifest (name= project name,version= project version, factory image at offset 0, OTA image with MD5 and SHA-256) and accepts-ssubstitutions.- The existing
http_requesthasverify_ssl: false: the HTTPS certificate of the download is not checked. - Packages merge an
ota:list with another list, but a dictionaryota:was replaced by the package's list (noesphomeOTA left at all).
Decision¶
- Workflow
.github/workflows/publication.yml, run when a release is published (or by hand for a tag): - builds the three display revisions with ESPHome pinned (
ESPHOME_PUBLICATION, the version tried on the tablet, never belowmin_version), signed with the project key from theTAB5_CLE_SIGNATUREsecret. The key's SBv2 public digest is written in the workflow: if the secret gives another one, nothing is signed. The signature of each image is checked, then the key file is deleted; - attaches
tab5-ha-hmi-<revision>.factory.bin,.ota.binandmanifest-<revision>.jsonto the release (tools/publication/preparer.py); - rebuilds the whole GitHub Pages site from the files of the releases (
tools/publication/pages.py), never from one run:stable/<revision>/holds the latest 3.x release that is not a pre-release,beta/<revision>/the most recent 3.x release. The page comes fromweb/on the default branch. - Channels: a pre-release is built for the beta channel, any other release for the stable one. A beta tablet therefore moves on to the stable that follows its beta; a stable tablet never sees a pre-release.
- The update entity exists only in published firmware:
tab5_publication(localeby default,stableorbetain the workflow) selectsTab5/paquets/publication-<value>.yaml; the local one is empty. Someone who compiles with their own key would otherwise be offered updates their tablet refuses. - Version:
project: versionis${ tab5_version | default('3.0.0-dev') }; the workflow passes the tag without itsv, which the update entity compares with the manifest. ota:inTab5/paquets/tab5-hardware.yamlbecomes a list, so a published build keepsesphomeand addshttp_request.- The three entity IDs the firmware still called with placeholders get defaults that exist everywhere (lot 6c-1): nothing is left to set for a published binary.
Alternatives rejected¶
- Signing on the PC and uploading by hand: the key never leaves the PC, but every release depends on it being on, and nothing checks what is published. Axel chose the CI secret (2026-09-27).
- Binaries only as release files: no CORS, so no web flasher and no update from HA.
- One site per run (artifacts of the current release): a pre-release would replace the stable manifests.
- The update entity in every build: a tablet signed with another key would be offered updates it refuses.
Consequences¶
- Axel sets the secret once, in the
publicationenvironment since 2026-10-01 (gh secret set TAB5_CLE_SIGNATURE --env publication < C:\Users\axell\.tab5\tab5_signature.pem, fromcmd: PowerShell has no<); an AI never types a key. Pages must be enabled with the « GitHub Actions » source. - A lost or leaked key: the secret and the digest in the workflow change together, and published tablets can only be flashed again over USB (ADR-0020).
- Relaunching the workflow for a tag rebuilds and replaces its files (
--clobber); relaunching it for any tag republishes the page with the currentweb/. - The download is protected by the signature, not by HTTPS (
verify_ssl: false). - Only the ST7123 has been tried on a device; the page says so for the other two revisions.
Amendment (2026-09-28): a showcase page, the installer under install/, a site deployed on its own¶
The site was only the installer, and only publication.yml deployed it: fixing the page meant compiling the three firmwares again and replacing the release files. Search engines did not index the project's pictures either. Checked on 2026-09-28 (curl): github.com serves the images of a README as /<owner>/<repo>/raw/main/…, and its robots.txt has Disallow: /*/raw/ for every crawler; axellum.github.io has no robots.txt, so the Pages site is the one place where they can be indexed.
web/index.htmlis a showcase page (French and English, photos with captions and alt texts, the host renders of ADR-0021,canonical, Open Graph, JSON-LDSoftwareSourceCode); the installer moves toweb/install/index.htmland reads../versions.jsonand../<channel>/<revision>/manifest.json. The channel folders stay at the root of the site: published firmwares keep reading their updates at the same address. (2026-10-05: the showcase became a redirect to the documentation's home page, the README — ADR-0030.)- Pictures under a descriptive name:
tools/publication/pages.pycopies the files ofdocs/images/listed inIMAGEStoimages/(m5stack-tab5-…); the repository keeps its own names (README, docs, press kit). It also writessitemap.xml: every page, with the images it shows. .github/workflows/site.ymldeploys the site without compiling anything: called bypublication.ymlonce the files are attached to a release, on every push tomainthat touchesweb/,docs/images/orpages.py, or by hand. Relaunchingpublication.ymlfor a tag still rebuilds and replaces its files; it is no longer needed to update the page.- A channel only serves a release whose files are attached (#221 and its follow-up):
site.ymllists the releases through the REST API (gh api …/releases, which gives their files and their upload state;gh release listdoes not), andpages.py choisirskips a release without the ST7123 manifest, or with a manifest but not yet its two binaries; the previous release is served meanwhile.publication.ymlattaches the files after compiling (v3.1.0: published 11:29 UTC, files 11:41), and a push tomainin between failed with « no assets to download » (2026-09-28). Another missing variant does not skip a release: one added toVARIANTESlater has no files in the older releases (the Waveshare 7B, from 3.9.0-rc.6). - One Pages deployment per commit:
actions/deploy-pagesgives the commit aspages_build_version, and the API rejects a version that is not a commit (404, tried in #231). On 2026-09-28, v3.2.0-rc.1 targeted the commit a push tomainhad just deployed: its deployment reported success, and the site kept serving the push's build (beta channel still on 3.1.0).site.ymlnow fails before deploying a commit that already has a successful deployment; publish the release on a new commit, or deploy from a new commit ofmain. tests/test_publication.pychecks that every picture a page shows is listed inIMAGESand exists, that each page has a title, a description, its canonical address, a sharing image and alt texts, and thatsite.ymlcompiles nothing.
Amendment (2026-10-01): the key in a protected environment¶
The audit of 2026-09-30 (S2) found the signing key in a repository secret: a workflow of any branch could read it.
- The
firmwarejob ofpublication.ymlruns in thepublicationenvironment, the only place that holdsTAB5_CLE_SIGNATURE. The environment deploys only frommain(a run started by hand) and from tagsv*(a published release), and waits for Axel's approval (« Review deployments »), administrators included. The repository secret is removed. - Tags
v*can no longer be deleted or moved (repository ruleset « Tags de version immuables », checked on 2026-10-01 with a throwaway tag: creation accepted, force-push and deletion refused). A release built from the wrong commit gets a new version number; relaunching the workflow for the same tag still works. tests/test_publication.pyfails if another job or another workflow reads the key.- A release now waits for one approval in Actions before anything is compiled.
Amendment (2026-10-19): another board, the Waveshare ESP32-P4 7B¶
From 3.9.0-rc.6 the 7B (ADR-0061) is published with every release, so it installs from the browser like a Tab5.
- A variant is a display revision of the Tab5 (
tab5_ecran) or another board (tab5_carte).tools/publication/preparer.pylists them (ECRANS,CARTES,VARIANTES), and so dopages.py, theECRANS_TAB5/CARTESlines ofpublication.ymland the radio buttons of the install page;tests/test_publication.pykeeps them equal, in the same order. Files and site folders are named after the variant:tab5-ha-hmi-waveshare-7b.*,stable/waveshare-7b/,beta/waveshare-7b/. - The build matrix is computed by the
preparationjob (matrice): each display revision, then each board whoseTab5/paquets/carte-<board>.yamlexists at the tag. An older tag republished by hand keeps its three revisions instead of failing on a board it does not know;elf_seulementkeeps only the variants whose image the release carries.preparer.pynow comes from the workflow's commit, likememe_code.py. - ESPHome per variant: the Tab5 keeps
ESPHOME_PUBLICATION(2026.9.0, its binaries unchanged: the published configuration of a Tab5 is the same before and after, compared as a structure, since the text ofesphome configchanges key order from one run to the next); the boards takeESPHOME_PUBLICATION_CARTES(2026.9.1, the floor ofTab5/ecran/carte_7b_version.h). - Updates read the variant's folder:
publication-commune.yamlbuilds the manifest path fromtab5_cartewhen it is nottab5, else fromtab5_ecran. A 7B must never read a Tab5 folder: it would be offered the Tab5 firmware, signed with the same key, so accepted, and made for another display (never tried, nor needed). - The install page says when the chosen channel has no firmware for the chosen device (the 7B in a stable published before it) and hides its button.