6.9 KiB · text History 9d79b3d
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:
    runs-on: self-hosted
    # 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`).
    #
    # The image carries rustup + espup's nightly, ldproxy and espflash. It does
    # NOT carry ESP-IDF: esp-idf-sys clones v5.2.3 and installs its tools on the
    # first run, which is what the cache below exists for.
    container: 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'

      # ~/.espressif is the expensive one: ESP-IDF v5.2.3 plus its toolchains,
      # ~2GB and ~15 minutes to reinstall. 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: |
            ~/.espressif
            ~/.cargo/registry
            ~/.cargo/git
            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
        working-directory: lambent-flex-c6
        env:
          LAMBENT_FLEX_VERSION: ${{ env.VERSION }}
        run: |
          set -euo pipefail
          [ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"
          cargo build --release

      - name: Package
        working-directory: lambent-flex-c6
        run: |
          set -euo pipefail
          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

          # 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"