name: Repo PlatformIO Build on: workflow_dispatch: inputs: owner: required: true type: string repo: required: true type: string ref: required: true type: string target_env: required: true type: string repo_build_id: required: true type: string build_key: required: true type: string resolved_source_sha: required: true type: string convex_url: required: true type: string jobs: build: runs-on: ubuntu-latest env: CONVEX_URL: ${{ inputs.convex_url }} REPO_BUILD_ID: ${{ inputs.repo_build_id }} CONVEX_BUILD_TOKEN: ${{ secrets.CONVEX_BUILD_TOKEN }} CI_PROGRESS_TOTAL: "10" PLATFORMIO_LIBDEPS_DIR: ${{ github.workspace }}/.pio-libdeps/${{ inputs.owner }}/${{ inputs.repo }}/${{ inputs.target_env }} steps: - uses: actions/checkout@v4 - name: Notify Convex — running shell: bash run: | curl -sSf -X POST "$CONVEX_URL/ingest-repo-build" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \ -d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"running\",\"github_run_id\":${{ github.run_id }}}" - name: CI progress — build started shell: bash env: STEP_INDEX: 1 LABEL: Build started on Actions run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest - name: CI progress — tooling ready shell: bash env: STEP_INDEX: 2 LABEL: Runner tooling ready run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" - name: Download source archive shell: bash env: OWNER: ${{ inputs.owner }} REPO: ${{ inputs.repo }} REF: ${{ inputs.ref }} run: | STEP_INDEX=3 LABEL="Downloading source archive" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" ENC_REF=$(python3 -c "import urllib.parse,os; print(urllib.parse.quote(os.environ['REF'], safe=''))") curl -fsSL -o /tmp/src.zip "https://codeload.github.com/${OWNER}/${REPO}/zip/${ENC_REF}" - name: CI progress — restoring PlatformIO global cache shell: bash env: STEP_INDEX: 4 LABEL: Restoring PlatformIO global cache (toolchains) run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" - name: Cache PlatformIO global store uses: actions/cache@v4 with: path: | ~/.platformio/.cache ~/.platformio/packages key: platformio-${{ runner.os }}-v1-${{ hashFiles('.github/workflows/custom_build.yml', '.github/workflows/custom_build_test.yml') }} restore-keys: | platformio-${{ runner.os }}-v1- - name: CI progress — restoring PlatformIO libdeps cache shell: bash env: STEP_INDEX: 5 LABEL: Restoring PlatformIO libdeps cache run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" # Key is owner/repo/env only (not source SHA); bump …-v2 if lib_deps get out of sync. - name: Cache PlatformIO libdeps (per repo + target) uses: actions/cache@v4 with: path: ${{ github.workspace }}/.pio-libdeps/${{ inputs.owner }}/${{ inputs.repo }}/${{ inputs.target_env }} key: pio-libdeps-${{ inputs.owner }}-${{ inputs.repo }}-${{ inputs.target_env }}-v1 - name: Extract firmware source shell: bash run: | STEP_INDEX=6 LABEL="Extracting firmware source" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" set -e sudo apt-get update -qq && sudo apt-get install -y -qq unzip rm -rf /tmp/src mkdir -p /tmp/src unzip -q /tmp/src.zip -d /tmp/src ROOT=$(find /tmp/src -mindepth 1 -maxdepth 1 -type d | head -1) printf '%s\n' "$ROOT" > /tmp/fw-src-root.txt mkdir -p "$PLATFORMIO_LIBDEPS_DIR" - name: Install PlatformIO shell: bash run: | STEP_INDEX=7 LABEL="Installing PlatformIO" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" set -e ROOT=$(cat /tmp/fw-src-root.txt) cd "$ROOT" python -m pip install -q --upgrade pip pip install -q platformio adafruit-nrfutil - name: Install PlatformIO dependencies shell: bash run: | STEP_INDEX=8 LABEL="Installing firmware dependencies (PlatformIO)" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" set -e ROOT=$(cat /tmp/fw-src-root.txt) cd "$ROOT" pio pkg install -e "${{ inputs.target_env }}" - name: Compile firmware (PlatformIO) shell: bash run: | STEP_INDEX=9 LABEL="Compiling firmware (PlatformIO)" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" set -e ROOT=$(cat /tmp/fw-src-root.txt) cd "$ROOT" MKLITTLEFS_BIN=$(find "$HOME/.platformio/packages" -type f -name mklittlefs 2>/dev/null | head -1) if [ -n "$MKLITTLEFS_BIN" ]; then export PATH="$(dirname "$MKLITTLEFS_BIN"):$PATH" fi command -v mklittlefs >/dev/null 2>&1 || echo "WARNING: mklittlefs not on PATH; LittleFS build may fail" pio run -e "${{ inputs.target_env }}" 2>&1 | tee /tmp/pio.log BUILD_DIR=".pio/build/${{ inputs.target_env }}" if [ ! -d "$BUILD_DIR" ]; then echo "Build output directory missing" exit 1 fi - name: Generate merged factory binary for ESP32 targets (if applicable) shell: bash run: | ROOT=$(cat /tmp/fw-src-root.txt) BUILD_DIR="$ROOT/.pio/build/${{ inputs.target_env }}" if [ -f "$BUILD_DIR/bootloader.bin" ]; then echo "ESP32 target detected; running mergebin → firmware-merged.factory.bin" cd "$ROOT" export MERGED_BIN_PATH="$BUILD_DIR/firmware-merged.factory.bin" pio run -t mergebin -e "${{ inputs.target_env }}" 2>&1 || \ echo "WARNING: mergebin target failed; factory binary will not be available" else echo "No bootloader.bin found; skipping mergebin (non-ESP32 target)" fi - name: Download Meshtastic ESP OTA companion (if applicable) shell: bash run: | set -e ROOT=$(cat /tmp/fw-src-root.txt) cd "$ROOT" BUILD_DIR=".pio/build/${{ inputs.target_env }}" bash "${{ github.workspace }}/scripts/download-meshtastic-ota.sh" "$ROOT/$BUILD_DIR" - name: Extend merged binary with Meshtastic OTA companion (if applicable) shell: bash run: | ROOT=$(cat /tmp/fw-src-root.txt) BUILD_DIR="$ROOT/.pio/build/${{ inputs.target_env }}" MERGED="$BUILD_DIR/firmware-merged.factory.bin" PARTS="$BUILD_DIR/partitions.bin" OTA_BIN="" for candidate in "$BUILD_DIR"/mt-*.ota.bin "$BUILD_DIR/bleota-c3.bin"; do if [ -f "$candidate" ]; then OTA_BIN="$candidate"; break; fi done if [ -f "$MERGED" ] && [ -f "$PARTS" ] && [ -n "$OTA_BIN" ]; then python3 "${{ github.workspace }}/scripts/extend-merged-with-ota.py" \ "$MERGED" "$PARTS" "$OTA_BIN" else echo "Skipping OTA extension (merged=$([ -f "$MERGED" ] && echo yes || echo no), ota=$([ -n "$OTA_BIN" ] && echo yes || echo no))" fi - name: Stage or generate nRF52 DFU files (if applicable) shell: bash run: | set -e ROOT=$(cat /tmp/fw-src-root.txt) BUILD_DIR="$ROOT/.pio/build/${{ inputs.target_env }}" # Path 1: MeshCore produces firmware.zip (Nordic DFU package) when adafruit-nrfutil # is available during pio run. Unzip it to get bin/dat/manifest.json at build root. if [ -f "$BUILD_DIR/firmware.zip" ]; then echo "Found firmware.zip; extracting Nordic DFU files to build root" unzip -j "$BUILD_DIR/firmware.zip" "*.bin" "*.dat" "manifest.json" -d "$BUILD_DIR/" || true fi # Path 2: firmware/ subdirectory fallback (local builds or alternate PIO layouts). if [ -d "$BUILD_DIR/firmware" ] && ! ls "$BUILD_DIR"/*.dat >/dev/null 2>&1; then echo "Found firmware/ subdirectory; staging nRF52 DFU files to build root" shopt -s nullglob for f in "$BUILD_DIR/firmware/"*.bin "$BUILD_DIR/firmware/"*.dat; do cp -a "$f" "$BUILD_DIR/" done shopt -u nullglob [ -f "$BUILD_DIR/firmware/manifest.json" ] && cp -a "$BUILD_DIR/firmware/manifest.json" "$BUILD_DIR/" fi # Path 3: firmware.bin present but still no .dat — nRF52 only. # Guard against ESP32 builds which also produce firmware.bin; bootloader.bin is # ESP32-specific and is never present in nRF52 build output. if [ -f "$BUILD_DIR/firmware.bin" ] && \ ! [ -f "$BUILD_DIR/bootloader.bin" ] && \ ! ls "$BUILD_DIR"/*.dat >/dev/null 2>&1; then echo "firmware.bin found but no .dat (nRF52); generating DFU init packet" adafruit-nrfutil dfu genpkg \ --dev-type 0xFFFF \ --dev-revision 0xFFFF \ --application-version 0xFFFFFFFF \ --sd-req 0xFFFE \ --application "$BUILD_DIR/firmware.bin" \ /tmp/nrf52-dfu.zip unzip -j /tmp/nrf52-dfu.zip "*.dat" -d "$BUILD_DIR/" echo "DFU init packet written: $(ls "$BUILD_DIR"/*.dat 2>/dev/null || echo '(none)')" fi - name: Package and upload firmware bundle id: pio env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} shell: bash run: | STEP_INDEX=10 LABEL="Packaging and uploading firmware bundle" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" set -e ROOT=$(cat /tmp/fw-src-root.txt) cd "$ROOT" BUILD_DIR=".pio/build/${{ inputs.target_env }}" ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz" STAGE=/tmp/fw-bundle rm -rf "$STAGE" mkdir -p "$STAGE" shopt -s nullglob if ls "$BUILD_DIR"/firmware-*.factory.bin >/dev/null 2>&1; then # ESP32: only the merged factory binary — sub-components are not needed for f in "$BUILD_DIR"/firmware-*.factory.bin; do cp -a "$f" "$STAGE/"; done else # nRF52 / RP2040: firmware binary, DFU init packet, UF2 files for f in "$BUILD_DIR"/firmware.bin "$BUILD_DIR"/firmware.dat \ "$BUILD_DIR"/*.uf2 "$BUILD_DIR"/*.hex; do [ -f "$f" ] && cp -a "$f" "$STAGE/" done # Nordic DFU manifest (MeshCore and other nRF52 builds) [ -f "$BUILD_DIR/manifest.json" ] && cp -a "$BUILD_DIR/manifest.json" "$STAGE/" fi shopt -u nullglob bash "${{ github.workspace }}/scripts/stage-fw-bundle-docs.sh" "$ROOT" "$STAGE" if [ -z "$(find "$STAGE" -mindepth 1 -maxdepth 1 -type f -print -quit)" ]; then echo "No firmware artifacts found in $BUILD_DIR" exit 1 fi tar -czf "/tmp/$ARTIFACT_NAME" -C "$STAGE" . OBJECT_PATH="${R2_BUCKET_NAME}/${ARTIFACT_NAME}" bunx wrangler r2 object put "$OBJECT_PATH" --file "/tmp/$ARTIFACT_NAME" --remote STEP_INDEX=10 LABEL="Firmware bundle uploaded" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py" echo "r2_key=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT" - name: Notify Convex — success if: success() shell: bash env: R2_KEY: ${{ steps.pio.outputs.r2_key }} run: | curl -sSf -X POST "$CONVEX_URL/ingest-repo-build" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \ -d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"succeeded\",\"github_run_id\":${{ github.run_id }},\"r2ObjectKey\":\"$R2_KEY\"}" - name: Notify Convex — failure if: failure() shell: bash env: GITHUB_RUN_ID: ${{ github.run_id }} run: | python3 -c " import json, os, urllib.request err = open('/tmp/pio.log').read()[-4000:] if os.path.exists('/tmp/pio.log') else 'build failed' body = { 'repo_build_id': os.environ['REPO_BUILD_ID'], 'state': 'failed', 'github_run_id': int(os.environ['GITHUB_RUN_ID']), 'errorSummary': err, } req = urllib.request.Request( os.environ['CONVEX_URL'] + '/ingest-repo-build', data=json.dumps(body).encode(), headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + os.environ['CONVEX_BUILD_TOKEN']}, method='POST', ) urllib.request.urlopen(req) "