The `set -eo pipefail` I added in PR #15 broke `make publish` on CI:
Make uses /bin/sh (dash on Ubuntu), and dash rejects `pipefail` with
"Illegal option -o pipefail" before any other command runs. Step 11
(Build, push, sign) failed at line 1 of the recipe.
Fix with a target-specific `SHELL := /bin/bash` so only this recipe
runs under bash; the rest of the Makefile stays on /bin/sh.
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
Root cause of the failed firmware build: AtomicU64 is unavailable on
xtensa-esp32-espidf — ESP32 LX6 is 32-bit with max-atomic-width=32 and
no 64-bit CAS, so std::sync::atomic::AtomicU64 isn't compiled in for
that target.
Switch the insertId monotonic counter to AtomicU32. Wraps at ~4.3 B,
which is plenty given our flush rate; insertId only needs uniqueness
within a single second of wall time anyway.
Also re-applies the rest of the cloud_log.rs review changes from the
reverted PR #15: nvs_util migration, LogQueue mutex unwrap_or_else,
module_path!() TARGET cross-references, backoff cap fix, insertId field.
The earlier bisect probes (f786c6d disabling validity/panic-hook/wifi
watchdog, 8cf968a full revert) were misleading — they all kept the
broken AtomicU64 import, so the link error persisted and made it look
like those features were the culprit.
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
Bisect step: full revert built successfully. Re-adding everything
except cloud_log.rs to check whether cloud_log is the culprit.
If this passes, the bug is in cloud_log.rs.
If this fails, narrow down within main/sig/ota/etc.
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
Bisect probe (f786c6d) still failed even after disabling validity check,
panic hook, and wifi watchdog. Reverting ALL firmware src changes to
main to confirm the failure is in my changes (not environmental).
If this CI passes: the bug is in one of my remaining changes that the
prior probe didn't disable. Will re-add changes file by file.
If this CI fails: the issue is environmental (toolchain roll, runner
change, etc.) and unrelated to my edits.
Tools and Makefile changes are kept (they already build).
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
CI failed on the previous commit and the logs aren't accessible from
this sandbox. Temporarily commenting out the three most novel additions
(sig.rs cert validity check + helper, main.rs install_panic_restart_hook
call, main.rs wifi connect watchdog) so the next CI run tells me
whether the failure is in one of those or elsewhere.
Will revert / re-enable in the next commit once we know which one (if any)
is the culprit.
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
sig.rs:
- reject empty DSSE signatures array (was index panic)
- check DSSE payloadType is application/vnd.in-toto+json
- check each cert's notBefore/notAfter window vs wall clock
- drop dead Sha256/Sha384 imports + _unused_keep_imports stub
main.rs:
- drop the demo ipify/wttr fetches; rollback no longer depends on
third-party uptime
- add a Wi-Fi connect watchdog that reboots after 60 s of hang
- install_panic_restart_hook so panics flush+restart instead of
landing in IDF's generic panic path
- comment the dual-path tracing (registry layer + log-always to
EspLogger) and the NVS-error-vs-missing semantics
cloud_log.rs:
- LogQueue mutex now recovers from poison via unwrap_or_else
- replace hardcoded "esp32_blinky::*" target strings with
module_path!() constants exported from each module
- add insertId on every entry so Cloud Logging preserves stable
within-second order
- raise backoff exp cap to 6 so the 5 min ceiling is actually
reachable
ota.rs:
- widen NVS string buffer for repo/tag (96 -> 256 B); 96 silently
fell back to defaults if a repo string overflowed
- debug_assert base >= 1 s in jittered() to catch a 0 s misconfig
- assert sig index has exactly 1 manifest instead of silently
picking the first
- log verify_bundle elapsed ms (Xtensa P-256/P-384 perf canary)
- note token-reuse risk between phase 1 and phase 2
provision tool:
- --dry-run for parsing checks without embuild artifacts
- warn loudly when sa_key_pem is inside the worktree
- delete staged identities JSON after the partition image is
generated
publisher tool:
- pull-verify now asserts layer mediaType matches the firmware
type, not just layer count
Makefile:
- set -eo pipefail in the publish recipe so a publisher crash
surfaces directly instead of being swallowed by the digest
pipeline
new module src/nvs_util.rs consolidates the read_str / read_blob
helpers that were duplicated across cloud_log/trust/ota/main.
https://claude.ai/code/session_01XkNKhsMjCMg4HMTzf3ZZYC
- New `docs/observability.md`: descriptive (present-tense) write-up
of the Cloud Logging + Cloud Monitoring pipelines as currently
shipped. Replaces the historical `logs-plan.md` + `monitoring-plan.md`
planning docs.
- New `docs/setup.md`: prerequisites, first flash, day-to-day Make
targets, and the optional GCP setup (lifted out of the README so the
README can stay terse). Includes the Python 3.12-shim explanation
from the old `notes.txt`.
- Move `ota.md` → `docs/ota.md`.
- Move `eink-plan.md` → `docs/eink-plan.md`. Per-feature plans still
use the `<feature>-plan.md` name; once shipped they get rewritten
in present tense alongside the other docs.
- Delete `logs-plan.md`, `monitoring-plan.md`, `notes.txt`. Their
user-facing content is now in `docs/setup.md`; their LLM-relevant
bits (architectural rationale, partition-table CMake quirk, Python
shim, no-LED, `make` conventions, NVS key length cap) are in
`CLAUDE.md`.
- Trim `README.md` to a top-level overview + links into `docs/`.
- Update internal cross-references (Makefile, ota.md, eink-plan.md,
tools/provision/src/main.rs doc-comment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`cargo install espflash --locked` builds from source. Moving the
.embuild + Swatinem cargo-cache restores ahead of it lets the install
use the cached registry index + downloaded crates instead of fetching
them fresh each run — shaves ~30 s off the install on warm caches.
Pure step-reordering; no behavioural change. Both caches still depend
on inputs that exist at checkout, so an earlier restore is safe.
Bigger speedup is available by replacing the cargo install with a
prebuilt-binary install (e.g. taiki-e/install-action drops espflash
from ~3-5 min to ~2 s). Deferred to its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related additions for fleet observability + tunability:
1. `[ota]` block in provisioning.toml. Optional, individually-optional
fields: `repo`, `tag`, `poll_secs`. Writes to the existing `ota` NVS
namespace (key names match src/ota.rs's NVS_REPO / NVS_TAG /
NVS_POLL_SECS). Missing keys leave the firmware on its compile-time
defaults (ghcr.io/imjasonh/esp32:latest, 60 s poll).
2. `fw_version` (the GIT_SHA baked in at build time) on every log
entry and every metric series, so behaviour can be correlated with
a release across the fleet.
- cloud_log: jsonPayload.fw_version on every entry.
- metrics: metric.labels.fw_version on every TimeSeries (resource
labels can't carry it — `generic_node` has a fixed schema; metric
labels let queries split heap, rssi, etc. by fw).
Both threaded from main.rs's existing FW_VERSION constant; no new
config plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Even with serialization + dynamic mbedtls buffers + KEEP_PEER_CERT=n,
two concurrent TLS sessions don't fit on this chip's heap. The OTA
download holds its session open for tens of seconds while
cloud_log + metrics POSTs need ~25-30 KB of contiguous heap on top
for their handshake — and the second session reliably triggers
MBEDTLS_ERR_SSL_ALLOC_FAILED, killing the OTA download mid-stream.
Add an `OTA_DOWNLOAD_IN_PROGRESS` AtomicBool in `gcp_auth`. OTA's
`download_and_apply` flips it true via an `OtaDownloadGuard` (RAII so
the flag clears even on `?` returns). cloud_log + metrics check the
flag at the top of each cycle and skip — but don't drain — when set.
Effects:
- cloud_log entries accumulate in the bounded queue and flush in a
single batched POST once the download completes (or fails).
- metrics misses at most one snapshot per download.
- OTA download succeeds reliably — verified end-to-end (~19s clean,
no TLS errors, "download complete, SHA verified" reached).
Downloads only happen when there's a genuinely new image, so this
pause is rare in steady state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The metrics work in PR #10 added a third concurrent TLS client
(metrics → monitoring.googleapis.com) on top of cloud_log → logging
and OTA → ghcr. Three concurrent handshakes pinned ~90 KB of mbedtls
context and pushed `min_free_heap` into the single-digit-KB range,
producing intermittent ESP_ERR_HTTP_CONNECT failures (mbedtls
ALLOC_FAILED at -0x7F00).
Serialize via a shared `Arc<Mutex<()>>` constructed in main and held
**at the call sites** in each sender — std `Mutex` isn't reentrant and
`auth.get_or_refresh()` mints a token (an HTTPS POST to oauth2) that
must run inside the same critical section. Lock granularity:
- cloud_log: token-refresh + entries:write under one lock window.
- metrics: token-refresh + timeSeries.create under one lock window.
- ota::poll_once: token + manifest under lock; release for the
signature bundle fetch's lock; release; verify (CPU, no TLS);
download_and_apply UNLOCKED. Releasing between phases avoids
blocking cloud_log/metrics behind ~2s of pure-Rust X.509/ECDSA work.
- ota::download_and_apply: deliberately not in the lock — multi-second
blob downloads must not block per-5-s log flushes or per-30-s
metrics POSTs.
Plus mbedtls heap knobs that compose with the dynamic-buffer config
already in sdkconfig:
- CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE=n: drop the parsed peer
cert chain after handshake. We don't reuse sessions or do mutual
TLS, so we never need it post-handshake. Saves ~3-5 KB per active
session.
Known limitation, deferred to a follow-up: cloud_log/metrics handshakes
can still collide with the held-open OTA download session and OOM the
download mid-stream. The plan there is an `OTA_DOWNLOAD_IN_PROGRESS`
atomic that cloud_log + metrics check before locking — they skip the
POST and let the queue accumulate until the download finishes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements monitoring-plan.md.
- src/gcp_auth.rs: shared TokenProvider. Mints a multi-scope
(logging.write + monitoring.write) JWT once and caches the bearer;
cloud_log + metrics threads share it. Also moves http_post,
parse_signing_key, b64url, now_unix_secs, unix_to_rfc3339,
device_mac out of cloud_log.rs.
- src/cloud_log.rs: cloud_log::run takes Arc<TokenProvider> instead
of owning the JWT/token state. LogQueue::stats() exposes depth +
lifetime drop count for the metrics path.
- src/metrics.rs: periodic snapshot → POST /v3/projects/{id}/timeSeries.
GAUGE INT64 metrics under custom.googleapis.com/esp32/<name>:
free_heap, free_heap_internal, min_free_heap, largest_free_block,
stack_hwm (label task=main|ota|cloud_log|metrics), wifi_rssi,
wifi_channel, cpu_freq_mhz, uptime_secs, nvs_used/free_entries,
cloud_log_queue_depth, cloud_log_dropped_total. Auto-creates
MetricDescriptors on first POST. Cadence via NVS metric_intvl
(gcp namespace), default 300s, 0 disables.
- Each spawned thread publishes its TaskHandle_t into a module-level
AtomicUsize so metrics can read uxTaskGetStackHighWaterMark per
task without holding handles for them. (Xtensa StackType_t is
uint8_t, so the API returns bytes — no word-size multiplication.)
Plus:
- sdkconfig: enable CONFIG_MBEDTLS_DYNAMIC_BUFFER (+ free_config_data,
free_ca_cert) so three concurrent TLS clients (cloud_log + metrics
+ OTA) don't blow our heap budget. Recovers ~15-25 KB per session.
- Makefile: `make flash` and `make run` now pass --partition-table.
Without it, espflash 4.x writes a *default* (factory-only) partition
table over our OTA layout, silently bricking OTA on the device.
- README + provisioning.toml.example: gcloud command for
roles/monitoring.metricWriter; document metrics_interval_secs.
- tools/provision: write metric_intvl NVS u32 (default 300).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan for src/metrics.rs — periodic snapshot of heap, stack, wifi, cpu,
uptime, NVS, cloud_log queue stats; POSTed as time series to
monitoring.googleapis.com/v3/timeSeries. Shares the cloud_log SA + key
via a multi-scope JWT and a refactored src/gcp_auth.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main bumped sha2 0.10 -> 0.11 (PR #8) and switched format!("{:x}", ...)
to hex::encode in src/ota.rs. The auto-merge handled both, plus
Cargo.toml union of dep lists. Cargo.lock had one residual marker around
the firmware deps block (kept main's qualified "sha2 0.11.0" + retained
cloud-logging's rsa/time/tracing-subscriber).
rsa = 0.9 still depends on sha2 = 0.10 internally, so SigningKey<Sha256>
must be parameterised against rsa's re-export (rsa::sha2::Sha256), not
our top-level sha2 0.11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ESP_ERR_NVS_NOT_FOUND from EspNvs::new(gcp,...) is normal for devices
without a [gcp] block; return Ok(None) rather than crashing.
- parse_signing_key trims trailing whitespace; jq -r appends a newline
on top of the PEM's own trailing newline and pem-rfc7468 then errors
trying to parse a second (empty) block.
- Demote per-poll OTA lines (sleeping / no change / manifest) and
per-flush cloud_log lines (posted batch / minted token) from info to
debug. They fire continuously without actionable info, and at INFO
they flood Cloud Logging. INFO stays for OTA update + verify events.
Plus README GCP setup steps and .gitignore for SA key files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the stub eprintln sender with the real implementation:
- mint_access_token: builds + signs a service-account JWT (RS256 over
base64url(header).base64url(claims)), POSTs to oauth2.googleapis.com
with the standard grant_type=...jwt-bearer form body, parses the
access_token + expires_in.
- CachedToken: held in the sender thread's local state. Refreshes
300 seconds before expiry (Google issues 1h tokens; we re-mint at
T+55min).
- post_batch: serializes the WriteEntriesRequest with logName,
resource (generic_node + project + MAC node_id), and entries
(severity + jsonPayload + optional timestamp), POSTs to
logging.googleapis.com/v2/entries:write with the bearer.
- Backoff on failure: sleep doubles on each consecutive failure
(capped at 5 min). Batches dropped on POST failure rather than
re-enqueued; loss is surfaced via dropped_before on the next entry.
Crates added: rsa 0.9 (with sha2 feature for AssociatedOid; PKCS#1
v1.5 needs the DigestInfo prefix), time 0.3 (RFC3339 formatting).
main.rs: SNTP startup is now gated on gcp.is_some(). The JWT auth
needs a real wall-clock for / (Google rejects ~5min skew),
so devices with cloud logging pay a one-time ~few-second sync at
boot. Devices without [gcp] skip it. Log entry timestamps themselves
are still optional — when omitted, GCP server-side assigns them.
Cloud Logging severity mapping: tracing TRACE/DEBUG -> DEBUG,
INFO -> INFO, WARN -> WARNING, ERROR -> ERROR (matches GCP's
LogSeverity enum).
device_mac() reads via esp_efuse_mac_get_default and uses the lower
6 bytes as the node_id resource label, so multi-device logs can be
filtered by MAC.
Firmware size: 1.60 MB -> 1.71 MB (+rsa, +time, +sender code).
Still under the 1.94 MB slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First commit of cloud-logging work (logs-plan.md). Foundation only:
the actual NTP/JWT/RSA/HTTPS-POST sender is the next commit; this
sender is a stub that writes 'would POST' to serial.
src/cloud_log.rs:
- GcpConfig::load reads the optional 'gcp' NVS namespace. If any
required key (project_id / sa_email / sa_key_id / sa_key_pem) is
missing, returns Ok(None) — cloud logging is opt-in per device.
- LogQueue: Mutex<VecDeque>-backed bounded ring buffer (256 entries),
drops oldest when full and surfaces the drop count on the next push.
- CloudLogLayer: tracing_subscriber Layer that captures events,
extracts structured fields via field::Visit, applies the configured
min_severity filter, and pushes onto the queue. Uses wall-clock
time when SystemTime::now() is past 2020 (NTP synced); else None
so Cloud Logging assigns server-side timestamps.
main.rs:
- Take NVS first, before any tracing events fire.
- If the gcp NVS namespace is populated, install CloudLogLayer as
the global tracing subscriber and spawn the sender thread (32 KB
stack).
- tracing now uses the 'log-always' feature so events still emit
log records even with a subscriber installed — keeps EspLogger
writing to serial regardless.
tools/provision/:
- Optional [gcp] section in provisioning.toml. Tool emits the gcp
namespace into the NVS CSV when present, validates min_severity
spelling early.
ota.md: gcp namespace added to NVS schema docs.
provisioning.toml.example: commented [gcp] block.
Firmware size: 1.52 MB -> 1.60 MB (tracing-subscriber + cloud_log code).
Plenty of slot headroom remaining (1.94 MB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same fix as the publisher in PR #5: sha2 0.11's digest output type
(`Array<u8, _>`) doesn't impl `LowerHex`, so `format!("{:x}",
Sha256::digest(...))` no longer compiles. Switch to `hex::encode`
and add `hex = "0.4"` as a direct dep.
Two call sites in src/ota.rs (manifest digest hash + downloaded layer
hash). esp-idf-svc 0.52 + embedded-svc 0.29 had no API changes that
affected our code paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dependabot fix: drop the root rust-toolchain.toml so Dependabot's
runner doesn't try to use the (uninstalled) `esp` toolchain when
running `cargo update`. The firmware Make recipe now invokes
`cargo +esp build` explicitly so local + CI builds still pin the
right toolchain. Host tools (publisher, provision) keep their own
rust-toolchain.toml = stable.
Dependabot config: batch tools/publisher + tools/provision into one
weekly PR using the `directories:` (plural) field. Firmware Cargo
deps and GHA action versions stay in their own PRs.
ota.md: fold in the provisioning-plan.md content (now that
provisioning is shipped). Updates partition layout numbers, NVS
schema, bootstrap workflow, and trust-config description (no longer
compile-time consts). Adds a 'Soft vs hard trust' section folded
from the plan. Future Work merged.
provisioning-plan.md deleted; README and CLAUDE.md updated to point
at ota.md and drop the dropped file from the project layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements provisioning-plan.md. The OTA-distributed firmware no longer
contains any secrets or per-device config — those live in NVS and are
written via USB by `make provision` from a (gitignored)
`provisioning.toml`. Same firmware bytes can run on any device.
Firmware changes:
- src/main.rs reads wifi/ssid + wifi/pass from NVS namespace `wifi`.
Strict-inert (sit-and-log) when missing; no compile-time fallback.
WIFI_SSID/WIFI_PASS env! macros gone.
- src/trust.rs becomes a `TrustConfig::load(nvs)` loader. Identities
come from NVS namespace `trust`, key `identities` (JSON-encoded
array). Sigstore root + intermediate PEMs come from `trust/fulcio_root`
and `trust/fulcio_inter` blobs. include_str! gone.
- src/sig.rs and src/ota.rs thread &TrustConfig through verify_bundle
and the OTA loop instead of reading global consts.
- build.rs no longer tracks WIFI_* env changes.
New tool:
- tools/provision/ host-side cargo crate. Reads provisioning.toml,
emits an NVS CSV, shells out to ESP-IDF's nvs_partition_gen.py to
produce a binary NVS image, optionally `espflash write-bin`'s it.
Make targets:
- `make provision` — build NVS image + flash it.
- `make bootstrap` — flash-all + provision (new device setup).
- `make build` no longer requires wifi.env.
- wifi.env removed from prereqs / WIFI_ENV variable removed.
Workflows:
- ci.yml: drops the placeholder wifi.env step, adds a `build provision`
job alongside the firmware + publisher jobs.
- publish.yml: drops WIFI_SSID/WIFI_PASS secret reads. Only secret
needed is the auto-injected GITHUB_TOKEN.
Docs:
- README updated to use `make bootstrap` + `make provision` flow.
Project layout moved to a new repo-local CLAUDE.md.
- provisioning.toml.example committed as the template the user copies.
- wifi.env.example removed (no longer used).
Migration: existing devices need `make bootstrap` over USB. The new
firmware has no embedded creds; OTAing to it without provisioning
would just sit in strict-inert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`[profile.release]` now uses lto, codegen-units=1, strip, and
panic=abort (last one matches the build-std=panic_abort already in
.cargo/config.toml). Combined: firmware shrinks from 1.71 MB to
1.52 MB (~9% smaller).
partitions.csv: grow each OTA slot from 1.75 MB to 1.9375 MB by
absorbing the previously-unused 384 KB at the end of flash. Now no
unused space at the end. Repartitioning is a USB-only migration
(`make flash-all`); device migrated locally before pushing.
Net headroom per slot: 126 KB -> 463 KB (3.7x). Plenty for the
upcoming e-ink driver + embedded-graphics stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was structured as a multi-phase plan with each phase marked DONE; now
that the system is operational, restructure as documentation describing
what's built (architecture, components, partition layout, update flow,
anti-bricking + USB recovery, CI signing, on-device verification).
Remaining ideas moved to a 'Future work' section at the end. Dropped
explicit support for legacy cosign .sig formats from the doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI failure: `embuild ... Could not install esp-idf ... Failed to locate
python ... Too many levels of symbolic links`. Root cause: when
.embuild is restored from a previous workflow's cache, the stale
python-shim/python3 symlink can collide with what `uv python find`
returns -- producing a self-reference that the kernel surfaces as
ELOOP on the next access.
Fix: `rm -f` the shim symlinks before resolving uv's 3.12 path, so
each run starts from a known-empty state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sha2 0.11 changed the digest return type from `GenericArray<u8, _>`
to `Array<u8, _>`, which doesn't implement `LowerHex` -- so the
existing `format!("{:x}", Sha256::digest(...))` calls no longer
compile. Switch to `hex::encode` (added as a dep), which works on
any `AsRef<[u8]>`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three ecosystems: GitHub Actions (workflow files), firmware Cargo
deps (root), publisher Cargo deps (tools/publisher). Each ecosystem
groups all updates into a single weekly PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI workflow (.github/workflows/ci.yml):
- Triggers on PRs (and main pushes for redundant fast feedback).
- Two parallel jobs: build the firmware (with placeholder wifi.env),
build the host-side publisher tool. No publishing or signing -- that
remains publish.yml's job.
- Per-PR concurrency: a new push to a branch cancels in-flight CI for
the previous commit on that branch.
GHA action bumps (publish.yml + ci.yml both use the new versions):
- actions/checkout v4 -> v6
- astral-sh/setup-uv v3 -> v8
- esp-rs/xtensa-toolchain v1.5 -> v1.7
- sigstore/cosign-installer v3 -> v4 (defaults to cosign 3, which
still produces the Sigstore Bundle v0.3 format we verify on-device)
- actions/cache v4 -> v5
- Swatinem/rust-cache stays v2 (already current)
Gets us off the deprecated Node.js 20 actions runtime.
Cargo.lock: now tracked. Both crates (firmware and publisher) are
binaries, and binary Cargo.lock should be committed for reproducible
builds. `cargo update` was a no-op except for a couple transitive
patch bumps in the firmware (generic-array 0.14.7 -> 0.14.9).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>