Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/apollo-m1-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Apollo M-1 build

# Builds the apollo_m1 env (the firmware Apollo ships) for every change to the product
# branch and every pull request into it. Upstream's WLED CI does not build this env.
# No secrets are used and the token is read-only. The ELF and map are kept for 90 days.
on:
push:
branches: [m1-wled-update, main]
pull_request:
branches: [m1-wled-update, main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: apollo-m1-build-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- name: Build apollo_m1
run: pio run -e apollo_m1
- name: Resolved libraries
run: pio pkg list -e apollo_m1
- uses: actions/upload-artifact@v4
with:
name: apollo_m1-${{ github.sha }}
retention-days: 90
path: |
.pio/build/apollo_m1/firmware.bin
.pio/build/apollo_m1/firmware.elf
.pio/build/apollo_m1/firmware.map
.pio/build/apollo_m1/bootloader.bin
.pio/build/apollo_m1/partitions.bin
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,9 @@ The Apollo rules below take precedence over anything inherited from upstream.
consumers first.
- Each shipped release is archived (binaries, source bundle, libraries, toolchain, debug
symbols) in the company Google Drive under Apollo Firmware Vault/M-1/<tag>.
- Build with env `apollo_m1`. Firmware changes need the virgin-boot gate and the
QA_CHECKLIST.md D13 AP walkthrough before release.
- Build with env `apollo_m1` (its libraries are pinned in platformio.ini; keep them pinned).
Firmware changes need the virgin-boot gate, the QA_CHECKLIST.md D13 AP walkthrough and an
OTA gate before release.
- Releases follow apollo/RELEASE_CHECKLIST.md: push the m1-bN tag first, then
`gh release create --verify-tag` (without it gh tags the default branch). Release
immutability is on, and m1-* tags cannot be moved or deleted.
77 changes: 77 additions & 0 deletions apollo/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# M-1 firmware release checklist (bundle m1-bN)

Every step, in order. The install page and the FlashScripts flash script both serve
`M-1_full_install.bin` from the release, so a release is not done until both are
re-pointed and hash-checked. Nothing in this process touches upstream WLED.

## 1. Scope and branch
- Work on a branch off the product branch (see CLAUDE.md, Branches) and merge by PR.
- Upstream changes come only from an upstream release tag. A cherry-pick from upstream
(for example a security fix before the next tag exists) is an explicit, recorded
exception: use `git cherry-pick -x` so the upstream commit is named.
- If apollo/fs changes, the first-boot look changes too: update FlashScripts LED_PASS text
(step 7) and the customer docs.

## 2. Gates (on hardware, with the exact build you will ship)
- Virgin-boot gate (factory-erased unit, full install).
- QA_CHECKLIST.md D13 AP walkthrough.
- OTA gate: previous bundle (m1-bN-1) to this build through the WLED update page, with
settings and presets surviving. When WLED-MM units are still in the field, also repeat
QA_CHECKLIST.md D2 (WLED-MM 14.5.1 to this build).

## 3. Build the artifacts
- Clean tree at the commit you gated: `git status` shows nothing to commit.
- `apollo/build_artifacts.sh`. On Apple silicon without Rosetta, mklittlefs cannot run:
if apollo/fs is unchanged since the last release, pass
`--littlefs <last release's littlefs.bin>` (in the firmware vault).
- Keep everything in apollo/out: both binaries, littlefs.bin, firmware.elf, firmware.map,
bootloader.bin, partitions.bin, BUILD_INFO.txt, SHA256SUMS.txt.

## 4. Tag (push the tag BEFORE creating the release)
```
git tag -a m1-bN -m "Apollo M-1 firmware bundle bN (gated YYYY-MM-DD)" <gated commit>
git push origin refs/tags/m1-bN # never `git push --tags`
git ls-remote origin 'refs/tags/m1-bN^{}' # must print <gated commit>
```
`m1-*` tags cannot be moved or deleted once pushed (repo ruleset), so check the commit first.

## 5. GitHub release (immutable once published)
```
gh release create m1-bN -R ApolloAutomation/WLED-M1 --verify-tag \
--title "Apollo M-1 firmware, bundle bN" --notes-file <notes.md> \
apollo/out/M-1_full_install.bin apollo/out/M-1_ota.bin
gh release view m1-bN -R ApolloAutomation/WLED-M1 --json assets --jq '.assets[] | .name + " " + .digest'
```
- `--verify-tag` stops gh from tagging the default branch when the tag is missing.
- The asset digests must equal the two lines in apollo/out/SHA256SUMS.txt.
- Release immutability is on: assets cannot be replaced after publishing. A bad release
gets a new tag (m1-bN+1); the tag name cannot be reused.
- Publishing starts an "apollo-pages" run that fails on the github-pages branch policy.
That red run is expected; deploy in step 6.

## 6. Install page (install.apolloautomation.com/#/m-1)
```
gh workflow run apollo-pages.yml -R ApolloAutomation/WLED-M1 --ref main
```
- It copies apollo/installer/manifest.json from `main` and `M-1_full_install.bin` from the
release marked Latest. If the WLED base version changed, update the manifest "version"
and the installer's devices.json label first.
- After the run succeeds (allow up to 10 minutes of CDN cache):
`curl -sL https://apolloautomation.github.io/WLED-M1/M-1_full_install.bin | shasum -a 256`
must equal the new full-install hash.

## 7. FlashScripts (ApolloAutomation/FlashScripts, M-1/flash-prod.sh)
- In one PR: PINNED_URL (new tag), PINNED_SHA256, PINNED_VERSION, and LED_PASS /
LED_PASS_SHORT if the first-boot look changed.
- Verify: `curl -sL <PINNED_URL> | shasum -a 256` equals PINNED_SHA256.

## 8. Firmware vault
- Company Google Drive, Apollo Firmware Vault/M-1/m1-bN: everything from apollo/out, the
release notes, and `git bundle create WLED-M1-all.bundle --all`.
- `shasum -a 256 -c SHA256SUMS.txt` inside the Drive folder.

## 9. Final check
- Install page: manifest returns 200 and the bin hash is the new one.
- FlashScripts PINNED_URL hash is the new one.
- WLED-MM-M1 manifests still return 200 (the installer still offers them).
- Customer docs and wiki match what the unit now does.
100 changes: 85 additions & 15 deletions apollo/build_artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@
# LittleFS with factory cfg.json/presets.json)
# out/M-1_ota.bin - app only, for the WLED OTA update page; does not
# touch the filesystem
# plus what is needed to support the release later: firmware.elf and firmware.map
# (decode crash backtraces), bootloader.bin, partitions.bin, littlefs.bin,
# BUILD_INFO.txt (commit and tool versions) and SHA256SUMS.txt.
#
# Usage: apollo/build_artifacts.sh [--skip-pio]
# Usage: apollo/build_artifacts.sh [--skip-pio] [--littlefs <file>] [--allow-dirty]
# --skip-pio package the existing .pio build instead of running pio first
# (refused if that build is older than the current commit)
# --littlefs <file> use a prebuilt LittleFS image instead of running mklittlefs,
# e.g. the previous release's littlefs.bin when apollo/fs is
# unchanged (mklittlefs from PlatformIO is x86-only on macOS and
# needs Rosetta on Apple silicon)
# --allow-dirty allow uncommitted changes to tracked files (never for releases)
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
Expand All @@ -22,46 +32,106 @@ APP_SLOT_SIZE=$((0x300000))
FS_OFF=0x610000
FS_SIZE=$((0x9E0000))

MKLITTLEFS="$HOME/.platformio/packages/tool-mklittlefs/mklittlefs"
BOOT_APP0="$(ls "$HOME"/.platformio/packages/framework-arduinoespressif32*/tools/partitions/boot_app0.bin 2>/dev/null | head -1)"
ESPTOOL="esptool.py"
command -v "$ESPTOOL" >/dev/null || ESPTOOL="python3 -m esptool"
SKIP_PIO=0
LITTLEFS_IN=""
ALLOW_DIRTY=0
while [ $# -gt 0 ]; do
case "$1" in
--skip-pio) SKIP_PIO=1 ;;
--littlefs) LITTLEFS_IN="${2:?--littlefs needs a file}"; shift ;;
--allow-dirty) ALLOW_DIRTY=1 ;;
*) echo "unknown option: $1"; exit 1 ;;
esac
shift
done

