From ceb689385ae6897cbf34bfd8d1d37a5e5ea6ad29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:36:01 +0000 Subject: [PATCH 1/5] SD boot: feed the WDT through the track-directory scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanTrackDir() opens and ArduinoJson-parses every file under /TRACKS and /TRACKS/SPRINT (up to MAX_LOCATIONS, 8 KB each at the 2 MHz SPI clock) inside one call. setup() only pets before and after buildTrackList(), so on a soft-reset boot — where the WDT carried over from the previous session is already counting (#163) — a large track set or a slow card could outlast the ~4 s deadline and reset the device mid-SD-read: the exact card wedge #163 exists to prevent. The same walk re-runs after a BLE track upload/delete with the WDT armed. Feed it per directory entry. Audit of the other SD walks reachable at boot or under the armed WDT: - ensureDefaultSettings(): one full file round trip per key (~25) inside SETTINGS_SETUP() — now fed per key. - buildReplayFileList(): capped by .dovex files FOUND, not entries scanned, so unbounded over a busy root — now fed per entry. - fwCrcOfStageFile(): reads back up to a 408 KiB image in 512 B blocks in one loop() iteration — now fed per block. - BLE LIST/TLIST already feed per entry; parseTrackFile, the course creator write and sdPlanSprintPrune are a single bounded file each. wdtPet() is a plain register write, a no-op before the WDT runs, and is already prototyped for the sim TU (sim_prototypes.h). No host test: the pets are Arduino glue with nothing pure to extract. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw --- ARCHITECTURE.md | 3 ++- BirdsEye/firmware_ota.ino | 3 +++ BirdsEye/replay.ino | 3 +++ BirdsEye/sd_functions.ino | 7 +++++++ BirdsEye/settings.ino | 4 ++++ CHANGELOG.md | 9 +++++++++ CLAUDE.md | 6 +++++- 7 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index efa339a..fd93905 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -270,7 +270,8 @@ the last resort. That watchdog **survives a soft reset** (only a pin, brown-out, power-on or System OFF reset clears it), so every firmware-initiated reboot hands the next boot a running WDT: `setup()` checks for one first thing (`wdtBootCheck()`) and feeds it between the -slow boot steps, and `wdtSetup()` leaves a running one alone — its +slow boot steps (and per entry inside the SD walks that scale with the +card's contents, like the track-directory scan), and `wdtSetup()` leaves a running one alone — its registers are locked. ### Shutdown is System OFF, wake is a reboot diff --git a/BirdsEye/firmware_ota.ino b/BirdsEye/firmware_ota.ino index 93f6ee0..5b3ae10 100644 --- a/BirdsEye/firmware_ota.ino +++ b/BirdsEye/firmware_ota.ino @@ -404,6 +404,9 @@ static bool fwCrcOfStageFile(uint32_t& outCrc) { uint32_t state = crc32::kInit; uint8_t buf[512]; while (true) { + // Up to a 408 KiB image read back in 512 B blocks inside one loop() + // iteration — seconds at SD speeds, so feed the ~4 s WDT per block. + wdtPet(); int r = f.read(buf, sizeof(buf)); if (r < 0) { f.close(); return false; } if (r == 0) break; diff --git a/BirdsEye/replay.ino b/BirdsEye/replay.ino index 2338d9b..02bd7b7 100644 --- a/BirdsEye/replay.ino +++ b/BirdsEye/replay.ino @@ -75,6 +75,9 @@ bool buildReplayFileList() { int filesScanned = 0; while (numReplayFiles < MAX_REPLAY_FILES) { + // The walk is capped by .dovex files FOUND, not entries scanned, so a + // root full of other files is unbounded — feed the WDT per entry. + wdtPet(); File32 entry = root.openNextFile(); if (!entry) { debug(F("Replay: openNextFile returned null after ")); diff --git a/BirdsEye/sd_functions.ino b/BirdsEye/sd_functions.ino index f9867eb..5803e6d 100644 --- a/BirdsEye/sd_functions.ino +++ b/BirdsEye/sd_functions.ino @@ -390,6 +390,13 @@ bool scanTrackDir(const char* folder, uint8_t kind) { // Loop through each file in the directory while (file.openNext(&trackDir, O_READ)) { + // Fed per entry: up to MAX_LOCATIONS files, each an 8 KB read + a full + // ArduinoJson parse at the 2 MHz SPI clock, all inside one call. At boot + // a WDT carried over a soft reset is already counting (see + // wdtBootCheck), and after a BLE track upload/delete the armed ~4 s WDT + // is — a big track set or a slow card outlasts it, and the reset lands + // mid-SD-read, which is exactly how a card gets wedged. + wdtPet(); if (numOfLocations >= MAX_LOCATIONS) { file.close(); break; diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index dec46f0..9fa2d56 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -216,6 +216,10 @@ static void ensureDefaultSettings() { char buf[48]; for (int i = 0; i < (int)(sizeof(defaults) / sizeof(defaults[0])); i++) { + // Every key is a full open + read + parse of the file (and a + // read-modify-write when missing) — ~25 round trips at 2 MHz inside + // SETTINGS_SETUP(), with a WDT carried over a soft reset counting. + wdtPet(); if (!getSetting(defaults[i].key, buf, sizeof(buf))) { setSetting(defaults[i].key, defaults[i].defaultValue); } diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cd041..f78d0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,15 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 (FAULT, no erase offer), and a volume that mounts on the retry boots normally. The recovered-on-probe path also now records the active SPI clock it had been leaving at 0. +- **The long SD walks feed the watchdog.** Building the track list opens + and JSON-parses every file under `/TRACKS` and `/TRACKS/SPRINT` (up to + 200, 8 KB each, at the 2 MHz SPI clock) in one call; it ran unfed, so + a large track set or a slow card on a soft-reset boot — or right after + a BLE track upload/delete — could outlast the ~4 s WDT and reset the + device mid-SD-read, the same wedge the carried-over-WDT fix above + closes. The track scan now feeds it per file, as do the boot settings + default check (one file round trip per key), the replay file browser's + root walk, and the OTA staged-image CRC read-back. ### Added - **SensorEgg GATT link** (plan 0018): a paired egg is no longer just diff --git a/CLAUDE.md b/CLAUDE.md index 513d644..51d77e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1000,7 +1000,11 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. `captureBootWakeCause()`): if `NRF_WDT->RUNSTATUS` says running it pets and sets `wdtCarriedOver`; `setup()` pets between every slow step (display delays, each `SD.begin()` attempt, the probe settle, - GPS probe, camera/egg/strip init) — no-ops on a clean boot; and + GPS probe, camera/egg/strip init) — no-ops on a clean boot — and + **inside every unbounded SD walk** (the track-directory scan, the + settings default check, the replay browser's root walk, BLE + `LIST`/`TLIST`, the OTA read-back), which run at boot or under the + armed WDT and scale with what is on the card; and `wdtSetup()` skips the (locked) configuration when one is already running. A boot's cause is on the debug line (`Boot cause: SRST`) and the SD format / FAULT pages. From b3fa6592c5ad16ccd1ebbff7a46944ac70e9e90e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:36:27 +0000 Subject: [PATCH 2/5] Debug boot: feed the WDT while waiting for the serial terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAS_DEBUG builds block in setup() on `while (!Serial);` right after the single wdtBootCheck() pet. After a soft reset the WDT carried over from the previous session is already counting, so with no terminal attached the wait outlived the ~4 s deadline and the device reset — into the same wait, forever. Feed it inside the wait. No other unbounded busy-wait sits on the setup() path (the NVMC READY spins in the one-time UICR write are microseconds, and setup() pets around that step already). Developer builds only; nothing shipped defines HAS_DEBUG. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw --- BirdsEye/BirdsEye.ino | 5 ++++- CHANGELOG.md | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 9d23a6c..377a91c 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -1049,7 +1049,10 @@ void setup() { #ifdef HAS_DEBUG Serial.begin(9600); - while (!Serial); + // Fed while waiting: after a soft reset the carried-over WDT is counting, + // and with no terminal attached this wait never ends — unfed, it reset + // the device every ~4 s forever. + while (!Serial) wdtPet(); #endif wdtPet(); debug(F("Boot cause: ")); diff --git a/CHANGELOG.md b/CHANGELOG.md index f78d0a4..2cc2476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,12 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 closes. The track scan now feeds it per file, as do the boot settings default check (one file round trip per key), the replay file browser's root walk, and the OTA staged-image CRC read-back. +- **`HAS_DEBUG` builds no longer boot-loop without a serial terminal + after a soft reset.** The `while (!Serial)` wait at the top of + `setup()` never fed the carried-over watchdog, so with nothing + attached to the USB port it reset the device every ~4 s. It now feeds + the WDT while it waits. Developer builds only — shipped images don't + define `HAS_DEBUG`. ### Added - **SensorEgg GATT link** (plan 0018): a paired egg is no longer just From 38f83b3204cfba89ce19f8da52cdcd911bf10ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:37:36 +0000 Subject: [PATCH 3/5] CI docs: correct the stale DovesLapTimer-pin notes compile-sketch.yml said a master/release build needed "a DovesLapTimer release tag carrying CrossingEngine/SprintTimer" before BETA could be promoted, and justified the flags-off arm's BETA library ref with "pinning the release tag would fail on BETA-era library APIs". Both stopped being true with v4.3.0: its src/ is byte-identical to the library's BETA branch (git diff v4.3.0 origin/BETA -- src is empty). CLAUDE.md's Simulator table also said the sim's CMakeLists pins DovesLapTimer BETA "(matches CI channel)", but it defaults DOVESLAPTIMER_REF to v4.3.0 and sim-build.yml overrides it to BETA only for BETA-targeted builds. Stale pin notes are how the wrong library gets shipped, so reword them to what is true and name every place the pin lives. Comments/docs only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw --- .github/workflows/compile-sketch.yml | 19 +++++++++---------- BirdsEye/sim/CMakeLists.txt | 2 +- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index d3cc6ad..a366721 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -22,13 +22,13 @@ jobs: # it compiled BETA code against master's pins and # failed on the sprint headers forever. # ref_name == BETA -> push / workflow_dispatch on BETA itself - # Anything else is master/release. NOTE: merging BETA into master is - # what promotes the channel — at that point the fallbacks below must be - # bumped deliberately (a DovesLapTimer release tag carrying - # CrossingEngine/SprintTimer, plus a decision on the SensorEgg flag), - # here and in sim-build.yml / release.yml. (The condition is repeated - # rather than hoisted into its own env var because a job-level `env:` - # entry cannot reference its siblings.) + # Anything else is master/release. The release pin (v4.3.0) already + # carries CrossingEngine/SprintTimer, so BETA source builds against it + # too; bump it deliberately — here and in sim-build.yml / release.yml / + # BirdsEye/sim/CMakeLists.txt — only when the firmware starts needing + # library code newer than the tag. (The condition is repeated rather + # than hoisted into its own env var because a job-level `env:` entry + # cannot reference its siblings.) # DovesLapTimer ref: the beta channel tracks the library's own BETA # branch so the two move together; everything else pins the known-good # release tag. @@ -211,9 +211,8 @@ jobs: # # One board is enough: the two arms here differ only in feature flags, # and the sense/non-sense split is already covered above. - # LAPTIMER_REF deliberately stays BETA so the flags are the ONLY - # variable — pinning the release tag as well would fail on BETA-era - # library APIs and tell us nothing about the flags. + # The library ref stays BETA so the flags are the ONLY variable + # between this job and the flags-on build above. compile-stock-arm: name: xiao-nrf52840-sense (flags off) if: >- diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 5c65556..4b9fbd3 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -35,7 +35,7 @@ include(FetchContent) # validates different code than ships — and can fail to link outright when the # branch gains sources this file doesn't list. That is exactly what happened # when the library's BETA gained CrossingEngine.cpp while master's sim was -# hardcoded to BETA: master's firmware pins v4.2.0, its sim tracked BETA. +# hardcoded to BETA: master's firmware then pinned v4.2.0, its sim tracked BETA. # # So: default to the stable tag (master/release policy) and let CI override # per channel, mirroring LAPTIMER_REF in compile-sketch.yml. sim-build.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc2476..f9bc4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,15 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 the run ETs — same backwards-compatible trailing-column scheme as SPRINT). The run state machine is the host-tested `drag_timer` unit. +### Changed +- **CI: stale DovesLapTimer-pin notes corrected.** `compile-sketch.yml` + claimed the release pin could not compile BETA source (no tag carrying + `CrossingEngine`/`SprintTimer`), and CLAUDE.md said the sim pins the + library's `BETA` branch. Neither is true any more: `v4.3.0`'s `src/` is + identical to the library's `BETA`, and the sim defaults to `v4.3.0`, + overridden to `BETA` only for BETA-targeted builds. Comments and docs + only — no build behavior changes. + ## [4.1.0] - 2026-08-24 MINOR — new settings and device behaviour, backwards compatible with the diff --git a/CLAUDE.md b/CLAUDE.md index 51d77e1..8d162fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,7 +192,7 @@ handoff spec. | `wasm/birdseye-sim.mjs` | Hand-written public ESM wrapper (stable import; async `reset()` re-instantiates the core module) | | `wasm/test.html` | Standalone browser harness: canvas blit (hash dirty-check), buttons, dovex file playback (≥13 columns — 4.0.0 logs carry 16), synthetic GPS-fix toggle + mph field (parked on the OKC asset track, 40 ms inject/step interleave) so fix-gated flows like the course creator are reachable | | `wasm/smoke.mjs` | Node smoke test the wasm CI job runs (boot→menu, state/version/VFS, determinism across instances, reset) | -| `CMakeLists.txt` | Native build; FetchContent pins: DovesLapTimer `BETA` (matches CI channel), SparkFun GNSS v3.1.9 (header-only use), ArduinoJson v6.21.5, ArxTypeTraits v0.3.2, Adafruit GFX 1.12.6 + SH110X 2.1.14 (real display stack) | +| `CMakeLists.txt` | Native build; FetchContent pins: DovesLapTimer `DOVESLAPTIMER_REF` (default `v4.3.0`, the release pin; `sim-build.yml` overrides it to `BETA` for BETA-targeted builds, mirroring the firmware's CI channel), SparkFun GNSS v3.1.9 (header-only use), ArduinoJson v6.21.5, ArxTypeTraits v0.3.2, Adafruit GFX 1.12.6 + SH110X 2.1.14 (real display stack) | ### Non-Source From faa3b4ba69da01ceeb9f1ae8265fea53309154d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:38:23 +0000 Subject: [PATCH 4/5] CI: compile the exact release configuration on the BETA -> master PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release PR's CI never built what release.yml ships. Its `compile` job correctly follows the SOURCE channel (BETA library + beta flags), and the flags-off `compile-stock-arm` job linked the library's BETA branch and built the Sense board only. So BETA source met the pinned release library (v4.3.0) — and the non-Sense board met the flags-off configuration — for the first time on the tag push, after the merge. compile-stock-arm is now a matrix over both boards, and on the release PR (head_ref == BETA) it links the release pin instead of BETA, with exactly release.yml's extra_flags. On feature PRs into BETA and BETA pushes it keeps the BETA library so the flags stay the only variable. The Sense job keeps its old check name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw --- .github/workflows/compile-sketch.yml | 42 ++++++++++++++++++++++------ CHANGELOG.md | 7 +++++ CLAUDE.md | 2 +- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/compile-sketch.yml b/.github/workflows/compile-sketch.yml index a366721..1b02c02 100644 --- a/.github/workflows/compile-sketch.yml +++ b/.github/workflows/compile-sketch.yml @@ -209,16 +209,38 @@ jobs: # several (the DOVEX row/header shapes and the settings defaults now # differ by channel). This job closes that hole from the BETA side. # - # One board is enough: the two arms here differ only in feature flags, - # and the sense/non-sense split is already covered above. - # The library ref stays BETA so the flags are the ONLY variable - # between this job and the flags-on build above. + # Both boards: this arm is also the RELEASE configuration (project.h + # defaults, no feature flags), and release.yml ships both images — the + # non-Sense board has its own `#if` paths (no IMU) that must compile + # with the flags off too. + # + # Library ref: + # head_ref == BETA (the BETA -> master release PR): the release pin, + # so this job compiles exactly what release.yml will build — release + # library + no flags, both boards. Without it the release PR never + # compiled BETA source against the pinned library at all, and a + # library dependency newer than the tag first failed on the tag push. + # otherwise (feature PRs into BETA, pushes to BETA): BETA, so the flags + # are the ONLY variable between this job and the flags-on build above. + # Keep the pin in sync with LAPTIMER_REF above and with release.yml. compile-stock-arm: - name: xiao-nrf52840-sense (flags off) + name: ${{ matrix.board.name }} (flags off) if: >- github.base_ref == 'BETA' || github.head_ref == 'BETA' || github.ref_name == 'BETA' runs-on: ubuntu-latest + env: + STOCK_LAPTIMER_REF: ${{ github.head_ref == 'BETA' && 'v4.3.0' || 'BETA' }} + strategy: + fail-fast: false + matrix: + board: + - name: xiao-nrf52840-sense + fqbn: Seeeduino:nrf52:xiaonRF52840Sense + define: BIRDSEYE_BOARD_SENSE + - name: xiao-nrf52840 + fqbn: Seeeduino:nrf52:xiaonRF52840 + define: BIRDSEYE_BOARD_NONSENSE steps: - name: Checkout uses: actions/checkout@v4 @@ -226,10 +248,13 @@ jobs: - name: Install adafruit-nrfutil run: pip install --user adafruit-nrfutil + - name: Report configuration + run: echo "Flags off, DovesLapTimer ref ${STOCK_LAPTIMER_REF}, board ${{ matrix.board.fqbn }}" + - name: Compile sketch with the feature flags off uses: arduino/compile-sketches@v1 with: - fqbn: Seeeduino:nrf52:xiaonRF52840Sense + fqbn: ${{ matrix.board.fqbn }} platforms: | - name: Seeeduino:nrf52 source-url: https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json @@ -244,9 +269,10 @@ jobs: - name: ArxTypeTraits - name: Adafruit NeoPixel - source-url: https://github.com/TheAngryRaven/DovesLapTimer.git - version: BETA + version: ${{ env.STOCK_LAPTIMER_REF }} sketch-paths: | - BirdsEye + # Exactly release.yml's extra_flags — nothing else. cli-compile-flags: | - --build-property - - compiler.cpp.extra_flags=-DBIRDSEYE_BOARD_SENSE -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG + - compiler.cpp.extra_flags=-D${{ matrix.board.define }} -DSERIAL_BUFFER_SIZE=256 -DDOVES_DISABLE_DEBUG diff --git a/CHANGELOG.md b/CHANGELOG.md index f9bc4a2..a0a6c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,13 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 identical to the library's `BETA`, and the sim defaults to `v4.3.0`, overridden to `BETA` only for BETA-targeted builds. Comments and docs only — no build behavior changes. +- **CI: the release PR now compiles the release configuration.** The + flags-off `compile-stock-arm` job builds both boards (it was Sense + only), and on the BETA → master PR it links the release library pin + (`v4.3.0`) instead of the library's `BETA` — so that PR compiles + exactly what `release.yml` will build (release library, no feature + flags, both boards). Before, BETA source met the pinned library and the + flags-off non-Sense image for the first time on the tag push. ## [4.1.0] - 2026-08-24 diff --git a/CLAUDE.md b/CLAUDE.md index 8d162fb..a7045ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,7 @@ handoff spec. | Path | Contents | |---|---| -| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off | +| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off. BETA-side PRs also run `compile-stock-arm` (flags off, both boards); on the BETA → master release PR it uses the `v4.3.0` pin, so that PR compiles exactly release.yml's configuration. | | `tests/` | Host doctest harness (CMake) for the pure-logic units | | `docs/plans/` | Numbered design records (`NNNN-slug.md`, see its README) — the rationale behind each chunk of work; plan-executing commits cite the number. Same convention as DovesDataViewer | | `CHANGELOG.md` | Keep-a-Changelog history; release workflow ties to version tags | From 5443b9ebc5ad65065caf024cb6eea2a08799e625 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:39:31 +0000 Subject: [PATCH 5/5] Release: refuse a tag that disagrees with FIRMWARE_VERSION release.yml derives the OTA manifest's version from the git tag, but the image reports FIRMWARE_VERSION from BirdsEye/project.h over BLE, and nothing tied the two together. Tagging v4.2.0 on a tree whose project.h still says "4.1.0" published a manifest no device could ever satisfy: every unit would install the update, still report 4.1.0, and be offered it again forever. Add a first step to the build job, tag builds only, that extracts the quoted literal from the non-override FIRMWARE_VERSION branch (asserting exactly one exists), fails unless it equals ${GITHUB_REF_NAME#v}, and also requires a `## [x.y.z]` CHANGELOG heading. It fails before the compile, and release/pages need `build`, so nothing is published. Manual (workflow_dispatch) runs have no tag and skip it. The version number itself is unchanged. Verified locally by running the step's script against v4.1.0 (pass), v4.2.0 and v4.0.0 (version mismatch), and a patched tree at 9.9.9 with no CHANGELOG heading (heading failure). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SdhRyfZ4eYuooXi9uy28aw --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++++++++ BirdsEye/project.h | 3 ++- CHANGELOG.md | 8 ++++++++ CLAUDE.md | 7 +++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91fb3ab..9c8d102 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,39 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # The OTA manifest's version comes from the TAG, but the device reports + # FIRMWARE_VERSION from project.h over BLE (DIS). If they disagree — + # tag v4.2.0 on a tree still saying "4.1.0" — the published image can + # never satisfy the manifest and the companion offers the update + # forever. Refuse the tag before anything is built or published (the + # release + pages jobs need `build`, so a failure here stops both). + # Also require a CHANGELOG heading for the version: the release notes + # link there. Manual runs have no tag to check and skip this. + - name: Verify tag matches FIRMWARE_VERSION and CHANGELOG + if: startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + # The literal in the non-override branch — the only + # `#define FIRMWARE_VERSION "..."` form in project.h (the override + # branch stringizes a token and never has a quoted literal). + matches="$(grep -cE '^[[:space:]]*#define[[:space:]]+FIRMWARE_VERSION[[:space:]]+"' BirdsEye/project.h || true)" + if [ "$matches" != "1" ]; then + echo "::error file=BirdsEye/project.h::Expected exactly one quoted FIRMWARE_VERSION literal, found ${matches}" + exit 1 + fi + fw_version="$(sed -nE 's/^[[:space:]]*#define[[:space:]]+FIRMWARE_VERSION[[:space:]]+"([^"]*)".*/\1/p' BirdsEye/project.h)" + echo "Tag: ${GITHUB_REF_NAME} -> ${tag_version}; project.h FIRMWARE_VERSION: ${fw_version}" + if [ "$fw_version" != "$tag_version" ]; then + echo "::error file=BirdsEye/project.h::FIRMWARE_VERSION is \"${fw_version}\" but the tag is ${GITHUB_REF_NAME} — bump project.h (or re-tag) so the image reports the version the OTA manifest will advertise" + exit 1 + fi + if ! awk -v h="## [${tag_version}]" 'index($0, h) == 1 { found = 1 } END { exit !found }' CHANGELOG.md; then + echo "::error file=CHANGELOG.md::No '## [${tag_version}]' heading — date the release section before tagging" + exit 1 + fi + echo "Version check passed." + - name: Set up arduino-cli uses: arduino/setup-arduino-cli@v2 diff --git a/BirdsEye/project.h b/BirdsEye/project.h index 4d7e4f1..c53f34c 100644 --- a/BirdsEye/project.h +++ b/BirdsEye/project.h @@ -18,7 +18,8 @@ // via the Device Information Service (see bluetooth.ino) so a companion // (DovesDataViewer) can compare it against the latest GitHub release and // decide whether an OTA update is needed. Keep this in sync with the release -// git tag (tag v2.0.0 -> "2.0.0") and the CHANGELOG. +// git tag (tag v2.0.0 -> "2.0.0") and the CHANGELOG — release.yml refuses a +// tag that disagrees with the quoted literal below. // // Beta/nightly builds override it from the build flag: the beta workflow // passes -DFIRMWARE_VERSION_OVERRIDE=-beta. as a bare token — diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a6c2d..f26b585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,14 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 exactly what `release.yml` will build (release library, no feature flags, both boards). Before, BETA source met the pinned library and the flags-off non-Sense image for the first time on the tag push. +- **CI: a release tag must match the firmware's own version.** The OTA + manifest's version is taken from the git tag, but devices report + `FIRMWARE_VERSION` from `project.h` — tagging `v4.2.0` on a tree still + saying `4.1.0` published a manifest no updated device could ever + satisfy, so the companion app would offer the update forever. + `release.yml` now fails the tag build before anything is built or + published unless the tag equals `FIRMWARE_VERSION` and `CHANGELOG.md` + has a `## [x.y.z]` heading for it. ## [4.1.0] - 2026-08-24 diff --git a/CLAUDE.md b/CLAUDE.md index a7045ee..ace6696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,7 @@ handoff spec. | Path | Contents | |---|---| -| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off. BETA-side PRs also run `compile-stock-arm` (flags off, both boards); on the BETA → master release PR it uses the `v4.3.0` pin, so that PR compiles exactly release.yml's configuration. | +| `.github/workflows/` | CI: compile-sketch (+ flash-size gate), arduino-lint, unit-tests, clang-tidy, coverage, sim-build (native sim TU + 60 s boot soak + determinism + goldens + lap oracles + two-session carryover, plus a wasm job: emsdk 3.1.61 build + node smoke + `birdseye-sim-wasm` artifact), release (dual-board build + GitHub Release + prod OTA manifest to `gh-pages`), beta (dual-board build on `BETA`-branch push → latest-only `beta/` OTA channel on `gh-pages`, no Release). Per-channel build config: `BETA` builds track DovesLapTimer's `BETA` branch and pass `-DBIRDSEYE_ENABLE_SENSOREGG=1`; master/release pin `v4.3.0` and build the `project.h` defaults — which since 4.1.0 means NeoPixel ON, SensorEgg off. BETA-side PRs also run `compile-stock-arm` (flags off, both boards); on the BETA → master release PR it uses the `v4.3.0` pin, so that PR compiles exactly release.yml's configuration. `release.yml` refuses a tag whose version doesn't equal `FIRMWARE_VERSION` in `project.h` or has no `## [x.y.z]` CHANGELOG heading | | `tests/` | Host doctest harness (CMake) for the pure-logic units | | `docs/plans/` | Numbered design records (`NNNN-slug.md`, see its README) — the rationale behind each chunk of work; plan-executing commits cite the number. Same convention as DovesDataViewer | | `CHANGELOG.md` | Keep-a-Changelog history; release workflow ties to version tags | @@ -2196,7 +2196,10 @@ This device operates in ignition-noise environments. Three layers of defense: BLE; a plain IDE build with no flag defaults to `sense`. - **Firmware version** is a single `#define FIRMWARE_VERSION` in `project.h`. Keep it in sync with the release git tag (`v2.0.0` -> `"2.0.0"`); it is - reported over BLE (DIS) for the OTA update check. `FIRMWARE_VARIANT` + reported over BLE (DIS) for the OTA update check, and `release.yml` + fails a tag build whose tag doesn't match it (or whose version has no + CHANGELOG heading) — the manifest version comes from the tag, so a + mismatch would offer the update forever. `FIRMWARE_VARIANT` (also in `project.h`) feeds the DIS model string. The version literal can be overridden at build time with `-DFIRMWARE_VERSION_OVERRIDE=` (a bare token; `project.h` stringizes it) — the `beta` workflow uses this to stamp