Mount the workspace the nested container can actually see

7636eed · Gabriel Morell · 2026-09-16 18:38

4 files +83 -35
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/firmware.yml
+54 −18
@@ -11,19 +11,20 @@ on:
11 11
12 12 jobs:
13 13 firmware:
14 − # On the host, NOT in a container — and the compiler runs in one `docker
15 − # run` instead.
14 + # No `container:` — the compiler runs in one nested `docker run` instead.
16 15 #
17 16 # The obvious shape, `container: espressif/idf-rust:…`, does not work: the
18 17 # runner executes JavaScript actions with node *inside* the job container,
19 18 # and that image has no node (its apt line is git curl gcc clang
20 19 # 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.
20 + # python3 — and nothing else). `actions/checkout@v4` died in one second on
21 + # run #1, and `actions/cache@v4` would have died next.
23 22 #
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.
23 + # So the job takes the runner's own image, where node exists and checkout
24 + # and cache work, and the toolchain image is used for the one thing it is
25 + # actually for. Note that this job is itself in a container (this runner
26 + # uses rootless podman), which is what the mount dance in "Build and
27 + # package" is about.
27 28 runs-on: self-hosted
28 29
29 30 env:
@@ -131,11 +132,46 @@ jobs:
131 132 echo "host: nproc=$cores ulimit-u=$(ulimit -u) pids.max=$(cat /sys/fs/cgroup/pids.max 2>/dev/null || echo n/a)"
132 133 free -m 2>/dev/null | head -2 || true
133 134
134 − # As root, then chown back: the workspace belongs to whatever user
135 − # the runner runs as, the image's own user is `esp`, and the two
136 − # need not agree. Root can write either way, and the chown at the
137 − # end hands the results back so the steps after this one (and the
138 − # cache) can read them.
135 + # The job itself runs in a container (rootless podman), so `$PWD`
136 + # here is a path inside *that* container, not on the host. Handing
137 + # it to `-v` mounted an empty directory and run #3 died with "could
138 + # not find Cargo.toml in /work/lambent-flex-c6".
139 + #
140 + # `--volumes-from` is the fix: the nested container inherits the job
141 + # container's mounts at the same paths, so $PWD means the same thing
142 + # on both sides. Podman writes its own container id to
143 + # /run/.containerenv, which is more reliable than $(hostname) —
144 + # act sets a hostname of its own.
145 + cid=""
146 + if [ -f /run/.containerenv ]; then
147 + cid=$(sed -n 's/^id="\(.*\)"$/\1/p' /run/.containerenv)
148 + fi
149 + [ -n "$cid" ] || cid=$(hostname)
150 + echo "job container: ${cid:-<none>}"
151 +
152 + mount=(--volumes-from "$cid")
153 + if ! docker run --rm "${mount[@]}" -w "$PWD/lambent-flex-c6" \
154 + "$TOOLCHAIN_IMAGE" test -f Cargo.toml 2>/dev/null; then
155 + # Not in a container after all (a runner label mapped to :host),
156 + # or volumes-from is unavailable: a plain bind mount is then
157 + # correct, because $PWD really is a host path.
158 + echo "volumes-from did not expose the workspace; trying a bind mount"
159 + mount=(-v "$PWD:$PWD")
160 +
161 + if ! docker run --rm "${mount[@]}" -w "$PWD/lambent-flex-c6" \
162 + "$TOOLCHAIN_IMAGE" test -f Cargo.toml 2>/dev/null; then
163 + echo "Neither mount strategy exposes the checkout. Diagnostics:" >&2
164 + cat /etc/os-release 2>/dev/null | head -2 >&2 || true
165 + echo "pwd=$PWD uid=$(id -u)" >&2
166 + cat /run/.containerenv 2>/dev/null >&2 || echo "(no /run/.containerenv)" >&2
167 + docker version --format '{{.Server.Version}}' >&2 2>/dev/null || true
168 + exit 1
169 + fi
170 + fi
171 +
172 + # Runs as root, then chowns the workspace back: the runner's user
173 + # and the image's `esp` user need not agree, and root can write
174 + # either way.
139 175 #
140 176 # HOME points inside the workspace so ~/.espressif and ~/.cargo are
141 177 # cacheable; RUSTUP_HOME stays in the image, where the pinned
@@ -144,10 +180,10 @@ jobs:
144 180 # exists to prevent.
145 181 docker run --rm \
146 182 --user 0:0 \
147 − -v "$PWD:/work" \
148 − -w /work/lambent-flex-c6 \
149 − -e HOME=/work/.home \
150 − -e CARGO_HOME=/work/.home/.cargo \
183 + "${mount[@]}" \
184 + -w "$PWD/lambent-flex-c6" \
185 + -e HOME="$PWD/.home" \
186 + -e CARGO_HOME="$PWD/.home/.cargo" \
151 187 -e RUSTUP_HOME=/home/esp/.rustup \
152 188 -e LAMBENT_FLEX_VERSION="$VERSION" \
153 189 -e HOST_UID="$(id -u)" \
@@ -156,8 +192,8 @@ jobs:
156 192 -e NUM_JOBS="$jobs" \
157 193 -e CMAKE_BUILD_PARALLEL_LEVEL="$jobs" \
158 194 -e MAKEFLAGS="-j$jobs" \
195 + -e WORKSPACE="$PWD" \
159 196 --pids-limit 8192 \
160 − "$TOOLCHAIN_IMAGE" \
161 197 bash -euo pipefail -c '
162 198 # ESP-IDF builds with cmake and the image ships ninja but not
163 199 # cmake. embuild installs one into IDF_TOOLS_PATH when it has
@@ -194,7 +230,7 @@ jobs:
194 230 dist/flash-all.bin
195 231 cp partitions.csv dist/partitions.csv
196 232
197 − chown -R "$HOST_UID:$HOST_GID" /work
233 + chown -R "$HOST_UID:$HOST_GID" "$WORKSPACE"
198 234 '
199 235
200 236 - name: Check it still fits in a slot
modified CLAUDE.md
+29 −17
@@ -456,26 +456,38 @@ failing on `AT_FDCWD`, something moved that pin. It is also why the workflow
456 456 sets `RUSTUP_HOME` back into the image rather than into the cached workspace:
457 457 a fresh RUSTUP_HOME would have rustup fetch a current nightly and undo the pin.
458 458
459 −**The job runs on the host and the compiler runs in one `docker run`.** The
459 +**There is no `container:`; the compiler runs in a nested `docker run`.** The
460 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 −
461 +#1 is the proof: the runner executes JavaScript actions with node *inside* the
462 +job container, and that image has no node at all (verified from its registry
463 +config: `git curl gcc clang ninja-build unzip libudev-dev tar xz-utils libusb
464 +libssl-dev pkg-config python3`, nothing else). `actions/checkout@v4` died in one
465 +second; `actions/cache@v4` would have died next. So the job takes the runner's
466 +own image, where node exists, and the toolchain image is used for compiling.
467 +
468 +Three consequences, each of them a run that failed first:
469 +
470 +- **The workspace has to be mounted with `--volumes-from`, not `-v`.** This
471 + runner puts jobs in rootless podman containers, so `$PWD` inside a step is a
472 + path in *that* container and a bind mount of it lands empty — run #3 died
473 + with "could not find Cargo.toml in /work/lambent-flex-c6". `--volumes-from`
474 + inherits the job container's mounts at the same paths, so `$PWD` means the
475 + same thing on both sides. The container id comes from `/run/.containerenv`
476 + (podman writes it there); `$(hostname)` is a fallback, since act sets a
477 + hostname of its own. There is a bind-mount fallback for a runner label that
478 + maps to `:host`, and a diagnostic dump if neither works.
479 +- **Parallelism is capped at four jobs.** Run #2 died with `bash: fork: retry:
480 + Resource temporarily unavailable` — the runner's cgroup allows 4096 pids and
481 + the host has 64 cores, so cargo and ninja each defaulting to one job per core
482 + is enough to exhaust it. `CARGO_BUILD_JOBS`, `NUM_JOBS`,
483 + `CMAKE_BUILD_PARALLEL_LEVEL` and `MAKEFLAGS` are all set, since each covers a
484 + different part of the build.
471 485 - The container runs `--user 0:0` and `chown`s the workspace back at the end.
472 486 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.
487 + write either way. `HOME` points at `.home` inside the workspace, so
488 + `~/.espressif` (ESP-IDF v5.2.3 plus toolchains, ~2GB, ~15 minutes to install)
489 + and `~/.cargo` land somewhere `actions/cache` can see. cmake is apt-installed
490 + in the container if missing — the image ships ninja but not cmake.
479 491
480 492 Debian bookworm, so `libxml2.so.2` is the system soname and the symlink
481 493 workaround above is not needed there.

Parents: 65e23ac