Run the compiler in a container instead of running the job in one

e6c7b44 · Gabriel Morell · 2026-09-16 18:31

4 files +121 -55
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