# Use PlatformIO's own tools (the versions the platform pins), not whatever is on PATH.
PIO_HOME="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}"
PIO_PY="$PIO_HOME/penv/bin/python"
[ -x "$PIO_PY" ] || PIO_PY="python3"
ESPTOOL_PY="$PIO_HOME/packages/tool-esptoolpy/esptool.py"
MKLITTLEFS="$PIO_HOME/packages/tool-mklittlefs/mklittlefs"
BOOT_APP0="$PIO_HOME/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin"

[ -f "$ESPTOOL_PY" ] || { echo "esptool not found at $ESPTOOL_PY - build the $ENV_NAME env once first"; exit 1; }
[ -f "$BOOT_APP0" ] || { echo "boot_app0.bin not found at $BOOT_APP0"; exit 1; }
esptool() { "$PIO_PY" "$ESPTOOL_PY" "$@"; }

[ -x "$MKLITTLEFS" ] || { echo "mklittlefs not found at $MKLITTLEFS"; exit 1; }
[ -n "$BOOT_APP0" ] || { echo "boot_app0.bin not found in PlatformIO packages"; exit 1; }
if [ "$ALLOW_DIRTY" = 0 ] && [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]; then
echo "tracked files have uncommitted changes; commit them or pass --allow-dirty (not for releases)"; exit 1
fi

