13.1 KiB · text History 5614525
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:
    # 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.
          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

          # 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`).
          #
          # 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 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 \
            "${mount[@]}" \
            -w "$PWD/lambent-flex-c6" \
            -e HOME="$PWD/.home" \
            -e CARGO_HOME="$PWD/.home/.cargo" \
            -e RUSTUP_HOME=/home/esp/.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"

              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"