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*']
jobs:
firmware:
# On the host, NOT in a container — and the compiler runs in one `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` dies in one second,
# and `actions/cache@v4` would die next.
#
# Inverting it costs nothing and fixes the lot: checkout and cache run on
# the host where node exists, and the toolchain image is used for the one
# thing it is actually for.
runs-on: self-hosted
env:
# Espressif's own Rust image, pinned to a version rather than
# `esp32c6_latest`. The pin is load-bearing twice over: it fixes the
# ESP-IDF-capable toolchain, and it fixes the *nightly* the image
# installed when it was built. `rust-toolchain.toml` asks for "nightly",
# rustup uses whatever nightly is already installed rather than fetching
# a newer one, and a current nightly does not build `std` for this target
# at all (see CLAUDE.md, failure 1 — `AT_FDCWD`).
TOOLCHAIN_IMAGE: espressif/idf-rust:esp32c6_1.95.0.0
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.
if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then
version=${GITHUB_REF_NAME#v}
else
version="${base}+ci$(date -u +%Y%m%d).g${GITHUB_SHA:0:7}"
fi
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
mkdir -p .home
# As root, then chown back: the workspace belongs to whatever user
# the runner runs as, the image's own user is `esp`, and the two
# need not agree. Root can write either way, and the chown at the
# end hands the results back so the steps after this one (and the
# cache) can read them.
#
# HOME points inside the workspace so ~/.espressif and ~/.cargo are
# cacheable; RUSTUP_HOME stays in the image, where the pinned
# nightly already lives — pointing it at the workspace would make
# rustup fetch a current nightly, which is the one thing this pin
# exists to prevent.
docker run --rm \
--user 0:0 \
-v "$PWD:/work" \
-w /work/lambent-flex-c6 \
-e HOME=/work/.home \
-e CARGO_HOME=/work/.home/.cargo \
-e RUSTUP_HOME=/home/esp/.rustup \
-e LAMBENT_FLEX_VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$TOOLCHAIN_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
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" /work
'
- 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.
9.1 KiB · text
History
e6c7b44