name: firmware
# Every build on master publishes, because the device updates to whatever the
# forge calls `latest` — so "published" and "what the fleet will run" are the
# same statement. A tag publishes under its own name instead of a `+ci…`
# version, which is the only difference between the two.
on:
push:
branches: [master]
tags: ['v*']
# A newer push supersedes this run: on a branch or PR the run in
# progress is cancelled; on master it finishes, and only a run still
# waiting is replaced.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
firmware:
# No `container:` — the compiler runs in one nested `docker run` instead.
#
# The obvious shape, `container: espressif/idf-rust:…`, does not work: the
# runner executes JavaScript actions with node *inside* the job container,
# and that image has no node (its apt line is git curl gcc clang
# ninja-build unzip libudev-dev tar xz-utils libusb libssl-dev pkg-config
# python3 — and nothing else). `actions/checkout@v4` died in one second on
# run #1, and `actions/cache@v4` would have died next.
#
# So the job takes the runner's own image, where node exists and checkout
# and cache work, and the toolchain image is used for the one thing it is
# actually for. Note that this job is itself in a container (this runner
# uses rootless podman), which is what the mount dance in "Build and
# package" is about.
runs-on: self-hosted
steps:
# Into a subdirectory, because Cargo.toml depends on `../neiam-portal`
# by path and the two have to be siblings.
- uses: actions/checkout@v4
with:
path: lambent-flex-c6
- name: Resolve the forge URL
env:
CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
run: |
set -euo pipefail
forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
if [ -z "$forge_url" ]; then
echo "No forge URL available." >&2
echo "Set a CI_FORGE_URL variable on this repository or its org." >&2
exit 1
fi
echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
# The path dependency. A plain clone rather than a second
# actions/checkout: `github.token` is scoped to this repository alone, so
# it would authenticate as the wrong thing — and neiam-portal is public,
# which is exactly what anonymous clone is for.
- name: Fetch neiam-portal
run: |
set -euo pipefail
git clone --depth 1 "$FORGE_URL/neiam/neiam-portal.git" neiam-portal
git -C neiam-portal log -1 --format='neiam-portal at %h %s'
# `.home` is the container's HOME, kept inside the workspace precisely so
# it can be cached from out here. ESP-IDF v5.2.3 and its toolchains land
# in .home/.espressif — ~2GB and ~15 minutes to reinstall, which is the
# whole reason this step exists. Keyed on the version pinned in
# .cargo/config.toml, so bumping ESP-IDF invalidates it and nothing else
# does.
- name: Cache ESP-IDF and cargo
uses: actions/cache@v4
with:
path: |
.home
lambent-flex-c6/target
key: esp-${{ hashFiles('lambent-flex-c6/.cargo/config.toml', 'lambent-flex-c6/Cargo.toml', 'lambent-flex-c6/sdkconfig.defaults') }}
restore-keys: esp-
- name: Version
run: |
set -euo pipefail
base=$(grep -m1 '^version' lambent-flex-c6/Cargo.toml | cut -d'"' -f2)
# A tag is a release and says so; everything else is stamped with the
# date and commit it came from, which is what a device reports back
# over /status and what a person needs to find the build again.
#
# Matched on GITHUB_REF rather than GITHUB_REF_TYPE/GITHUB_REF_NAME,
# neither of which this forge sets — its runner context carries
# `ref`, `sha` and `repository` and nothing else of this kind. The
# tag branch had never run, and would have published an *empty*
# version string the first time somebody tagged a release. Which
# devices would then install, since empty differs from what they run.
case "${GITHUB_REF:-}" in
refs/tags/*)
version=${GITHUB_REF#refs/tags/}
version=${version#v}
;;
*)
version="${base}+ci$(date -u +%Y%m%d).g${GITHUB_SHA:0:7}"
;;
esac
: "${version:?the version came out empty}"
echo "version: $version"
echo "VERSION=$version" >> "$GITHUB_ENV"
# No cfg.toml exists here and none is created: a published image must
# carry no credentials, and a provisioned device reads its own from NVS
# regardless. A factory-fresh device flashed with this comes up in the
# captive portal, which is the correct behaviour for a build that knows
# nothing about anyone's network.
- name: Build and package
run: |
set -euo pipefail
# Espressif's own Rust image, pinned to a version rather than
# `esp32c6_latest`, for the ESP-IDF-capable toolchain it carries:
# rustup, ldproxy and espflash.
#
# It is NOT what pins the compiler — rust-toolchain.toml is. Run #5
# is why that distinction is written down: the image's `nightly` was
# missing rust-src, so rustup resynced the channel to satisfy the
# toolchain file and pulled the current nightly, which cannot build
# `std` for this target at all (CLAUDE.md, failure 1 — `AT_FDCWD`).
# A dated channel has nothing to resync to.
#
# Kept next to the command that uses it. Run #4 lost the image
# argument from the `docker run` below in an edit, podman quietly
# took the next word — `bash` — as the image name, and the build ran
# in an Alpine image until it asked for apt-get. A name three lines
# from its use is harder to drop than one at the top of the job, and
# the `:?` below turns "empty" into an error rather than into a
# surprising image.
IMAGE=espressif/idf-rust:esp32c6_1.95.0.0
: "${IMAGE:?the toolchain image must not be empty}"
mkdir -p .home
# `bash: fork: retry: Resource temporarily unavailable` killed run
# #2 here. That is a process ceiling, not a build error: cargo and
# ninja both default to one job per core, and an ESP-IDF build plus
# a Rust codegen fan-out is hundreds of processes on a machine with
# many of them. Whatever the ceiling turns out to be — a pids cgroup,
# RLIMIT_NPROC, or memory — asking for less is the fix that does not
# depend on knowing which.
#
# Four is deliberately modest. This build is dominated by one cold
# ESP-IDF install and link, not by parallel compilation, so the cost
# is small and the failure it avoids is total.
cores=$(nproc)
jobs=$(( cores > 4 ? 4 : cores ))
echo "building with $jobs jobs (of $cores cores)"
# Printed rather than interpreted: if this still dies, these four
# numbers say which ceiling it was, and reading them out of a log
# beats guessing at it a second time.
echo "host: nproc=$cores ulimit-u=$(ulimit -u) pids.max=$(cat /sys/fs/cgroup/pids.max 2>/dev/null || echo n/a)"
free -m 2>/dev/null | head -2 || true
# The job itself runs in a container (rootless podman), so `$PWD`
# here is a path inside *that* container, not on the host. Handing
# it to `-v` mounted an empty directory and run #3 died with "could
# not find Cargo.toml in /work/lambent-flex-c6".
#
# `--volumes-from` is the fix: the nested container inherits the job
# container's mounts at the same paths, so $PWD means the same thing
# on both sides. Podman writes its own container id to
# /run/.containerenv, which is more reliable than $(hostname) —
# act sets a hostname of its own.
cid=""
if [ -f /run/.containerenv ]; then
cid=$(sed -n 's/^id="\(.*\)"$/\1/p' /run/.containerenv)
fi
[ -n "$cid" ] || cid=$(hostname)
echo "job container: ${cid:-<none>}"
mount=(--volumes-from "$cid")
if ! docker run --rm "${mount[@]}" -w "$PWD/lambent-flex-c6" \
"$IMAGE" test -f Cargo.toml 2>/dev/null; then
# Not in a container after all (a runner label mapped to :host),
# or volumes-from is unavailable: a plain bind mount is then
# correct, because $PWD really is a host path.
echo "volumes-from did not expose the workspace; trying a bind mount"
mount=(-v "$PWD:$PWD")
if ! docker run --rm "${mount[@]}" -w "$PWD/lambent-flex-c6" \
"$IMAGE" test -f Cargo.toml 2>/dev/null; then
echo "Neither mount strategy exposes the checkout. Diagnostics:" >&2
cat /etc/os-release 2>/dev/null | head -2 >&2 || true
echo "pwd=$PWD uid=$(id -u)" >&2
cat /run/.containerenv 2>/dev/null >&2 || echo "(no /run/.containerenv)" >&2
docker version --format '{{.Server.Version}}' >&2 2>/dev/null || true
exit 1
fi
fi
# Runs as root, then chowns the workspace back: the runner's user
# and the image's `esp` user need not agree, and root can write
# either way.
#
# HOME, CARGO_HOME and RUSTUP_HOME all point inside the workspace,
# so they are cacheable — and so that nothing writes into the image.
# Run #5 tried the image's own /home/esp/.rustup and rustup failed
# with "Invalid cross-device link (os error 18)": renaming a file out
# of a lower overlayfs layer is EXDEV under rootless podman.
#
# The toolchain that lands there is pinned by rust-toolchain.toml to
# a dated nightly. Run #5 also showed why that matters: the image's
# `nightly` was missing rust-src, so rustup went to satisfy the
# toolchain file and resynced the channel — pulling *today\'s*
# nightly, the one that cannot build std for this target at all.
# A dated channel has nothing to resync to.
docker run --rm \
--user 0:0 \
"${mount[@]}" \
-w "$PWD/lambent-flex-c6" \
-e HOME="$PWD/.home" \
-e CARGO_HOME="$PWD/.home/.cargo" \
-e RUSTUP_HOME="$PWD/.home/.rustup" \
-e LAMBENT_FLEX_VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
-e CARGO_BUILD_JOBS="$jobs" \
-e NUM_JOBS="$jobs" \
-e CMAKE_BUILD_PARALLEL_LEVEL="$jobs" \
-e MAKEFLAGS="-j$jobs" \
-e WORKSPACE="$PWD" \
--pids-limit 8192 \
"$IMAGE" \
bash -euo pipefail -c '
# ESP-IDF builds with cmake and the image ships ninja but not
# cmake. embuild installs one into IDF_TOOLS_PATH when it has
# to; having the system one is faster and one less thing to be
# surprised by.
if ! command -v cmake >/dev/null; then
apt-get update -qq
apt-get install -y --no-install-recommends cmake >/dev/null
fi
echo "container: ulimit-u=$(ulimit -u) pids.max=$(cat /sys/fs/cgroup/pids.max 2>/dev/null || echo n/a) jobs=$CARGO_BUILD_JOBS"
# Install it explicitly, so a toolchain problem reports itself
# here rather than as a confusing failure inside cargo. Reads
# the channel from rust-toolchain.toml; idempotent once cached.
rustup show active-toolchain || rustup toolchain install --profile minimal
rustup show
cargo build --release
mkdir -p dist
# The OTA payload: the app image alone, which is what gets
# written into the inactive slot.
espflash save-image --chip esp32c6 \
--partition-table partitions.csv \
target/riscv32imac-esp-espidf/release/lambent-flex-c6 \
dist/lambent-flex-c6.bin
# …and everything a device needs over USB, for the one-time
# migration onto the two-slot layout (and for any board that has
# to be recovered with a cable). Merged, so it flashes at 0x0 in
# one go.
build_dir=$(./tools/idf-build-dir.sh release)
cp "$build_dir/bootloader/bootloader.bin" dist/bootloader.bin
espflash save-image --chip esp32c6 --merge --skip-padding \
--partition-table partitions.csv \
--bootloader dist/bootloader.bin \
target/riscv32imac-esp-espidf/release/lambent-flex-c6 \
dist/flash-all.bin
cp partitions.csv dist/partitions.csv
chown -R "$HOST_UID:$HOST_GID" "$WORKSPACE"
'
- name: Check it still fits in a slot
working-directory: lambent-flex-c6
run: |
set -euo pipefail
# An image that outgrew its slot installs as a truncated write and
# bricks whatever took it. Cheaper to find out here.
slot=$(awk -F, '/^ota_0/ {gsub(/ /, "", $5); print $5}' partitions.csv)
slot=$((slot))
size=$(stat -c %s dist/lambent-flex-c6.bin)
echo "app image ${size} bytes, ota slot ${slot} bytes ($((100 * size / slot))%)"
if [ "$size" -gt "$slot" ]; then
echo "The app image does not fit in an OTA slot." >&2
exit 1
fi
# `${{ github.token }}` is minted for this job and may write this
# repository's packages and nothing else, so there is no secret to
# create first.
#
# The app image goes first on purpose. Publishing creates the version,
# and the version becomes the manifest's `latest` the moment it exists —
# so a device checking in mid-publish should find the one file it
# actually needs already there.
- name: Publish to the raw registry
working-directory: lambent-flex-c6
env:
REPOSITORY: ${{ github.repository }}
TOKEN: ${{ github.token }}
run: |
set -euo pipefail
for file in lambent-flex-c6.bin flash-all.bin bootloader.bin partitions.csv; do
echo "publishing $file"
curl --fail-with-body -sS \
-u "gitgud-actions:$TOKEN" \
--upload-file "dist/$file" \
"$FORGE_URL/pkg/raw/$REPOSITORY/$VERSION/$file"
echo
done
echo "Devices will pick this up from:"
echo " $FORGE_URL/pkg/raw/$REPOSITORY/manifest.json"
neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
14.9 KiB · text
History
6f452af