Skip to content

feat: migrate away from Arduino - #438

Open
hhvrc wants to merge 127 commits into
developfrom
feat/arduino-3.0
Open

feat: migrate away from Arduino#438
hhvrc wants to merge 127 commits into
developfrom
feat/arduino-3.0

Conversation

@hhvrc

@hhvrc hhvrc commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the firmware on native ESP-IDF 6. The Arduino framework, the PlatformIO build and the CDN deployment are all gone; what replaces them is an idf.py build over a layered component tree, a security-verified TLS path, host-runnable tests for the pure logic, and a CI that deploys through the repository server.

The branch started as an Arduino 3.0 bump and turned into the removal of Arduino instead. It is large because there is no partial version of this: the framework provided WiFi, HTTP, WebSockets, RMT, the filesystem, the serial console and the logging sink, and each had to be replaced before the framework could go.

Build

  • PlatformIO -> native ESP-IDF. scripts/build.py drives idf.py; per-board sdkconfig fragments live in boards/*.defaults and layer onto sdkconfig.defaults. platformio.ini, the .env files and the extra_scripts are gone. flatbuffers is vendored as a submodule component.
  • Board config comes from Kconfig. GPIO pins, API/CDN domains and buffer sizes read CONFIG_OPENSHOCK_* directly rather than -D flags. This surfaced a real bug: the WS2812B R/G channel swap tested an un-prefixed macro the native build never defines, so it silently never triggered on Waveshare S3-Zero or Wemos S3-Mini.
  • Slimmer dependency set. Dropping arduino-esp32 also drops the managed components it pulled in regardless of use - esp-dsp, esp-sr, esp-modbus, rainmaker, zigbee, and the rest.

Subsystems moved off Arduino

Subsystem Was Now
WiFi / scan / SoftAP WiFi class, arduino_event_t esp_wifi + esp_netif + esp_event
HTTP client HTTPClient / WiFiClientSecure esp_http_client, reusable keep-alive HTTP::Client
Gateway client WebSocketsClient esp_websocket_client with manual fragment reassembly
Captive portal ESPAsyncWebServer + WebSocketsServer + Arduino LittleFS esp_http_server + native DNS responder + StaticFs
RF + LED drivers esp32-hal-rmt driver/rmt_tx
Filesystem Arduino LittleFS fs component: LfsPartition / StaticFs / ConfigFs over raw esp_partition
Serial + logging Serial, log_printf dedicated serial transport (UART0 / USB-Serial-JTAG); logging writes bytes to it
Hashing private mbedTLS per-algorithm APIs public mbedtls/md.h generic interface
JSON cJSON osjson (jsmn zero-copy parse + streaming generation)

Structure

main/ now holds only app_main. Everything else is an auto-discovered component, and the interconnected tangle has been progressively pulled apart into layers that depend one way:

common, crypto, logging, temporal, osjson, events, hwutil, chipset, littlefs, fs, flatbuffers, serialization, config, protocols, led_drivers, http, dns_server, rfc8908, serial, device_control, core, serial_console.

The extractions that mattered: generated flatbuffer schemas out of core (which broke the config<->serialization cycle), then config, then device_control (estop, RF transmitter, visual state, command handler). Leaky internal headers - CaptivePortalInstance.h, GatewayClient.h, the message-handler internals - moved out of include/ into src/ so esp_http_server, esp_websocket_client and friends stopped propagating to consumers.

Security

  • TLS certificates are verified. CONFIG_ESP_TLS_INSECURE and SKIP_SERVER_CERT_VERIFY were set, leaving the gateway WebSocket and the HTTP/OTA clients open to MITM. The firmware now verifies against a compiled-in bundle, with GlobalSign Root CA R1 pinned - api.openshock.app presents a chain topped by the cross-signed GTS Root R4, whose issuer Mozilla/curl retired, and esp_crt_bundle does no path building. A weekly pinned-cert-audit workflow retires pins no live chain still needs and warns before a needed one expires. Costs ~16 KB flash.
  • The captive-portal DNS responder is hardened. A crafted query could overflow the response buffer and transmit adjacent memory back to the sender - reachable by any client associated with the setup AP. The responder now bounds the reply before building it, rejects responses, non-standard and multi-question queries, compressed and over-long names, and parses the AP address with inet_pton instead of a scanf of four ints.
  • Task shutdown no longer touches freed memory. StopTask() polled eTaskGetState() on the handle of a task that had already called vTaskDelete(nullptr), reading a TCB the idle task was free to have freed - and could escalate to force-killing it. Tasks now publish a caller-owned exit flag; the signature change makes the old racy call impossible to write by accident, and all six tasks are migrated.
  • SemVer ordering was wrong for RC tags. Prerelease was parsed after the core split on ., so 1.5.0-rc.7 dropped the .7 - different RCs compared equal, which is OpenShock's own tag format and drove OTA ordering.

Tests

Host-runnable Unity suites on the linux target, for the pure-logic pockets that had no off-hardware coverage at all: the RF bit-encoders (frame layout decoded back from RMT symbols, intensity clamping, model dispatch), osjson, the enum parsers, SemVer, Convert, string/hex helpers, Checksum, TinyVec, FnProxy, DigitCounter.

CI/CD

  • Builds every board with scripts/build.py through install-esp-idf-action; the matrix is derived from boards/*.defaults by a Python port of get-vars. C++ and staticfs build in parallel and merge afterward, with all 13 boards merged in one parallel job.
  • Deployment is the repository server, and nothing else. cdn-deploy, cdn-bump, the four CDN composite actions and the cdn environment are gone along with the Bunny storage credential. What is left is the contract: mint an OIDC token, init the release, upload every board with its sha256 manifest, promote or discard. Where artifacts live, which host serves them and when a channel starts offering a version are the server's decisions behind one API call.
  • The release is opened before the build, so a duplicate version, a concurrent run or an unknown board is refused in seconds rather than after a half-hour matrix. Every build publishes to the development instance, so a branch build's artifacts are actually fetchable by a hub pointed at it.
  • Publishing rules moved out of YAML if: expressions into repo_server_policy.py; the scripts use requests and the semver package instead of hand-rolled multipart and regex, with dependencies pinned by digest and installed --require-hashes.
  • cpp-linter scans the tree rather than the diff API (GitHub returns 406 on a diff this size); clang-tidy is off until the compile DB is clang-toolchain compatible, and CodeQL still covers static analysis.

Change files

esp-idf-6-migration.md (breaking), dns-server-hardening.md, ci-repository-server-only-deploy.md.

Review

All Copilot findings from the earlier Arduino 3.0 pass are resolved or no longer applicable, each answered in thread.

Open item

-DARDUINO_USB_CDC_ON_BOOT=1 was dropped for Waveshare S3-Zero and Wemos Lolin S3-Mini and has no native replacement in their .defaults - neither board sets CONFIG_ESP_CONSOLE_*, so both fall back to the IDF default rather than USB-Serial-JTAG. Worth confirming the console comes up on hardware before merge.

nullstalgia and others added 23 commits January 9, 2026 03:14
I'd prefer to be able to distinguish between the different types easily in support chat.
Merge develop into feat/arduino-3.0. Replace remaining tcb::span with
std::span, add missing WiFi.h include. Build not yet clean — remaining
issues: Serial namespace rename (SerialCmds), USBSerial API, and
CaptivePortalConfig struct changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 09:06
hhvrc and others added 5 commits July 14, 2026 00:30
Replace the Arduino-style one-byte getter (nextSerialByte) with an
index-based scanner over the staging chunk. Serial::Read already batches
the transport read; only the parser was still consuming a byte at a time.

tryReadSerialLine now walks the chunk with a local index, handling control
bytes individually and bulk-appending runs of printable characters via a
new SerialBuffer::append. skipSerialWhitespaces scans the chunk directly as
well. Behavior is byte-identical to the previous implementation (verified
against the old parser across backspace/tab/control-byte/overflow cases).
# Conflicts:
#	.github/workflows/ci-build.yml
#	.github/workflows/codeql.yml
#	.github/workflows/cpp-linter.yml
#	.github/workflows/get-vars.yml
A crafted query could overflow the response buffer. The question end was
only bounded by the received length, so a 512-byte query whose question
filled the datagram left no room for the 16-byte answer: the memcpy that
appended it wrote past `resp`, and the following sendto transmitted those
bytes. Any client associated with the portal AP could trigger it.

Check that the reply fits before building it, and validate the query
before trusting it:

  - reject responses (QR set), which otherwise let two responders answer
    each other indefinitely and made us usable as a reflector
  - reject anything that isn't a single standard query (QDCOUNT, opcode)
  - reject compressed, reserved-type, and over-long names; a pointer in a
    question has nothing valid to point back at, and echoing one aimed the
    answer's own 0xC00C at a dangling chain
  - drop datagrams that exactly fill the buffer, since recvfrom truncates

Also parse the response address with inet_pton, which rejects the
out-of-range components and trailing garbage that a scanf of four ints
silently truncated, and answer with a 60 s TTL so clients stop re-querying
on every probe. The reply is now built in place, which frees the second
512-byte buffer from the task's 3 KB stack.

Errors are handled separately from timeouts so a persistently failing
socket can't spin the core, and the socket is closed after the task has
stopped rather than before. shutdown() is gone: lwIP rejects it with
EOPNOTSUPP on anything that isn't TCP, so the receive timeout was always
the real wakeup and is now mandatory rather than best-effort.
StopTask() waited for a task to self-delete by polling eTaskGetState() on
its handle, but that is exactly what a self-deleting task's handle cannot
be used for. vTaskDelete(nullptr) only queues the task:

    vListInsertEnd(&xTasksWaitingTermination, &(pxTCB->xStateListItem));
    ++uxDeletedTasksWaitingCleanUp;

after which the idle task frees the TCB. eTaskGetState() dereferences the
handle to read pxTCB->xStateListItem, so once that free has happened the
poll reads freed heap — and StopTask() reads it twice, in the loop
condition and again in the check after it, with the other core's idle task
free to run in between. If the freed memory doesn't happen to match
xTasksWaitingTermination or NULL, the state comes back as something other
than eDeleted and StopTask() escalates to vTaskDelete() on a freed TCB.

Only the task itself can signal its exit safely, so TaskUtils owns that
pattern now. TaskExiting() publishes a caller-owned flag and then deletes
the task; StopTask() takes the same flag and waits on it, and only touches
the handle when the flag is still clear — precisely the case where the task
provably has not deleted itself, so the force-kill path is sound.

The signature change is deliberate: it makes the old racy call impossible
to write by accident. All six tasks are migrated, each clearing its flag
before the task is created, and no raw vTaskDelete(nullptr) remains outside
TaskExiting().

The three public headers get a bare std::atomic<bool> rather than an
include of TaskUtils.h, and CommandHandler's flag is fully qualified since
it is declared above that file's using-directive.
Covers the user-visible half of the fix: the crash and memory disclosure a
crafted query could cause on the setup network, the stricter rules about
what the responder will answer, the non-zero answer TTL, and the stricter
validation of the setup network address.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 390 files, which is 290 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 21d431c5-d393-4102-a7da-7bac61678797

📥 Commits

Reviewing files that changed from the base of the PR and between 77d84b3 and f4229da.

⛔ Files ignored due to path filters (7)
  • .github/scripts/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • certificates/cacert-curl.pem is excluded by !**/*.pem
  • certificates/cacert-merged.pem is excluded by !**/*.pem
  • certificates/pinned_certs/GlobalSign_Root_CA_R1.pem is excluded by !**/*.pem
  • partitions/ota_4mb.csv is excluded by !**/*.csv
  • partitions/single_4mb.csv is excluded by !**/*.csv
  • scripts/flatc.exe is excluded by !**/*.exe
📒 Files selected for processing (390)
  • .changes/ci-repository-server-only-deploy.md
  • .changes/dns-server-hardening.md
  • .changes/esp-idf-6-migration.md
  • .claude/hooks/clang-format.sh
  • .claude/settings.json
  • .env
  • .env.development
  • .env.production
  • .gitattributes
  • .github/actions/build-compilationdb/action.yml
  • .github/actions/build-firmware/action.yml
  • .github/actions/build-frontend/action.yml
  • .github/actions/build-staticfs/action.yml
  • .github/actions/cdn-bump-version/action.yml
  • .github/actions/cdn-upload-firmware/action.yml
  • .github/actions/cdn-upload-version-info/action.yml
  • .github/actions/merge-partitions/action.yml
  • .github/actions/pio-cache/action.yml
  • .github/actions/repo-server-publish/action.yml
  • .github/actions/repo-server-token/action.yml
  • .github/actions/setup-esp-idf/action.yml
  • .github/actions/sftp-mirror/action.yml
  • .github/scripts/.gitignore
  • .github/scripts/.python-version
  • .github/scripts/get-vars.js
  • .github/scripts/get-vars.py
  • .github/scripts/gha.py
  • .github/scripts/package.json
  • .github/scripts/repo_server_policy.py
  • .github/scripts/repo_server_publish.py
  • .github/scripts/repo_server_token.py
  • .github/scripts/requirements.txt
  • .github/workflows/check-changes.yml
  • .github/workflows/ci-build.yml
  • .github/workflows/codeql.yml
  • .github/workflows/cpp-linter.yml
  • .github/workflows/get-vars.yml
  • .github/workflows/pinned-cert-audit.yml
  • .github/workflows/pr-check-comment.yml
  • .github/workflows/release.yml
  • .gitignore
  • .gitmodules
  • .mcp.json
  • .vscode/settings.json
  • CMakeLists.txt
  • FLATBUFFERS_CHANGES.md
  • boards/DFRobot-Firebeetle2-ESP32E.defaults
  • boards/NodeMCU-32S.defaults
  • boards/OpenShock-Core-V1.defaults
  • boards/OpenShock-Core-V2.defaults
  • boards/Pishock-2023.defaults
  • boards/Pishock-Lite-2021.defaults
  • boards/Seeed-Xiao-ESP32C3.defaults
  • boards/Seeed-Xiao-ESP32S3.defaults
  • boards/Waveshare_esp32_s3_zero.defaults
  • boards/Wemos-D1-Mini-ESP32.defaults
  • boards/Wemos-D1-Mini-ESP32.json
  • boards/Wemos-Lolin-S2-Mini.defaults
  • boards/Wemos-Lolin-S2-Mini.json
  • boards/Wemos-Lolin-S3-Mini.defaults
  • boards/Wemos-Lolin-S3.defaults
  • boards/Wemos-Lolin-S3.json
  • certificates/.gitignore
  • certificates/README.md
  • certificates/cert_bundle.py
  • certificates/gen_crt_bundle.py
  • certificates/x509_crt_bundle
  • chips/ESP32-C3/4MB/merge-image.py
  • chips/ESP32-S2/4MB/merge-image.py
  • chips/ESP32-S3/4MB/merge-image.py
  • chips/ESP32/4MB/merge-image.py
  • components/.gitignore
  • components/chipset/CMakeLists.txt
  • components/chipset/include/Chipset.h
  • components/chipset/src/CompatibilityChecks.cpp
  • components/common/CMakeLists.txt
  • components/common/host_test/CMakeLists.txt
  • components/common/host_test/main/CMakeLists.txt
  • components/common/host_test/main/stubs/Logging.h
  • components/common/host_test/main/test_checksum.cpp
  • components/common/host_test/main/test_convert.cpp
  • components/common/host_test/main/test_digitcounter.cpp
  • components/common/host_test/main/test_enums.cpp
  • components/common/host_test/main/test_fnproxy.cpp
  • components/common/host_test/main/test_hexutils.cpp
  • components/common/host_test/main/test_main.cpp
  • components/common/host_test/main/test_semver.cpp
  • components/common/host_test/main/test_stringhelpers.cpp
  • components/common/host_test/main/test_stringutils.cpp
  • components/common/host_test/main/test_tinyvec.cpp
  • components/common/host_test/sdkconfig.defaults
  • components/common/include/Checksum.h
  • components/common/include/Convert.h
  • components/common/include/FormatHelpers.h
  • components/common/include/OpenShock.h
  • components/common/include/RateLimiter.h
  • components/common/include/ReadWriteMutex.h
  • components/common/include/SemVer.h
  • components/common/include/SimpleMutex.h
  • components/common/include/StringHelpers.h
  • components/common/include/TinyVec.h
  • components/common/include/enums/AccountLinkResultCode.h
  • components/common/include/enums/FirmwareBootType.h
  • components/common/include/enums/GatewayClientState.h
  • components/common/include/enums/OtaUpdateChannel.h
  • components/common/include/enums/OtaUpdateStep.h
  • components/common/include/enums/SetGPIOResultCode.h
  • components/common/include/enums/ShockerCommandType.h
  • components/common/include/enums/ShockerModelType.h
  • components/common/include/enums/WebSocketMessageType.h
  • components/common/include/util/DigitCounter.h
  • components/common/include/util/FnProxy.h
  • components/common/include/util/HexUtils.h
  • components/common/include/util/TaskUtils.h
  • components/common/src/Convert.cpp
  • components/common/src/DigitCounter.cpp
  • components/common/src/RateLimiter.cpp
  • components/common/src/ReadWriteMutex.cpp
  • components/common/src/SemVer.cpp
  • components/common/src/SimpleMutex.cpp
  • components/common/src/StringHelpers.cpp
  • components/common/src/TaskUtils.cpp
  • components/common/src/Version.cpp
  • components/config/CMakeLists.txt
  • components/config/include/config/BackendConfig.h
  • components/config/include/config/CaptivePortalConfig.h
  • components/config/include/config/Config.h
  • components/config/include/config/ConfigBase.h
  • components/config/include/config/EStopConfig.h
  • components/config/include/config/OtaUpdateConfig.h
  • components/config/include/config/RFConfig.h
  • components/config/include/config/RootConfig.h
  • components/config/include/config/SerialInputConfig.h
  • components/config/include/config/WiFiConfig.h
  • components/config/include/config/WiFiCredentials.h
  • components/config/include/config/internal/utils.h
  • components/config/src/BackendConfig.cpp
  • components/config/src/CaptivePortalConfig.cpp
  • components/config/src/Config.cpp
  • components/config/src/EStopConfig.cpp
  • components/config/src/OtaUpdateConfig.cpp
  • components/config/src/RFConfig.cpp
  • components/config/src/RootConfig.cpp
  • components/config/src/SerialInputConfig.cpp
  • components/config/src/WiFiConfig.cpp
  • components/config/src/WiFiCredentials.cpp
  • components/config/src/internal/utils.cpp
  • components/core/CMakeLists.txt
  • components/core/Kconfig.projbuild
  • components/core/idf_component.yml
  • components/core/include/GatewayConnectionManager.h
  • components/core/include/OtaUpdateManager.h
  • components/core/include/captiveportal/Manager.h
  • components/core/include/http/JsonAPI.h
  • components/core/include/message_handlers/WebSocket.h
  • components/core/include/serialization/CallbackFn.h
  • components/core/include/serialization/JsonAPI.h
  • components/core/include/serialization/JsonSerial.h
  • components/core/include/serialization/WSGateway.h
  • components/core/include/serialization/WSLocal.h
  • components/core/include/wifi/WiFiManager.h
  • components/core/include/wifi/WiFiNetwork.h
  • components/core/include/wifi/WiFiScanManager.h
  • components/core/include/wifi/WiFiScanStatus.h
  • components/core/src/GatewayClient.cpp
  • components/core/src/GatewayClient.h
  • components/core/src/GatewayConnectionManager.cpp
  • components/core/src/OtaUpdateManager.cpp
  • components/core/src/captiveportal/CaptivePortalInstance.cpp
  • components/core/src/captiveportal/CaptivePortalInstance.h
  • components/core/src/captiveportal/Manager.cpp
  • components/core/src/http/JsonAPI.cpp
  • components/core/src/message_handlers/ShockerCommandList.cpp
  • components/core/src/message_handlers/ShockerCommandList.h
  • components/core/src/message_handlers/impl/WSGateway.h
  • components/core/src/message_handlers/impl/WSLocal.h
  • components/core/src/message_handlers/websocket/Gateway.cpp
  • components/core/src/message_handlers/websocket/Local.cpp
  • components/core/src/message_handlers/websocket/gateway/OtaUpdateRequest.cpp
  • components/core/src/message_handlers/websocket/gateway/Ping.cpp
  • components/core/src/message_handlers/websocket/gateway/ShockerCommandList.cpp
  • components/core/src/message_handlers/websocket/gateway/Trigger.cpp
  • components/core/src/message_handlers/websocket/gateway/_InvalidMessage.cpp
  • components/core/src/message_handlers/websocket/local/Common_ShockerCommandList.cpp
  • components/core/src/message_handlers/websocket/local/_InvalidMessage.cpp
  • components/core/src/serialization/JsonAPI.cpp
  • components/core/src/serialization/JsonSerial.cpp
  • components/core/src/serialization/WSGateway.cpp
  • components/core/src/serialization/WSLocal.cpp
  • components/core/src/wifi/WiFiManager.cpp
  • components/core/src/wifi/WiFiNetwork.cpp
  • components/core/src/wifi/WiFiScanManager.cpp
  • components/crypto/CMakeLists.txt
  • components/crypto/host_test/CMakeLists.txt
  • components/crypto/host_test/main/CMakeLists.txt
  • components/crypto/host_test/main/stubs/Logging.h
  • components/crypto/host_test/main/test_base64.cpp
  • components/crypto/host_test/main/test_main.cpp
  • components/crypto/host_test/sdkconfig.defaults
  • components/crypto/include/Base64.h
  • components/crypto/include/Hashing.h
  • components/crypto/src/Base64.cpp
  • components/device_control/CMakeLists.txt
  • components/device_control/include/CommandHandler.h
  • components/device_control/include/estop/EStopManager.h
  • components/device_control/include/estop/EStopState.h
  • components/device_control/include/radio/RFTransmitter.h
  • components/device_control/include/visual/VisualStateManager.h
  • components/device_control/src/CommandHandler.cpp
  • components/device_control/src/EStopManager.cpp
  • components/device_control/src/radio/RFTransmitter.cpp
  • components/device_control/src/visual/VisualStateManager.cpp
  • components/dns_server/CMakeLists.txt
  • components/dns_server/include/dns_server/DNSServer.h
  • components/dns_server/src/DNSServer.cpp
  • components/events/CMakeLists.txt
  • components/events/include/events/Events.h
  • components/events/src/Events.cpp
  • components/flatbuffers/CMakeLists.txt
  • components/flatbuffers/flatbuffers
  • components/fs/CMakeLists.txt
  • components/fs/include/fs/ConfigFs.h
  • components/fs/include/fs/FsCheck.h
  • components/fs/include/fs/LfsPartition.h
  • components/fs/include/fs/StaticFs.h
  • components/fs/src/FsCheck.cpp
  • components/fs/src/LfsPartition.cpp
  • components/http/CMakeLists.txt
  • components/http/include/http/ContentTypes.h
  • components/http/include/http/HTTPRequestManager.h
  • components/http/src/HTTPRequestManager.cpp
  • components/hwutil/CMakeLists.txt
  • components/hwutil/include/hwutil/PartitionUtils.h
  • components/hwutil/src/PartitionUtils.cpp
  • components/led_drivers/CMakeLists.txt
  • components/led_drivers/include/led_drivers/MonoLedDriver.h
  • components/led_drivers/include/led_drivers/RgbLedDriver.h
  • components/led_drivers/src/MonoLedDriver.cpp
  • components/led_drivers/src/RgbLedDriver.cpp
  • components/littlefs/CMakeLists.txt
  • components/littlefs/LICENSE.md
  • components/littlefs/include/lfs.h
  • components/littlefs/include/lfs_util.h
  • components/littlefs/src/lfs.c
  • components/littlefs/src/lfs_util.c
  • components/logging/CMakeLists.txt
  • components/logging/include/Logging.h
  • components/logging/src/Logging.cpp
  • components/osjson/CMakeLists.txt
  • components/osjson/host_test/CMakeLists.txt
  • components/osjson/host_test/main/CMakeLists.txt
  • components/osjson/host_test/main/test_generate.cpp
  • components/osjson/host_test/main/test_getters.cpp
  • components/osjson/host_test/main/test_json.cpp
  • components/osjson/host_test/main/test_navigation.cpp
  • components/osjson/host_test/main/test_parse.cpp
  • components/osjson/host_test/main/test_stress.cpp
  • components/osjson/host_test/sdkconfig.defaults
  • components/osjson/idf_component.yml
  • components/osjson/include/json/Json.h
  • components/osjson/src/Json.cpp
  • components/protocols/CMakeLists.txt
  • components/protocols/host_test/CMakeLists.txt
  • components/protocols/host_test/main/CMakeLists.txt
  • components/protocols/host_test/main/stubs/Logging.h
  • components/protocols/host_test/main/stubs/driver/rmt_types.h
  • components/protocols/host_test/main/test_caixianlin.cpp
  • components/protocols/host_test/main/test_encodebits.cpp
  • components/protocols/host_test/main/test_main.cpp
  • components/protocols/host_test/main/test_sequence.cpp
  • components/protocols/host_test/sdkconfig.defaults
  • components/protocols/include/radio/rmt/CaiXianlinEncoder.h
  • components/protocols/include/radio/rmt/D80Encoder.h
  • components/protocols/include/radio/rmt/Petrainer998DREncoder.h
  • components/protocols/include/radio/rmt/PetrainerEncoder.h
  • components/protocols/include/radio/rmt/Sequence.h
  • components/protocols/include/radio/rmt/T330Encoder.h
  • components/protocols/src/CaiXianlinEncoder.cpp
  • components/protocols/src/D80Encoder.cpp
  • components/protocols/src/Petrainer998DREncoder.cpp
  • components/protocols/src/PetrainerEncoder.cpp
  • components/protocols/src/Sequence.cpp
  • components/protocols/src/T330Encoder.cpp
  • components/protocols/src/radio/rmt/internal/Shared.h
  • components/rfc8908/CMakeLists.txt
  • components/rfc8908/include/rfc8908/RFC8908Handler.h
  • components/rfc8908/src/RFC8908Handler.cpp
  • components/serial/CMakeLists.txt
  • components/serial/include/serial/Serial.h
  • components/serial/src/Serial.cpp
  • components/serial_console/CMakeLists.txt
  • components/serial_console/include/serial_console/SerialInputHandler.h
  • components/serial_console/include/serial_console/command_handlers/CommandEntry.h
  • components/serial_console/include/serial_console/command_handlers/common.h
  • components/serial_console/include/serial_console/command_handlers/index.h
  • components/serial_console/src/SerialInputHandler.cpp
  • components/serial_console/src/command_handlers/CommandEntry.cpp
  • components/serial_console/src/command_handlers/authtoken.cpp
  • components/serial_console/src/command_handlers/domain.cpp
  • components/serial_console/src/command_handlers/echo.cpp
  • components/serial_console/src/command_handlers/estop.cpp
  • components/serial_console/src/command_handlers/factoryreset.cpp
  • components/serial_console/src/command_handlers/hostname.cpp
  • components/serial_console/src/command_handlers/jsonconfig.cpp
  • components/serial_console/src/command_handlers/keepalive.cpp
  • components/serial_console/src/command_handlers/ledtest.cpp
  • components/serial_console/src/command_handlers/networks.cpp
  • components/serial_console/src/command_handlers/rawconfig.cpp
  • components/serial_console/src/command_handlers/restart.cpp
  • components/serial_console/src/command_handlers/rftransmit.cpp
  • components/serial_console/src/command_handlers/rftxpin.cpp
  • components/serial_console/src/command_handlers/sysinfo.cpp
  • components/serial_console/src/command_handlers/validgpios.cpp
  • components/serial_console/src/command_handlers/version.cpp
  • components/serialization/CMakeLists.txt
  • components/serialization/include/serialization/_fbs/FirmwareBootType_generated.h
  • components/serialization/include/serialization/_fbs/GatewayToHubMessage_generated.h
  • components/serialization/include/serialization/_fbs/HubConfig_generated.h
  • components/serialization/include/serialization/_fbs/HubToGatewayMessage_generated.h
  • components/serialization/include/serialization/_fbs/HubToLocalMessage_generated.h
  • components/serialization/include/serialization/_fbs/LocalToHubMessage_generated.h
  • components/serialization/include/serialization/_fbs/OtaUpdateProgressTask_generated.h
  • components/serialization/include/serialization/_fbs/SemVer_generated.h
  • components/serialization/include/serialization/_fbs/ShockerCommandType_generated.h
  • components/serialization/include/serialization/_fbs/ShockerCommand_generated.h
  • components/serialization/include/serialization/_fbs/ShockerModelType_generated.h
  • components/serialization/include/serialization/_fbs/WifiAuthMode_generated.h
  • components/serialization/include/serialization/_fbs/WifiNetworkEventType_generated.h
  • components/serialization/include/serialization/_fbs/WifiNetwork_generated.h
  • components/serialization/include/serialization/_fbs/WifiScanStatus_generated.h
  • components/temporal/CMakeLists.txt
  • components/temporal/include/Temporal.h
  • include/Checksum.h
  • include/Common.h
  • include/FirmwareBootType.h
  • include/Hashing.h
  • include/WebSocketDeFragger.h
  • include/captiveportal/CaptivePortalInstance.h
  • include/captiveportal/RFC8908Handler.h
  • include/config/internal/utils.h
  • include/events/Events.h
  • include/http/HTTPRequestManager.h
  • include/message_handlers/WebSocket.h
  • include/radio/rmt/CaiXianlinEncoder.h
  • include/radio/rmt/D80Encoder.h
  • include/radio/rmt/Petrainer998DREncoder.h
  • include/radio/rmt/PetrainerEncoder.h
  • include/radio/rmt/T330Encoder.h
  • include/serial/SerialInputHandler.h
  • include/serial/command_handlers/common.h
  • include/serial/command_handlers/index.h
  • include/span.h
  • include/util/DigitCounter.h
  • include/util/FnProxy.h
  • include/util/IPAddressUtils.h
  • include/util/PartitionUtils.h
  • include/util/TaskUtils.h
  • lib/README
  • main/CMakeLists.txt
  • main/main.cpp
  • platformio.ini
  • requirements.txt
  • scripts/.gitignore
  • scripts/build.py
  • scripts/build_frontend.py
  • scripts/embed_env_vars.py
  • scripts/flatc
  • scripts/gen_dep_graph.py
  • scripts/gen_env_header.py
  • scripts/gen_staticfs.py
  • scripts/generate_schemas.py
  • scripts/install_dependencies.py
  • scripts/merge_image.py
  • scripts/use_openshock_params.py
  • scripts/utils/boardconf.py
  • scripts/utils/dotenv.py
  • scripts/utils/pioenv.py
  • sdkconfig.defaults
  • src/CompatibilityChecks.cpp
  • src/GatewayClient.cpp
  • src/WebSocketDeFragger.cpp
  • src/captiveportal/CaptivePortalInstance.cpp
  • src/captiveportal/RFC8908Handler.cpp
  • src/config/OtaUpdateConfig.cpp
  • src/config/internal/utils.cpp
  • src/http/HTTPRequestManager.cpp
  • src/serialization/JsonAPI.cpp
  • src/util/IPAddressUtils.cpp
  • src/wifi/WiFiScanManager.cpp
  • test/README

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

hhvrc added 10 commits August 14, 2026 16:26
Firmware publishing to the repository server is authenticated by a GitHub
Actions OIDC token, and registration on the server is per (owner, repo). Neither
can be exercised locally or from any other repository: the token request URL
only exists inside an Actions run, and a token minted elsewhere proves nothing
about this one. So the check has to live here.

It writes nothing. Both probes either read a public endpoint or name an object
that cannot exist, so a run leaves no releases, no storage objects and nothing
to clean up. Aborting a random release id is the pass condition: the lookup
misses, and reaching that miss means signature, issuer, audience, registration
and scope were all accepted.

The board catalog is checked at the same time. It is seeded by hand and names
have to match the boards/*.defaults basenames a hub reports as its own board, so
a board this repository builds but the server does not know stays invisible
until a release init rejects it mid-publish.

401 is reported with both of its causes spelled out, since the server refuses an
unregistered repository with the same response as a bad token and the two cannot
be told apart from the wire.
The smoke test proved the server accepts a token from this repository, but it
proved it with requests nothing else makes. A probe that only the probe runs
tells you the probe works. What has to be known before release day is that the
publish path works, so the publish path is what should be running.

Three composite actions, and one of them does the real thing:

  repo-server-token      mints the OIDC token, one definition of the audience
  repo-server-preflight  reads only: server up, repo authorised, boards known
  repo-server-publish    init, upload every board, then promote or discard

`mode` picks the ending and nothing before it differs. A dry run parses the same
changelog through the same grammar, uploads the same bytes, and passes the same
server-side hash verification as a real publish; only the promote is skipped and
the staged release is discarded instead. So ci-build dry-runs on every build, and
release day runs code that has been exercised continuously rather than for the
first time.

Discarding is unconditional on the way out, including after a failure. A staged
release that is never aborted holds its version against the retry meant to fix
it, until the server's TTL job reaps it.

The real publish stays gated on OPENSHOCK_REPO_SERVER_PUBLISH rather than on the
deploy gate, because the CDN path still serves devices. Flipping that variable is
the cutover; until then this proves the cutover will work without performing it.
The dry run stops before the promote, which leaves the last three links in the
chain untested until the day they matter: whether publishing makes a version
visible, whether the CDN serves the bytes, and whether a hub is told to update.
"No change on the server" was the dry run working correctly, and also the reason
none of that had ever been exercised.

repo-server-verify closes it. After a real publish it reads the release back as
an anonymous caller, through the endpoints a hub and the flashtool use, and for
every board downloads each artifact from the URL the server hands out and hashes
it against the file this run compiled. A match means the bytes a device would
flash are the bytes CI built - which the upload response cannot establish, since
that only proves the server received them, not that the CDN serves them from the
published key.

It also checks the hash the API advertises against the file behind it, because a
correct file behind a wrong advertised hash fails every OTA update while looking
healthy from outside. And that a hub reporting the version just published gets
204 rather than being offered the same build again.

Testing the server now runs on one axis, cheapest first, each row the row above
plus more:

  repo-server-preflight   any branch, seconds, no build - server reachable,
                          repo authorised, catalog complete
  ci-build dry run        + init, upload every board, server-side hash checks
  ci-build publish        + promote, read back, compare served bytes to built

repo-server-smoke was the first row already, but named as though it were the
whole thing and sitting beside a ci-build job that ran the same preflight before
doing the real work. Renamed to say what it is. It stays separate because it
answers questions about server state an admin changes underneath CI - a scope
revoked, a board not yet added - and re-checking those should not cost a
30-minute build. Everything that writes lives in ci-build, where the artifacts
are.

OPENSHOCK_REPO_SERVER_PUBLISH is the only switch: set it, dispatch ci-build on
any ref, unset it after; leaving it set is the cutover. Not a dispatch input,
because GitHub only offers inputs from the default branch's copy of a workflow,
so one would never appear on the branch developing this. Not tied to the deploy
gate either, which fails the same way from the other end - a dispatch on a
feature branch yields build=true, deploy=false, putting the full test out of
reach exactly where it is being written.
CI's contract with the repository server is: submit each blob with the hash it
computed, and fail on any non-success response. Everything else was overreach,
and the CDN was the largest piece of it.

cdn-deploy and cdn-bump are gone, along with the cdn-upload-firmware,
cdn-upload-version-info, cdn-bump-version and sftp-mirror actions and the cdn
environment they read their Bunny credentials from. Where artifacts are stored,
which hostname serves them and the moment a channel starts offering a version
are decisions the server makes behind one API call. CI was making them from the
outside, holding a storage credential to do it, and could leave objects, pointer
files and the release index disagreeing about what is live.

What is left is the contract: mint a token, init, upload every board with its
sha256 manifest, promote or discard, and fail on any status that is not the
expected one. Nothing else.

repo-server-verify is gone for the same reason it always should have been. It
read published releases back and compared the served bytes to the built ones,
which is the server's invariant - it is the only side that knows where it wrote
and which hostname fronts that storage. CI was inferring a server-side
misconfiguration from the outside, badly, and reporting it as a hash mismatch.

repo-server-preflight is gone too. An unregistered repository, a missing scope
and an unknown board are all non-success responses from init, before a single
artifact is uploaded, and each already names its own cause. Asking the same
questions beforehand moved the answer earlier by about a second and gave every
one of them a second place to be wrong.

Publishing now follows the deploy gate rather than OPENSHOCK_REPO_SERVER_PUBLISH,
which is retired - delete the repo variable. That variable existed to keep the
publish decoupled from deploy while the CDN still served devices; with the CDN
path gone, a release tag that built and published nothing would be the failure
mode instead. A tag or a nightly/manual develop deploy publishes; every other
build does the same init-upload-discard dry run as before.

An unset OPENSHOCK_REPO_SERVER_URL no longer skips the job silently. That was
safe when the CDN was the real deploy; now it is the difference between shipping
and not, so it fails loudly on a deploy and only degrades to a quiet skip
elsewhere. The staged-draft-release guard moved from cdn-deploy into the publish
it was always protecting, scoped to real publishes so dry runs do not poll
GitHub for a minute over nothing.

The GitHub release and the API announcement still run last, in that order, each
chained on the publish succeeding with no always() guard - a failed or discarded
publish skips both.
The publish path was bash calling curl and jq, then Python calling urllib with a
multipart body assembled by hand. Both were writing protocol code that is not this
repository's problem to solve.

repo_server_publish.py and repo_server_token.py now use requests, and get-vars.py
parses tags with the semver package instead of a local regex and version class. The
hand-rolled MultipartBody and MultipartReader are gone; a Session carries the bearer
token in one place and reuses the connection across boards.

Dependencies are pinned by digest in .github/scripts/requirements.txt and installed
with --require-hashes. The transitive requests dependencies are listed for the same
reason, and charset-normalizer carries several hashes because pip may choose any of
the compiled wheels. This is the job that holds the publish credential; what runs
next to it should not be whatever the index happens to serve that morning.

The three scripts share .github/scripts/gha.py for workflow commands, step outputs
and the interpreter floor, instead of each restating them. gha.py is deliberately
conservative in syntax: a module whose job is to report that the interpreter is too
old has to be importable on that interpreter, or the check fails as a SyntaxError
instead of the sentence explaining what to install.

setup-python now pins the interpreter via .github/scripts/.python-version rather than
relying on what the runner image ships, which is also what guarantees pip is present.

get-vars.py was verified against its predecessor across ten refs - three branches, a
feature branch, rc/stable/beta/develop tags, the legacy v0.8.1 tag and an invalid one
- and produces identical version, channel and error output for every one.

Python bytecode is ignored repo-wide from the root .gitignore, replacing the
single-line scripts/.gitignore that only covered one directory.
The dry run proved the publish path worked and then discarded the only thing anyone wanted out of it. OPENSHOCK_REPO_SERVER_URL points at the development instance now, so a live version there is the point rather than a hazard: the artifacts a branch just produced are fetchable by a hub aimed at the same instance, which a discarded release never was.

mode is publish unconditionally. The deploy gate no longer decides whether to publish, only whether to cut a GitHub release.

The staged-draft requirement drops its deploy condition. get-vars maps a push to master or beta onto a channel devices follow, tag or no tag, so with every build publishing, that gate was the only thing standing between an ordinary branch push and the fleet.

An unset OPENSHOCK_REPO_SERVER_URL is now fatal rather than a quiet skip. That was survivable while a build might publish nothing; there is no such build left, so it now means a run compiled firmware with nowhere to ship it.

Publishing consumes a version and only a maintainer can undo it. Branch builds carry +<short-sha>, so ordinary pushes do not collide; re-running a build on an already published commit does.

The comments in this job are cut to match. They restated the code beside them, argued against a preflight that no longer exists, and named /admin/firmware/releases twice.
The comments around the publish path had grown to restate the code beside them and to justify choices nobody was asking about. A module docstring explaining that Python puts a script's own directory on sys.path is not telling anyone something they could not have assumed.

What is left is what cannot be inferred from the code: that GitHub does not evaluate paths-ignore for tags, why concurrency needs no group shared across refs, why gha.py is deliberately conservative in syntax.

Comments are no longer wrapped to a column. A sentence occupies one line however long it runs, and a line break means a sentence ended.
Four guard steps each carried an if: condition, putting the rules in an expression language with no types, no tests, and a failure mode of silently evaluating false. repo_server_policy.py decides what applies from the server, channel, tag and commit, so four steps and four conditions become one and none.
Kept this branch's ESP-IDF build and repository-server publish path, which
supersede develop's PlatformIO actions, CDN deploy jobs and its own port of
the OTA update manager. Took develop's dependency bumps, the publish-tag and
pr-check-comment actions, and the frontend package updates.
The release was opened at the very end of a run, so a version already published, a concurrent run holding the same one, or an unknown board was refused after a half-hour matrix build instead of before it. A stage job now inits the release straight after get-vars and hands the id downstream, and the abort job is the finally for a lifetime that no longer fits inside one script. The prod reviewer moves to stage with it, so a release is approved before it spends the compute rather than after, which is why the build roots wait on stage.

The dispatch tag input is gone and channel is threaded into get-vars instead. Both inputs previously applied only at publish time, after the build had already baked the derived version into OPENSHOCK_FW_VERSION and the filename, so a dispatch naming a version shipped firmware that reported one version and was published as another.
@hhvrc
hhvrc deployed to repo-server-dev August 18, 2026 13:51 — with GitHub Actions Active
hhvrc added 2 commits August 18, 2026 16:13
A thirteen-way matrix spent more on checkout, pip and artifact downloads per runner than on the work: the merge is a few seconds of esptool per board. One job now does all of them, so the merged binaries arrive as a single firmware_merged artifact rather than one per board, and resolve_artifacts tells them apart by the board in their filename.

merge_image.py took one board, which would have meant driving it from a bash loop. It now takes a list, expands {board} into --bindir and --output itself, and merges across the CPUs it has. Workers are processes rather than threads because esptool keeps module-level state and exits the interpreter instead of raising, and each board's output is captured and printed whole so parallel runs do not braid their logs together.

A failing board no longer stops the rest, which is what fail-fast: false gave the matrix: every board that failed is named at the end.
hhvrc added 8 commits August 19, 2026 16:22
The root CMakeLists force-included a single generated header into every
compilation in the project through COMPILE_OPTIONS, so all of ESP-IDF saw it
too. That header carried OPENSHOCK_FW_GIT_COMMIT and a version string ending in
+<short-sha>, both of which change on every commit.

The effect was not that ccache stopped reusing objects - it still recovered
about 95% of them - but that it could only do so through its preprocessor mode.
Direct-mode lookups missed on every translation unit, so each build ran the full
preprocessor over the entire project before it could match anything. Measured on
NodeMCU-32S with a warm cache and a fresh build directory, a commit-only change
cost 345s against 142s for the identical tree, with a direct-mode hit rate of
0.11%.

Split the generated header in two. openshock_board.h holds board and chip
identity, build mode and log level, and is included by OpenShock.h, so only
OpenShock's own components see it. openshock_version.h holds the version and
commit, and is included by the five translation units that report them. Neither
is force-included. The same measurement is now 173s at a 100% direct-mode hit
rate.

The version header is also regenerated at build time rather than only at
configure time. It was previously written by execute_process during CMake
configure, which meant an incremental build kept reporting whatever version its
build directory was first configured with - `idf.py app` after changing the
commit finished in 5s and silently produced a binary stamped with the old SHA.
It now refreshes on every build and writes only when the contents differ, so a
new commit rebuilds five objects and an unchanged one rebuilds nothing.
Thirteen boards map onto four chips, but no two of them shared a single compiled
ESP-IDF object. The five per-board GPIO settings were Kconfig symbols, so they
landed in sdkconfig.h - which 922 of ESP-IDF 6.0.2's component sources include
directly, and many more transitively. Two boards that differed only by an LED pin
therefore produced a different sdkconfig.h and recompiled the entire framework.

They are plain generated macros in openshock_board.h now, set per board as bare
OPENSHOCK_* lines in boards/<board>.defaults and stripped from the sdkconfig
fragment by scripts/build.py before idf.py sees it. The settings that are
identical across every board - domains, hostname, AP prefix, URI buffer - stay
Kconfig, because they cost nothing in shared objects.

This collapses 13 distinct sdkconfigs into 6: the five ESP32/4MB boards become
one, as do the three ESP32-S3/8MB boards. Comparing the compile command for an
ESP-IDF source across two boards in a group, 115 of 117 argv tokens now match.
Three separate problems in one cache key.

The key contained github.sha. Cache entries are immutable, so a key that always
misses always writes a full new copy: 13 fresh entries on every push, 47 live at
the time of writing. That, more than anything else, is what pushed the repository
past the 10 GB cap and left GitHub evicting the ESP-IDF entry every job needs.
The SHA is now only a save discriminator; restore-keys takes the newest store for
the group.

The key was per board, when what determines object identity is the resolved
sdkconfig. get-vars.py now derives a cache group per board - the chip plus a
digest of every CONFIG_* line in sdkconfig.defaults and the board fragment - and
puts it in the build matrix. Thirteen boards resolve to six stores. The digest is
derived rather than hardcoded as chip+flash so that a board which later changes
any other sdkconfig setting splits into its own group by itself, instead of
silently sharing a store it can never hit.

Finally, the build directory path appears on every compile command line, in
-I<dir>/config and the @<dir>/toolchain/cflags response file. Building each board
under build/<board> gave every board a different command line, so even with an
identical sdkconfig two boards shared nothing - a same-group build measured 0%
hits for exactly this reason. Each CI job builds one board, so it now sets
OPENSHOCK_BUILD_DIR to a fixed path; local builds keep build/<board>. The same
measurement is now 98.25% hits, 155s against 295s cold.

Also sets CCACHE_COMPILERCHECK=content, since the toolchain is restored from a
cache with fresh mtimes every run, bounds the store with CCACHE_MAXSIZE, prints
the stats, and skips saving on pull requests - a PR restores the base branch's
store in full, but writing from every PR push is what multiplies entries fastest.
…base

Two independent costs paid on every run.

The ESP-IDF install was cached whole, at 3.65 GB per entry, and duplicated across
refs: 6.81 GB of the repository's 11.63 GB was two byte-identical copies of it.
Split the cache into restore/save so the install can be trimmed in between, and
prune what CI never invokes - dist/ (the download archives, already extracted
into tools/), the GDBs, OpenOCD, the ULP toolchain, and esp-clang, which only
clang-tidy uses and cpp-linter runs with tidy-checks '-*'. The gcc toolchains
are left alone: xtensa builds 12 boards and riscv32 builds the ESP32-C3.

A measurement step reports the size of the install per directory first. The
figures the prune list was drawn from come from a local install polluted by other
projects, and EIM may not install esp-clang on a runner at all, so the first run
is what confirms them. If a warm run starts taking minutes in the install step
again, the installer is re-fetching something pruned here.

Separately, build-compilationdb ran a full uncached firmware build for both
CodeQL and cpp-linter. CodeQL needs it - its tracer only sees the C/C++ if the
compiler actually runs. cpp-linter does not: it reads compile_commands.json,
which CMake writes at configure time, before a single object is compiled. It now
runs `idf.py reconfigure` instead, which produces the same 8.3 MB database in 62s
and compiles nothing.
Arduino is no longer a component of this project - it appears in neither
dependencies.lock nor any idf_component.yml - so kconfgen reported all fourteen
CONFIG_ARDUINO_SELECTIVE_* and CONFIG_AUTOSTART_ARDUINO symbols as unknown and
discarded them. They had no effect on the build.

CONFIG_MBEDTLS_PSK_MODES, CONFIG_MBEDTLS_KEY_EXCHANGE_PSK and
CONFIG_LWIP_SO_RCVBUF were also only there for Arduino, but unlike the above they
are real settings that still take effect, so they are flagged in a comment for
the de-Arduino migration rather than changed as a side effect of a caching pass.

OPENSHOCK_FW_BUILD_DATE was exported by build-firmware, build-compilationdb and
codeql.yml, but nothing reads it - gen_env_header.py has never emitted it.
…e on PRs

Three things the first CI run exposed.

The prune matched the wrong paths. A runner installs the toolchains directly under
the install root (/tmp/esp/esp-clang) where a local EIM install puts them under a
tools/ subdirectory (~/.espressif/tools/esp-clang), and only the latter was
checked. The single directory that did match was ~/.espressif/dist, so the run
reported "pruned 1099 MB in total" and left esp-clang's 1.9 GB, esp-clang-libs'
335 MB, 247 MB of GDBs, OpenOCD and the ULP toolchain in the cache. Both layouts
are now checked, by name rather than by glob so that xtensa-esp-elf-gdb can go
without putting xtensa-esp-elf at risk. The key moves to -4 so the trimmed install
is stored fresh.

The measurement step did its job: the install is 7.7 GB under /tmp/esp plus 1.1 GB
of archives, and esp-clang is present on a runner - which was the open question
the prune list was written against.

Every matrix job tried to write the ESP-IDF cache. The install is identical for all
13, so when the key changes they all tar and upload ~3.5 GB at once; one wins the
reservation and the other twelve spend ~20s each to be told "another job may be
creating this cache". get-vars now nominates a single writer and the rest restore
read-only.

Finally, ccache was never saved at all. The save was skipped on pull requests, on
the assumption that a PR restores its base branch's store - but ci-build only runs
`push` for develop, beta and master, so a feature branch only ever builds as a PR
and the store was never written by anything. First run measured 0% hits and "Cache
not found" for both keys. It now saves on every event; at 6 stores per push instead
of 13, each around 30 MB for one board and capped by CCACHE_MAXSIZE, the entry
count this costs is affordable in a way the old per-board-per-SHA key was not.
`stage` is skipped on every run that does not publish, and GitHub propagates that
skip down the needs graph to any job that does not override it. build-frontend and
build-firmware each carry their own condition and so ran; build-staticfs did not,
so on a pull request it skipped, and took merge-partitions and checkpoint-build
with it. A PR therefore compiled every board and then produced no flashable
images.

Pre-existing, and not caused by the caching work - run 32147411341, a pull request
from before any of it, shows build-frontend succeeding and build-staticfs skipped
in exactly the same way. It builds fine on workflow_dispatch, where nothing
upstream is skipped, which is why it went unnoticed.

Both jobs now carry an explicit condition, matching what build-frontend and
build-firmware already do.
The installer, not the compiler, was the cost of a build. In the run that had to be
cancelled, one job spent 867s in espressif/install-esp-idf-action and 128s
compiling firmware. Roughly 700s of that was apt fetching cmake at about 35 KB/s,
because all 13 matrix jobs were doing the same thing at the same moment. The action
re-runs apt and re-prepares the IDF clone on every invocation, cache hit or not.

The restored tree is self-contained, so a warm job no longer needs the action at
all. EIM leaves an activate_idf_<version>.sh beside the install with absolute paths
baked in, and the whole tree is cached, so those paths still resolve. The script's
own `-e` mode prints KEY=VALUE lines, which map onto GITHUB_ENV and GITHUB_PATH.
Sourcing it is not an option: it refuses to run unless $0 is a shell name, which it
never is inside a GitHub step, and it reads $ZSH_VERSION without a default, which
trips `set -u`. $IDF_PATH/tools is added separately, since idf.py lives there and
the script's PATH line does not cover it.

ccache is then the only build prerequisite the IDF tree does not carry - cmake,
ninja and python all come from it - and it is installed only when missing: 592 KB
at worst against the action's ~15 MB.

Verified locally by building NodeMCU-32S with nothing but the environment that step
produces: no installer, no clone, no apt. idf.py and ccache both resolve and the
build completes.

A warm-esp-idf job now populates the cache once before the matrix fans out.
Otherwise a changed key puts all 13 boards back into the installer simultaneously,
which is the stampede that made the run unusable. It calls setup-esp-idf with the
new lookup-only input, so the key stays defined in exactly one place and the probe
checks for the entry without downloading it - seconds when it is already there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants