neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Run the compiler in a container instead of running the job in one
e6c7b44 · Gabriel Morell · 2026-09-16 18:31
Message
{commit_body(@commit)}
Files changed
modified
.forgejo/workflows/firmware.yml
+91
−46
@@ -11,19 +11,30 @@ on:
| 11 | 11 | |
| 12 | 12 | jobs: |
| 13 | 13 | firmware: |
| 14 | − runs-on: self-hosted | |
| 15 | − # Espressif's own Rust image, pinned to a version rather than | |
| 16 | − # `esp32c6_latest`. The pin is load-bearing twice over: it fixes the | |
| 17 | − # ESP-IDF-capable toolchain, and it fixes the *nightly* the image installed | |
| 18 | − # when it was built. `rust-toolchain.toml` asks for "nightly", rustup uses | |
| 19 | − # whatever nightly is already installed rather than fetching a newer one, | |
| 20 | − # and a current nightly does not build `std` for this target at all (see | |
| 21 | − # CLAUDE.md, failure 1 — `AT_FDCWD`). | |
| 14 | + # On the host, NOT in a container — and the compiler runs in one `docker | |
| 15 | + # run` instead. | |
| 16 | + # | |
| 17 | + # The obvious shape, `container: espressif/idf-rust:…`, does not work: the | |
| 18 | + # runner executes JavaScript actions with node *inside* the job container, | |
| 19 | + # and that image has no node (its apt line is git curl gcc clang | |
| 20 | + # ninja-build unzip libudev-dev tar xz-utils libusb libssl-dev pkg-config | |
| 21 | + # python3 — and nothing else). `actions/checkout@v4` dies in one second, | |
| 22 | + # and `actions/cache@v4` would die next. | |
| 22 | 23 | # |
| 23 | − # The image carries rustup + espup's nightly, ldproxy and espflash. It does | |
| 24 | − # NOT carry ESP-IDF: esp-idf-sys clones v5.2.3 and installs its tools on the | |
| 25 | − # first run, which is what the cache below exists for. | |
| 26 | − container: espressif/idf-rust:esp32c6_1.95.0.0 | |
| 24 | + # Inverting it costs nothing and fixes the lot: checkout and cache run on | |
| 25 | + # the host where node exists, and the toolchain image is used for the one | |
| 26 | + # thing it is actually for. | |
| 27 | + runs-on: self-hosted | |
| 28 | + | |
| 29 | + env: | |
| 30 | + # Espressif's own Rust image, pinned to a version rather than | |
| 31 | + # `esp32c6_latest`. The pin is load-bearing twice over: it fixes the | |
| 32 | + # ESP-IDF-capable toolchain, and it fixes the *nightly* the image | |
| 33 | + # installed when it was built. `rust-toolchain.toml` asks for "nightly", | |
| 34 | + # rustup uses whatever nightly is already installed rather than fetching | |
| 35 | + # a newer one, and a current nightly does not build `std` for this target | |
| 36 | + # at all (see CLAUDE.md, failure 1 — `AT_FDCWD`). | |
| 37 | + TOOLCHAIN_IMAGE: espressif/idf-rust:esp32c6_1.95.0.0 | |
| 27 | 38 | |
| 28 | 39 | steps: |
| 29 | 40 | # Into a subdirectory, because Cargo.toml depends on `../neiam-portal` |
@@ -57,17 +68,17 @@ jobs:
| 57 | 68 | git clone --depth 1 "$FORGE_URL/neiam/neiam-portal.git" neiam-portal |
| 58 | 69 | git -C neiam-portal log -1 --format='neiam-portal at %h %s' |
| 59 | 70 | |
| 60 | − # ~/.espressif is the expensive one: ESP-IDF v5.2.3 plus its toolchains, | |
| 61 | − # ~2GB and ~15 minutes to reinstall. Keyed on the version pinned in | |
| 71 | + # `.home` is the container's HOME, kept inside the workspace precisely so | |
| 72 | + # it can be cached from out here. ESP-IDF v5.2.3 and its toolchains land | |
| 73 | + # in .home/.espressif — ~2GB and ~15 minutes to reinstall, which is the | |
| 74 | + # whole reason this step exists. Keyed on the version pinned in | |
| 62 | 75 | # .cargo/config.toml, so bumping ESP-IDF invalidates it and nothing else |
| 63 | 76 | # does. |
| 64 | 77 | - name: Cache ESP-IDF and cargo |
| 65 | 78 | uses: actions/cache@v4 |
| 66 | 79 | with: |
| 67 | 80 | path: | |
| 68 | − ~/.espressif | |
| 69 | − ~/.cargo/registry | |
| 70 | − ~/.cargo/git | |
| 81 | + .home | |
| 71 | 82 | lambent-flex-c6/target |
| 72 | 83 | key: esp-${{ hashFiles('lambent-flex-c6/.cargo/config.toml', 'lambent-flex-c6/Cargo.toml', 'lambent-flex-c6/sdkconfig.defaults') }} |
| 73 | 84 | restore-keys: esp- |
@@ -94,40 +105,74 @@ jobs:
| 94 | 105 | # regardless. A factory-fresh device flashed with this comes up in the |
| 95 | 106 | # captive portal, which is the correct behaviour for a build that knows |
| 96 | 107 | # nothing about anyone's network. |
| 97 | − - name: Build | |
| 98 | − working-directory: lambent-flex-c6 | |
| 99 | − env: | |
| 100 | − LAMBENT_FLEX_VERSION: ${{ env.VERSION }} | |
| 108 | + - name: Build and package | |
| 101 | 109 | run: | |
| 102 | 110 | set -euo pipefail |
| 103 | − [ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh" | |
| 104 | − cargo build --release | |
| 105 | − | |
| 106 | − - name: Package | |
| 111 | + mkdir -p .home | |
| 112 | + | |
| 113 | + # As root, then chown back: the workspace belongs to whatever user | |
| 114 | + # the runner runs as, the image's own user is `esp`, and the two | |
| 115 | + # need not agree. Root can write either way, and the chown at the | |
| 116 | + # end hands the results back so the steps after this one (and the | |
| 117 | + # cache) can read them. | |
| 118 | + # | |
| 119 | + # HOME points inside the workspace so ~/.espressif and ~/.cargo are | |
| 120 | + # cacheable; RUSTUP_HOME stays in the image, where the pinned | |
| 121 | + # nightly already lives — pointing it at the workspace would make | |
| 122 | + # rustup fetch a current nightly, which is the one thing this pin | |
| 123 | + # exists to prevent. | |
| 124 | + docker run --rm \ | |
| 125 | + --user 0:0 \ | |
| 126 | + -v "$PWD:/work" \ | |
| 127 | + -w /work/lambent-flex-c6 \ | |
| 128 | + -e HOME=/work/.home \ | |
| 129 | + -e CARGO_HOME=/work/.home/.cargo \ | |
| 130 | + -e RUSTUP_HOME=/home/esp/.rustup \ | |
| 131 | + -e LAMBENT_FLEX_VERSION="$VERSION" \ | |
| 132 | + -e HOST_UID="$(id -u)" \ | |
| 133 | + -e HOST_GID="$(id -g)" \ | |
| 134 | + "$TOOLCHAIN_IMAGE" \ | |
| 135 | + bash -euo pipefail -c ' | |
| 136 | + # ESP-IDF builds with cmake and the image ships ninja but not | |
| 137 | + # cmake. embuild installs one into IDF_TOOLS_PATH when it has | |
| 138 | + # to; having the system one is faster and one less thing to be | |
| 139 | + # surprised by. | |
| 140 | + if ! command -v cmake >/dev/null; then | |
| 141 | + apt-get update -qq | |
| 142 | + apt-get install -y --no-install-recommends cmake >/dev/null | |
| 143 | + fi | |
| 144 | + | |
| 145 | + cargo build --release | |
| 146 | + | |
| 147 | + mkdir -p dist | |
| 148 | + | |
| 149 | + # The OTA payload: the app image alone, which is what gets | |
| 150 | + # written into the inactive slot. | |
| 151 | + espflash save-image --chip esp32c6 \ | |
| 152 | + --partition-table partitions.csv \ | |
| 153 | + target/riscv32imac-esp-espidf/release/lambent-flex-c6 \ | |
| 154 | + dist/lambent-flex-c6.bin | |
| 155 | + | |
| 156 | + # …and everything a device needs over USB, for the one-time | |
| 157 | + # migration onto the two-slot layout (and for any board that has | |
| 158 | + # to be recovered with a cable). Merged, so it flashes at 0x0 in | |
| 159 | + # one go. | |
| 160 | + build_dir=$(./tools/idf-build-dir.sh release) | |
| 161 | + cp "$build_dir/bootloader/bootloader.bin" dist/bootloader.bin | |
| 162 | + espflash save-image --chip esp32c6 --merge --skip-padding \ | |
| 163 | + --partition-table partitions.csv \ | |
| 164 | + --bootloader dist/bootloader.bin \ | |
| 165 | + target/riscv32imac-esp-espidf/release/lambent-flex-c6 \ | |
| 166 | + dist/flash-all.bin | |
| 167 | + cp partitions.csv dist/partitions.csv | |
| 168 | + | |
| 169 | + chown -R "$HOST_UID:$HOST_GID" /work | |
| 170 | + ' | |
| 171 | + | |
| 172 | + - name: Check it still fits in a slot | |
| 107 | 173 | working-directory: lambent-flex-c6 |
| 108 | 174 | run: | |
| 109 | 175 | set -euo pipefail |
| 110 | − mkdir -p dist | |
| 111 | − | |
| 112 | − # The OTA payload: the app image alone, which is what gets written | |
| 113 | − # into the inactive slot. | |
| 114 | − espflash save-image --chip esp32c6 \ | |
| 115 | − --partition-table partitions.csv \ | |
| 116 | − target/riscv32imac-esp-espidf/release/lambent-flex-c6 \ | |
| 117 | − dist/lambent-flex-c6.bin | |
| 118 | − | |
| 119 | − # …and everything a device needs over USB, for the one-time | |
| 120 | − # migration onto the two-slot layout (and for any board that has to | |
| 121 | − # be recovered with a cable). Merged, so it flashes at 0x0 in one go. | |
| 122 | − build_dir=$(./tools/idf-build-dir.sh release) | |
| 123 | − cp "$build_dir/bootloader/bootloader.bin" dist/bootloader.bin | |
| 124 | − espflash save-image --chip esp32c6 --merge --skip-padding \ | |
| 125 | − --partition-table partitions.csv \ | |
| 126 | − --bootloader dist/bootloader.bin \ | |
| 127 | − target/riscv32imac-esp-espidf/release/lambent-flex-c6 \ | |
| 128 | − dist/flash-all.bin | |
| 129 | − cp partitions.csv dist/partitions.csv | |
| 130 | − | |
| 131 | 176 | # An image that outgrew its slot installs as a truncated write and |
| 132 | 177 | # bricks whatever took it. Cheaper to find out here. |
| 133 | 178 | slot=$(awk -F, '/^ota_0/ {gsub(/ /, "", $5); print $5}' partitions.csv) |
modified
CLAUDE.md
+30
−9
@@ -445,23 +445,44 @@ label and state are read **once** at boot and cached for the status endpoint.
| 445 | 445 | Querying per request would report "unknown" precisely while an update was in |
| 446 | 446 | progress — the opposite of what is happening. |
| 447 | 447 | |
| 448 | −### CI builds in Espressif's container, pinned | |
| 448 | +### CI builds in Espressif's container, pinned — but not as the job container | |
| 449 | 449 | |
| 450 | 450 | `espressif/idf-rust:esp32c6_1.95.0.0`, not `esp32c6_latest`. The pin fixes the |
| 451 | 451 | nightly the image installed at build time (2026-04-27), and `rust-toolchain.toml` |
| 452 | 452 | asks for "nightly" — which rustup resolves to whatever is already installed |
| 453 | 453 | rather than fetching a newer one. That is what keeps CI away from the current |
| 454 | 454 | nightly and the `AT_FDCWD` regression in failure 1 above. If CI ever starts |
| 455 | −failing on `AT_FDCWD`, something moved that pin. | |
| 456 | − | |
| 457 | −The image carries rustup, espup's nightly, ldproxy and espflash, but **not | |
| 458 | −ESP-IDF** — esp-idf-sys clones v5.2.3 and installs its tools on the first run, | |
| 459 | −which is what `~/.espressif` is cached for. Debian bookworm, so `libxml2.so.2` | |
| 460 | −is the system soname and the symlink workaround above is not needed there. | |
| 455 | +failing on `AT_FDCWD`, something moved that pin. It is also why the workflow | |
| 456 | +sets `RUSTUP_HOME` back into the image rather than into the cached workspace: | |
| 457 | +a fresh RUSTUP_HOME would have rustup fetch a current nightly and undo the pin. | |
| 458 | + | |
| 459 | +**The job runs on the host and the compiler runs in one `docker run`.** The | |
| 460 | +obvious shape — `container: espressif/idf-rust:…` — fails immediately, and run | |
| 461 | +#1 of this repo is the proof: the runner executes JavaScript actions with node | |
| 462 | +*inside* the job container, and that image has no node at all (verified from | |
| 463 | +its registry config: `git curl gcc clang ninja-build unzip libudev-dev tar | |
| 464 | +xz-utils libusb libssl-dev pkg-config python3`, nothing else). | |
| 465 | +`actions/checkout@v4` died in one second; `actions/cache@v4` would have died | |
| 466 | +next. Inverted, checkout and cache run on the host where node exists, and the | |
| 467 | +image is used for the one thing it is for. | |
| 468 | + | |
| 469 | +Consequences of the inversion, all in the workflow: | |
| 470 | + | |
| 471 | +- The container runs `--user 0:0` and `chown`s the workspace back at the end. | |
| 472 | + The runner's user and the image's `esp` user need not agree, and root can | |
| 473 | + write either way. | |
| 474 | +- `HOME` points at `.home` inside the workspace, so `~/.espressif` (ESP-IDF | |
| 475 | + v5.2.3 plus toolchains, ~2GB, ~15 minutes to install) and `~/.cargo` land | |
| 476 | + somewhere `actions/cache` can see from the host. | |
| 477 | +- cmake is apt-installed in the container if missing — the image ships ninja | |
| 478 | + but not cmake. | |
| 479 | + | |
| 480 | +Debian bookworm, so `libxml2.so.2` is the system soname and the symlink | |
| 481 | +workaround above is not needed there. | |
| 461 | 482 | |
| 462 | 483 | `Cargo.toml` depends on `../neiam-portal` by path, so the workflow clones it |
| 463 | −next to the checkout. **That repository must exist on the forge**; it is not | |
| 464 | −there as of this writing, and CI cannot build until it is pushed. | |
| 484 | +next to the checkout. That repository was pushed to the forge on 2026-09-16 | |
| 485 | +and is public, which is what makes the anonymous clone work. | |
| 465 | 486 | |
| 466 | 487 | ## Pinout and the indicator LED |
| 467 | 488 |
Parents: 9d79b3d