if [ "${1:-}" != "--skip-pio" ]; then
if [ "$SKIP_PIO" = 0 ]; then
(cd "$ROOT" && pio run -e "$ENV_NAME")
fi

for f in bootloader.bin partitions.bin firmware.bin; do
for f in bootloader.bin partitions.bin firmware.bin firmware.elf firmware.map; do
[ -f "$BUILD/$f" ] || { echo "missing $BUILD/$f - build the $ENV_NAME env first"; exit 1; }
done

APP_SIZE=$(stat -f%z "$BUILD/firmware.bin" 2>/dev/null || stat -c%s "$BUILD/firmware.bin")
mtime() { stat -f%m "$1" 2>/dev/null || stat -c%Y "$1"; }
fsize() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1"; }

# A leftover bench build must never be packaged as a release.
if [ "$SKIP_PIO" = 1 ] && [ "$(mtime "$BUILD/firmware.bin")" -lt "$(git -C "$ROOT" log -1 --format=%ct)" ]; then
echo "$BUILD/firmware.bin is older than the current commit; rebuild (run without --skip-pio)"; exit 1
fi

APP_SIZE=$(fsize "$BUILD/firmware.bin")
if [ "$APP_SIZE" -gt "$APP_SLOT_SIZE" ]; then
echo "firmware.bin ($APP_SIZE bytes) exceeds the $APP_SLOT_SIZE byte OTA slot"; exit 1
fi

mkdir -p "$OUT"
# Clear only this script's own outputs so a failed run can't leave a stale mix.
(cd "$OUT" && rm -f M-1_full_install.bin M-1_ota.bin littlefs.bin firmware.elf firmware.map \
bootloader.bin partitions.bin BUILD_INFO.txt SHA256SUMS.txt)

echo "== LittleFS image from apollo/fs (offset $FS_OFF, size $FS_SIZE)"
"$MKLITTLEFS" -c "$ROOT/apollo/fs" -b 4096 -p 256 -s "$FS_SIZE" "$OUT/littlefs.bin" >/dev/null
if [ -n "$LITTLEFS_IN" ]; then
echo "== LittleFS image: prebuilt $LITTLEFS_IN"
[ "$(fsize "$LITTLEFS_IN")" -eq "$FS_SIZE" ] || { echo "$LITTLEFS_IN is not $FS_SIZE bytes"; exit 1; }
cp "$LITTLEFS_IN" "$OUT/littlefs.bin"
FS_SOURCE="prebuilt $(basename "$LITTLEFS_IN") (apollo/fs last changed in $(git -C "$ROOT" log -1 --format=%h -- apollo/fs))"
else
"$MKLITTLEFS" --version >/dev/null 2>&1 || {
echo "mklittlefs at $MKLITTLEFS does not run here (x86-only on macOS: needs Rosetta)."
echo "If apollo/fs is unchanged since the last release, pass --littlefs <that release's littlefs.bin>."
exit 1
}
echo "== LittleFS image from apollo/fs (offset $FS_OFF, size $FS_SIZE)"
"$MKLITTLEFS" -c "$ROOT/apollo/fs" -b 4096 -p 256 -s "$FS_SIZE" "$OUT/littlefs.bin" >/dev/null
FS_SOURCE="mklittlefs $("$MKLITTLEFS" --version 2>&1 | head -1) from apollo/fs"
fi

