name: CI concurrency: group: ci-${{ github.head_ref || github.run_id }} cancel-in-progress: true on: # The merge queue is the pre-merge gate for the protected branches (master, # develop): a merge_group run validates the merged result before code lands. For # now PRs and merge_group runs build the same narrowed board subset (--level pr); # see the setup job. push still runs the full matrix on master/develop (post-merge) # as well as on the event/* and feature/* branches, which have no merge queue. merge_group: types: [checks_requested] branches: - master - develop push: branches: - master - develop - event/* - feature/* paths-ignore: - "**.md" - version.properties pull_request: branches: - master - develop - event/* - feature/* paths-ignore: - "**.md" #- "**.yml" schedule: # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release). # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests # and 02:00 daily_packaging crons. - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop) workflow_dispatch: inputs: # trunk-ignore(checkov/CKV_GHA_7): intentional manual-test switch for the nightly publish path nightly: description: "Nightly mode: build + publish develop to github.io firmware-nightly/ (skips creating a GitHub release)" type: boolean default: false permissions: read-all jobs: setup: strategy: fail-fast: true matrix: arch: - all - check runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: python-version: 3.x cache: pip - run: pip install -U platformio - name: Generate matrix id: jsonStep run: | # PRs and (for now) merge_group builds use the narrowed --level pr board # subset. Full-matrix builds run on push / schedule / workflow_dispatch. if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr) else TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) fi echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF" echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT echo "$TARGETS" >> $GITHUB_STEP_SUMMARY outputs: all: ${{ steps.jsonStep.outputs.all }} check: ${{ steps.jsonStep.outputs.check }} version: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Get release version string run: | echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT echo "deb=$(./bin/buildinfo.py deb)" >> $GITHUB_OUTPUT id: version env: BUILD_LOCATION: local outputs: long: ${{ steps.version.outputs.long }} deb: ${{ steps.version.outputs.deb }} check: needs: setup strategy: fail-fast: false matrix: check: ${{ fromJson(needs.setup.outputs.check) }} # Runs on GitHub-hosted runners so checks don't compete with builds for the # self-hosted 'arctastic' pool (which builds use). runs-on: ubuntu-latest if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} steps: - uses: actions/checkout@v7 with: submodules: recursive - name: Check ${{ matrix.check.board }} uses: meshtastic/gh-action-firmware@main with: pio_platform: ${{ matrix.check.platform }} pio_env: ${{ matrix.check.board }} pio_target: check build: needs: [setup, version] strategy: fail-fast: false matrix: build: ${{ fromJson(needs.setup.outputs.all) }} uses: ./.github/workflows/build_firmware.yml with: version: ${{ needs.version.outputs.long }} pio_env: ${{ matrix.build.board }} platform: ${{ matrix.build.platform }} build-debian-src: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} uses: ./.github/workflows/build_debian_src.yml with: series: UNRELEASED build_location: local secrets: inherit MacOS: if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} strategy: fail-fast: false matrix: macos_ver: - "26" # ARM64 # - '26-intel' # x86_64 - "15" # ARM64 # - '15-intel' # x86_64 uses: ./.github/workflows/build_macos_bin.yml with: macos_ver: ${{ matrix.macos_ver }} # secrets: inherit Windows: if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} strategy: fail-fast: false matrix: windows_ver: - "2025" # x86_64 uses: ./.github/workflows/build_windows_bin.yml with: windows_ver: ${{ matrix.windows_ver }} # secrets: inherit package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml with: pio_env: native-tft secrets: inherit test-native: if: ${{ !contains(github.ref_name, 'event/') && github.repository == 'meshtastic/firmware' && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} permissions: # Needed for dorny/test-reporter. contents: read actions: read checks: write uses: ./.github/workflows/test_native.yml build-wasm: if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} # Build the WebAssembly portduino node ([env:native-wasm]) as part of normal CI, # like the other platforms. It's a dedicated job (not a row in the `build` # matrix) because its artifact is meshnode.{mjs,wasm} - not a flashable # .bin/.uf2/.hex - and it needs the Emscripten SDK; board_level=extra keeps # it out of generate_ci_matrix.py. uses: ./.github/workflows/build_portduino_wasm.yml docker: if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} permissions: # Needed for pushing to GHCR. contents: read packages: write strategy: fail-fast: false matrix: distro: [debian, alpine] platform: [linux/arm64] pio_env: [native-tft] uses: ./.github/workflows/docker_build.yml with: distro: ${{ matrix.distro }} platform: ${{ matrix.platform }} runs-on: ${{ contains(matrix.platform, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} pio_env: ${{ matrix.pio_env }} push: false gather-artifacts: if: github.repository == 'meshtastic/firmware' strategy: fail-fast: false matrix: arch: - esp32 - esp32s3 - esp32c3 - esp32c6 - nrf52840 - rp2040 - rp2350 - stm32 runs-on: ubuntu-latest needs: [version, build] steps: - name: Checkout code uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: path: ./ pattern: firmware-${{matrix.arch}}-* merge-multiple: true - name: Display structure of downloaded files run: ls -R - name: Repackage in single firmware zip uses: actions/upload-artifact@v7 with: name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} overwrite: true path: | ./firmware-*.mt.json ./firmware-*.bin ./firmware-*.uf2 ./firmware-*.hex ./firmware-*.zip ./device-*.sh ./device-*.bat ./littlefs-*.bin ./bleota*bin ./mt-*-ota.bin ./Meshtastic_nRF52_factory_erase*.uf2 retention-days: 30 - uses: actions/download-artifact@v8 with: name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} merge-multiple: true path: ./output # For diagnostics - name: Show artifacts run: ls -lR - name: Device scripts permissions run: | chmod +x ./output/device-install.sh || true chmod +x ./output/device-update.sh || true - name: Zip firmware run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - name: Repackage in single elfs zip uses: actions/upload-artifact@v7 with: name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} overwrite: true path: ./*.elf retention-days: 30 firmware-size-report: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} continue-on-error: true permissions: contents: read actions: read runs-on: ubuntu-latest needs: [build] steps: - uses: actions/checkout@v7 - name: Download current manifests uses: actions/download-artifact@v8 with: path: ./manifests/ pattern: manifest-* merge-multiple: true - name: Collect current firmware sizes run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json - name: Upload size report artifact uses: actions/upload-artifact@v7 with: name: firmware-sizes-${{ github.sha }} overwrite: true path: ./current-sizes.json retention-days: 90 - name: Download baseline sizes from develop if: github.event_name == 'pull_request' continue-on-error: true id: baseline-develop env: GH_TOKEN: ${{ github.token }} run: | RUN_ID=$(gh run list -R "${{ github.repository }}" \ --workflow CI --branch develop --status success \ --limit 1 --json databaseId --jq '.[0].databaseId // empty') if [ -n "$RUN_ID" ]; then ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \ --jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1) if [ -n "$ARTIFACT_NAME" ]; then gh run download "$RUN_ID" -R "${{ github.repository }}" \ --name "$ARTIFACT_NAME" --dir ./baseline-develop/ cp "./baseline-develop/current-sizes.json" ./develop-sizes.json echo "found=true" >> "$GITHUB_OUTPUT" else echo "found=false" >> "$GITHUB_OUTPUT" fi else echo "found=false" >> "$GITHUB_OUTPUT" fi - name: Download baseline sizes from master if: github.event_name == 'pull_request' continue-on-error: true id: baseline-master env: GH_TOKEN: ${{ github.token }} run: | RUN_ID=$(gh run list -R "${{ github.repository }}" \ --workflow CI --branch master --status success \ --limit 1 --json databaseId --jq '.[0].databaseId // empty') if [ -n "$RUN_ID" ]; then ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \ --jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1) if [ -n "$ARTIFACT_NAME" ]; then gh run download "$RUN_ID" -R "${{ github.repository }}" \ --name "$ARTIFACT_NAME" --dir ./baseline-master/ cp "./baseline-master/current-sizes.json" ./master-sizes.json echo "found=true" >> "$GITHUB_OUTPUT" else echo "found=false" >> "$GITHUB_OUTPUT" fi else echo "found=false" >> "$GITHUB_OUTPUT" fi - name: Generate size comparison report if: github.event_name == 'pull_request' id: report run: | ARGS="./current-sizes.json --budgets bin/ram_budgets.json" if [ -f ./develop-sizes.json ]; then ARGS="$ARGS --baseline develop:./develop-sizes.json" fi if [ -f ./master-sizes.json ]; then ARGS="$ARGS --baseline master:./master-sizes.json" fi REPORT=$(python3 bin/size_report.py $ARGS) if [ -z "$REPORT" ]; then echo "has_report=false" >> "$GITHUB_OUTPUT" else echo "has_report=true" >> "$GITHUB_OUTPUT" { echo '' echo '# Firmware Size Report' echo '' echo "$REPORT" echo '' echo '---' echo "*Updated for ${{ github.sha }}*" } > ./size-report.md cat ./size-report.md >> "$GITHUB_STEP_SUMMARY" fi - name: Save PR number if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true' run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt - name: Upload size report if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true' uses: actions/upload-artifact@v7 with: name: size-report path: | ./size-report.md ./pr-number.txt retention-days: 5 # RAM/flash guardrails: fails CI when an env listed in bin/ram_budgets.json # exceeds its static RAM (.data+.bss) or flash budget. Kept separate from # firmware-size-report, which is informational and continue-on-error. size-budget-gate: if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} permissions: contents: read actions: read runs-on: ubuntu-latest needs: [build] steps: - uses: actions/checkout@v7 # No continue-on-error / empty-dir fallback: the gate must fail closed when # the data it enforces on cannot be fetched (size_report.py additionally # fails on missing budgeted envs under --enforce-budgets). - name: Download current manifests uses: actions/download-artifact@v8 with: path: ./manifests/ pattern: manifest-* merge-multiple: true - name: Collect current firmware sizes run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json - name: Enforce RAM/flash budgets run: python3 bin/size_report.py ./current-sizes.json --budgets bin/ram_budgets.json --enforce-budgets release-artifacts: permissions: # Needed for 'gh release upload'. contents: write runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' && github.event.inputs.nightly != 'true' }} outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} needs: - setup - version - gather-artifacts - build-debian-src - package-pio-deps-native-tft # - MacOS steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.x - name: Generate release notes id: release_notes run: | chmod +x ./bin/generate_release_notes.py NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD 2>release_notes.log) echo "notes<> $GITHUB_OUTPUT echo "$NOTES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT echo "### Release note range" >> $GITHUB_STEP_SUMMARY cat release_notes.log >> $GITHUB_STEP_SUMMARY env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release uses: softprops/action-gh-release@v3 id: create_release with: draft: true prerelease: true name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha tag_name: v${{ needs.version.outputs.long }} target_commitish: ${{ github.sha }} body: ${{ steps.release_notes.outputs.notes }} - name: Download source deb uses: actions/download-artifact@v8 with: pattern: firmware-debian-${{ needs.version.outputs.deb }}~UNRELEASED-src merge-multiple: true path: ./output/debian-src - name: Download `native-tft` pio deps uses: actions/download-artifact@v8 with: pattern: platformio-deps-native-tft-${{ needs.version.outputs.long }} merge-multiple: true path: ./output/pio-deps-native-tft - name: Zip Linux sources working-directory: output run: | zip -j -9 -r ./meshtasticd-${{ needs.version.outputs.deb }}-src.zip ./debian-src zip -9 -r ./platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip ./pio-deps-native-tft # For diagnostics - name: Display structure of downloaded files run: ls -lR - name: Generate Release manifest run: | jq -n --arg ver "${{ needs.version.outputs.long }}" --argjson targets ${{ toJson(needs.setup.outputs.all) }} '{ "version": $ver, "targets": $targets }' > firmware-${{ needs.version.outputs.long }}.json - name: Save Release manifest artifact uses: actions/upload-artifact@v7 with: name: manifest-${{ needs.version.outputs.long }} overwrite: true path: firmware-${{ needs.version.outputs.long }}.json - name: Add sources to GitHub Release # Only run when targeting master branch with workflow_dispatch if: ${{ github.ref_name == 'master' }} run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{ needs.version.outputs.long }}.json gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-firmware: permissions: # Needed for 'gh release upload'. contents: write strategy: fail-fast: false matrix: arch: - esp32 - esp32s3 - esp32c3 - esp32c6 - nrf52840 - rp2040 - rp2350 - stm32 runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' && github.event.inputs.nightly != 'true' }} needs: [release-artifacts, version] steps: - name: Checkout uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.x - uses: actions/download-artifact@v8 with: pattern: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} merge-multiple: true path: ./output - name: Display structure of downloaded files run: ls -lR - name: Device scripts permissions run: | chmod +x ./output/device-install.sh || true chmod +x ./output/device-update.sh || true - name: Zip firmware run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - uses: actions/download-artifact@v8 with: name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} merge-multiple: true path: ./elfs - name: Zip debug elfs run: zip -j -9 -r ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./elfs # For diagnostics - name: Display structure of downloaded files run: ls -lR - name: Add bins and debug elfs to GitHub Release # Only run when targeting master branch with workflow_dispatch if: ${{ github.ref_name == 'master' }} run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-firmware: runs-on: ubuntu-24.04 if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.nightly != 'true' }} needs: [release-firmware, version] env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.x - name: Get firmware artifacts uses: actions/download-artifact@v8 with: pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }} merge-multiple: true path: ./publish - name: Get manifest artifact uses: actions/download-artifact@v8 with: pattern: manifest-${{ needs.version.outputs.long }} path: ./publish - name: Generate release notes run: | chmod +x ./bin/generate_release_notes.py ./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD > ./publish/release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish firmware to meshtastic.github.io uses: peaceiris/actions-gh-pages@v4 env: # On event/* branches, use the event name as the destination prefix DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }} with: deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} external_repository: meshtastic/meshtastic.github.io publish_branch: master publish_dir: ./publish destination_dir: ${{ env.DEST_PREFIX }}firmware-${{ needs.version.outputs.long }} keep_files: true user_name: github-actions[bot] user_email: github-actions[bot]@users.noreply.github.com commit_message: ${{ needs.version.outputs.long }} enable_jekyll: true # Nightly publish: refresh the single, stable firmware-nightly/ folder on # meshtastic.github.io with the current develop build. Runs on the cron schedule # (or a manual nightly=true dispatch) and never creates a GitHub release. The # folder's release_notes.md is maintained by hand and deliberately left untouched. publish-nightly: runs-on: ubuntu-24.04 if: ${{ (github.event_name == 'schedule' || github.event.inputs.nightly == 'true') && github.repository == 'meshtastic/firmware' }} needs: [setup, version, gather-artifacts] env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 steps: - name: Get firmware artifacts uses: actions/download-artifact@v8 with: pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }} merge-multiple: true path: ./stage - name: Generate Release manifest run: | jq -n --arg ver "${{ needs.version.outputs.long }}" --argjson targets ${{ toJson(needs.setup.outputs.all) }} '{ "version": $ver, "targets": $targets }' > ./stage/firmware-${{ needs.version.outputs.long }}.json - name: Generate nightly pointer run: | jq -n \ --arg ver "${{ needs.version.outputs.long }}" \ --arg sha "${{ github.sha }}" \ '{version: $ver, id: ("v" + $ver), title: ("Meshtastic Firmware " + $ver + " Nightly"), commit: $sha}' \ > ./stage/index.json - name: Preserve manually-maintained release notes # firmware-nightly/release_notes.md is edited by hand. Carry the current # copy into ./stage so the keep_files:false publish (which refreshes the # folder and clears stale nightly binaries) does not drop it. Seed a # placeholder on the first run (404); fail closed on any other error so a # transient fetch failure never clobbers the notes. run: | set -euo pipefail url=https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/release_notes.md code=$(curl -sSL -o ./stage/release_notes.md -w '%{http_code}' --retry 5 --retry-all-errors "$url" || echo 000) if [ "$code" = "200" ]; then echo "Preserved existing release_notes.md" elif [ "$code" = "404" ]; then echo "No existing release_notes.md; seeding placeholder" printf '# Nightly (develop)\n\nAutomated nightly build from the `develop` branch. Edit these notes by hand.\n' > ./stage/release_notes.md else echo "Unexpected HTTP $code fetching release_notes.md; refusing to publish to avoid clobbering manual notes" exit 1 fi # For diagnostics - name: Display structure of files to publish run: ls -lR ./stage - name: Publish nightly to meshtastic.github.io uses: peaceiris/actions-gh-pages@v4 with: deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} external_repository: meshtastic/meshtastic.github.io publish_branch: master publish_dir: ./stage # keep_files:false is scoped to destination_dir, so this refreshes only # firmware-nightly/ (clearing stale nightly binaries) while sibling # release folders stay untouched; release_notes.md is carried in above. destination_dir: firmware-nightly keep_files: false user_name: github-actions[bot] user_email: github-actions[bot]@users.noreply.github.com commit_message: Nightly ${{ needs.version.outputs.long }} enable_jekyll: true