echo "== M-1_full_install.bin (merged at 0x0, ESP32-S3)"
$ESPTOOL --chip esp32s3 merge_bin -o "$OUT/M-1_full_install.bin" \
esptool --chip esp32s3 merge_bin -o "$OUT/M-1_full_install.bin" \
--flash_mode keep --flash_freq keep --flash_size 16MB \
0x0 "$BUILD/bootloader.bin" \
0x8000 "$BUILD/partitions.bin" \
"$OTADATA_OFF" "$BOOT_APP0" \
"$APP0_OFF" "$BUILD/firmware.bin" \
"$FS_OFF" "$OUT/littlefs.bin"

echo "== M-1_ota.bin (app only)"
echo "== M-1_ota.bin (app only) and support files"
cp "$BUILD/firmware.bin" "$OUT/M-1_ota.bin"
cp "$BUILD/firmware.elf" "$BUILD/firmware.map" "$BUILD/bootloader.bin" "$BUILD/partitions.bin" "$OUT/"

{
echo "commit: $(git -C "$ROOT" rev-parse HEAD) ($(git -C "$ROOT" describe --tags --always --dirty))"
echo "built: $(date -u '+%Y-%m-%dT%H:%M:%SZ') (firmware.bin from $(date -u -r "$(mtime "$BUILD/firmware.bin")" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$(mtime "$BUILD/firmware.bin")" '+%Y-%m-%dT%H:%M:%SZ'))"
echo "env: $ENV_NAME"
echo "platformio: $(pio --version 2>/dev/null || echo unknown)"
echo "esptool: $(esptool version 2>/dev/null | tail -1)"
echo "littlefs: $FS_SOURCE"
echo "web ui: $(grep -h 'define WEB_BUILD_TIME' "$ROOT"/wled00/html_ui.h 2>/dev/null | head -1)"
} > "$OUT/BUILD_INFO.txt"

(cd "$OUT" && shasum -a 256 M-1_full_install.bin M-1_ota.bin littlefs.bin firmware.elf firmware.map bootloader.bin partitions.bin > SHA256SUMS.txt)

echo
echo "Artifacts in $OUT:"
ls -l "$OUT/M-1_full_install.bin" "$OUT/M-1_ota.bin"
ls -l "$OUT"
echo
cat "$OUT/BUILD_INFO.txt"
echo
echo "App size: $APP_SIZE / $APP_SLOT_SIZE bytes ($((APP_SIZE * 100 / APP_SLOT_SIZE))% of OTA slot)"
16 changes: 16 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -924,3 +924,19 @@ build_flags = ${env:esp32s3dev_16MB_opi_hub75.build_flags}
-D ABL_MILLIAMPS_DEFAULT=0 ;; dedicated 5V supply; see FACT 4 for the upstream ABL question
-D UM_AUDIOREACTIVE_ENABLE ;; AudioReactive on by default (Justin, closes D3); rev4 no-mic impact tracked in QA D9
-D WLED_MM_HUB75_MIGRATION ;; read HUB75 bus entries written by WLED-MM cfg.json (OTA upgrade path)
-D WLED_REPO=\"ApolloAutomation/WLED-M1\" ;; fixed identity; set_metadata.py would otherwise read it from whatever remote the branch tracks
;; Pinned libraries for reproducible M-1 builds: the same list and order as the inherited
;; ${esp32s3.lib_deps} + ${hub75.lib_deps}, with the two floating entries fixed to exactly what
;; bundle b8 compiled. QuickESPNow #optional-debug is a branch (it moved on 2026-09-27) and
;; AnimatedGIF was ^1.4.7. Re-check this list against the inherited one on every upstream merge.
lib_deps =
esp32async/AsyncTCP @ 3.4.7
bitbank2/AnimatedGIF @ 1.4.7
https://github.com/Aircoookie/GifDecoder.git#bc3af189b6b1e06946569f6b4287f0b79a860f8e
https://github.com/someweisguy/esp_dmx.git#47db25d8c515e76fabcf5fc5ab0b786f98eeade0
IRremoteESP8266 @ 2.8.2
https://github.com/Makuna/NeoPixelBus.git#1d7ff38f14d04a976f9c6e365c83a232bcba04fc
https://github.com/Aircoookie/ESPAsyncWebServer.git#v2.4.2
marvinroger/AsyncMqttClient @ 0.9.0
https://github.com/blazoncek/QuickESPNow.git#193e991642cbffe1d44e7445a0a34445f69a67d8
https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-DMA.git#f17fb7fe9d487e9643f919eb5aeedea8d9d1f8d7 ;; 3.0.14
Loading