Embedded Firmware Builds on GitHub Actions
Embedded firmware on GitHub Actions needs a cached cross toolchain, a board matrix emitting per-target artifacts, and runners sized per leg. YAML and rates.
Last verified:
An embedded firmware pipeline on GitHub Actions is a cross-compilation pipeline: an x86-64 runner installs an arm-none-eabi or RISC-V toolchain plus the vendor SDK, builds one image per board across a matrix, and uploads the ELF, HEX, and map file for each target. Three things decide how fast and how cheap that pipeline runs: how the toolchain and SDK get cached, how wide the board matrix fans out, and how clearly the pipeline draws the line at checks that need real hardware.
This page gives the cache strategy for cross toolchains and vendor SDKs, a working board matrix that produces per-target artifacts, the sizing and cost arithmetic behind the runner labels, and the hardware boundary that decides what belongs in a hosted job at all.
Overview
A firmware build inverts the usual GitHub Actions cost profile. The compile itself is small, since a bare-metal or RTOS image is tens of thousands of lines rather than millions, and the link step finishes in seconds. The expensive part is everything before the first .o file: downloading and unpacking the cross toolchain tarball, installing a vendor SDK, running a package manager that fetches board support packages, and pulling git submodules that carry HAL sources. On a fresh virtual machine, that setup routinely runs longer than the build it exists to serve.
The second structural fact is matrix width. Firmware projects ship for several boards, several silicon revisions, and usually a debug and a release variant of each. Every one of those combinations is an independent job, each starting from an empty machine and paying its own setup cost, so a caching mistake gets multiplied by the matrix rather than paid once.
Cross builds belong on Linux x64, because vendor toolchains and SDK installers ship x86-64 Linux binaries and target support is what matters rather than host architecture. The cloud runner catalog lists Ubuntu 22.04, 24.04, and 26.04 images from 2 to 32 vCPUs, each with a 150GB SSD and the same tooling as GitHub-hosted runners. Runners are ephemeral virtual machines, allocated per job and destroyed afterward, which is exactly why toolchain state has to live somewhere outside the machine. That somewhere is WarpBuild Cache, enabled by default on Linux runners and documented in the caching guide.
The third fact is the hardware boundary. A hosted runner has no board attached, so part of a firmware test plan is out of its reach permanently and no amount of runner tuning changes that:
| Check | Hosted runner | Physical board |
|---|---|---|
| Compile and link one image per board | Yes | |
| Static analysis and coding-standard rules | Yes | |
| Flash and RAM budget from the map file | Yes | |
| Host-compiled unit tests of portable logic | Yes | |
| Emulated boot and driver tests under QEMU or Renode | Where a model exists | |
| Timing against real peripheral silicon | Yes | |
| Analog behavior, power draw, EMC | Yes | |
| Radio interop and certification runs | Yes | |
| Flash-and-run of a production image | Yes |
The practical split is that hosted runners own everything up to the artifact, and a bench of boards behind self-hosted runners consumes those artifacts on a slower cadence, usually nightly or on release tags rather than on every pull request.
Configuration
Cross toolchains and vendor SDKs cache differently from application dependencies. A lockfile hash is the wrong key here, because the toolchain does not change when your source does. Key on the version string instead, so the cache is restored on nearly every run and rebuilt only when you deliberately bump a version.
| Ecosystem | What to cache | Path | Key input |
|---|---|---|---|
| Arm GNU toolchain | Unpacked toolchain tree | /opt/arm-gnu-toolchain | Toolchain release string |
| Zephyr | West modules and the Zephyr SDK | ~/zephyrproject/modules, ~/zephyr-sdk-<version> | west.yml hash plus SDK version |
| ESP-IDF | Downloaded tools and installed toolchains | ~/.espressif | IDF release tag |
| PlatformIO | Platform packages and frameworks | ~/.platformio | platformio.ini hash |
| Embedded Rust | Registry, git checkouts, target dir | ~/.cargo, target/ | Cargo.lock hash |
| Vendor board packages | Extracted BSP or HAL pack | vendor/sdk | SDK version or pack lockfile |
Here is a pipeline built on that strategy. The board matrix runs on 8 vCPUs, each leg restoring the shared toolchain and its own SDK slice, and host-side unit tests run separately on 4 vCPUs.
name: firmware
on:
push:
branches: [main]
pull_request:
env:
ARM_TOOLCHAIN_VERSION: 14.2.rel1
TOOLCHAIN_ROOT: /opt/arm-gnu-toolchain
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
strategy:
fail-fast: false
matrix:
board: [nucleo_f429zi, nrf52840dk, stm32h743_eval]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Restore cross toolchain
id: toolchain
uses: WarpBuilds/cache@v1
with:
path: ${{ env.TOOLCHAIN_ROOT }}
key: arm-gnu-${{ env.ARM_TOOLCHAIN_VERSION }}-x86_64
- name: Install cross toolchain
if: steps.toolchain.outputs.cache-hit != 'true'
run: |
sudo mkdir -p "$TOOLCHAIN_ROOT"
curl -sSL "$ARM_TOOLCHAIN_URL" \
| sudo tar -xJ --strip-components=1 -C "$TOOLCHAIN_ROOT"
env:
ARM_TOOLCHAIN_URL: https://developer.arm.com/-/media/Files/downloads/gnu/${{ env.ARM_TOOLCHAIN_VERSION }}/binrel/arm-gnu-toolchain-${{ env.ARM_TOOLCHAIN_VERSION }}-x86_64-arm-none-eabi.tar.xz
- name: Put the toolchain on PATH
run: echo "$TOOLCHAIN_ROOT/bin" >> "$GITHUB_PATH"
- name: Restore vendor SDK
uses: WarpBuilds/cache@v1
with:
path: vendor/sdk
key: sdk-${{ matrix.board }}-${{ hashFiles('vendor/sdk.lock') }}
restore-keys: sdk-${{ matrix.board }}-
- name: Build
run: make BOARD=${{ matrix.board }} -j8
- name: Size report
run: |
arm-none-eabi-size --format=berkeley \
build/${{ matrix.board }}/firmware.elf
- uses: actions/upload-artifact@v4
with:
name: firmware-${{ matrix.board }}
path: |
build/${{ matrix.board }}/firmware.elf
build/${{ matrix.board }}/firmware.hex
build/${{ matrix.board }}/firmware.map
host-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- run: cmake -S test -B build-host -DCMAKE_BUILD_TYPE=Debug
- run: cmake --build build-host --parallel 4
- run: ctest --test-dir build-host --output-on-failureFour details carry the weight.
The toolchain key holds still. arm-gnu-14.2.rel1-x86_64 changes when you edit one environment variable and at no other time, so every matrix leg on every branch restores the same entry. The conditional install step then runs approximately never, which is the point.
Artifacts are named per board. firmware-${{ matrix.board }} gives a release job a predictable set of names to fan back in, and it keeps a failed leg from poisoning the others. fail-fast: false matters for the same reason: one broken board should report as one broken board rather than cancelling the matrix.
The map file ships with the image. Flash and RAM headroom are release criteria on constrained parts, and the map file is the only artifact that can answer "which commit ate 3 KB of flash" after the fact. Uploading it costs nothing and turns a size regression into a diff.
Submodules are part of the setup bill. submodules: recursive on a HAL-heavy tree can pull hundreds of megabytes. Where the vendor tree is stable, cache the checked-out submodule paths on the pinned commit rather than refetching them.
For installs that take minutes and scatter files across the filesystem, such as a full IDF or SDK installer, caching directories is the wrong tool. Snapshot runners capture the whole runner VM mid-workflow and boot later jobs from it: add snapshot.enabled=true to the runs-on label on main, call WarpBuilds/snapshot-save@v1 after the install completes, and use snapshot.key=<alias> on pull request runs to boot from that state. Snapshots are deleted after 15 days, and the labels are silently ignored on runner types that do not support them. The snapshot runner documentation covers the full label syntax.
Sizing
Linux x64 sizes and rates, from the pricing page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Firmware sizing follows one rule: parallelism lives in the matrix, so buy legs before you buy cores. A single board image has a few hundred translation units at most, and make -j8 exhausts the compile graph long before it exhausts 32 vCPUs. Cores start paying again when a leg does more than compile the application: building a vendor SDK or an RTOS tree from source, running clang-tidy across the full unit set, or generating protocol or device-tree code as part of the build.
Practical starting points. Keep documentation, formatting, and lint jobs on warp-ubuntu-latest-x64-2x at $0.004 per minute. Put host-compiled unit tests and emulator runs on warp-ubuntu-latest-x64-4x at $0.008 per minute. Run the board matrix on warp-ubuntu-latest-x64-8x at $0.016 per minute, and move up only when a leg still shows every core saturated at the end of the build.
Worked cost model
GitHub publishes list prices for its hosted runners; the 8-core Linux larger runner meters at $0.022 per minute (GitHub Actions billing reference, checked on 2026-08-13). Take a firmware team with a 12 board matrix, 5 minutes per leg, running 20 pipelines a day across 22 working days: 26,400 runner-minutes per month, plus 12 GB of toolchain and SDK cache and 10,560 cache operations.
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 26,400 minutes | $580.80 |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | 26,400 minutes | $422.40 |
| WarpBuild cache storage | $0.20 per GB-month | 12 GB | $2.40 |
| WarpBuild cache operations | $0.0001 per operation | 10,560 operations | $1.06 |
The WarpBuild total is $425.86 against $580.80 for the same minutes, a difference of $154.94 per month. As a rate comparison on its own: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price checked on 2026-08-13.
Full rates for every size and platform are on the pricing page.
Bottlenecks
Setup dominating the build. The signature is a five minute job with a forty second compile inside it. Read the step durations before tuning anything else: a toolchain download, an SDK installer, and a recursive submodule fetch are each a separate line, and each has a different fix (version-keyed cache, snapshot, pinned submodule cache).
Matrix legs that queue behind a shared setup job. A needs: edge that makes every board wait for one preparation job converts a parallel matrix into a serial prologue. Where the preparation output is a cache entry or a snapshot, the legs can restore it independently and start immediately.
Vendor container images pulled per leg. Toolchain containers are commonly multi-gigabyte, and pulling one per matrix leg per run turns image transfer into the dominant cost. Where the pipeline builds its own toolchain image, remote Docker builders keep the layer cache warm between runs, so a Dockerfile edit rebuilds the changed layers instead of the whole image.
Emulator jobs on undersized machines. QEMU or Renode runs of a full board model are single-process and memory-hungry, and a 2 vCPU machine with 8 GB stalls them. These belong on the 4x size, sized by memory rather than cores.
Architecture confusion on 32-bit targets. Building an armv7 firmware image is a cross-compilation job that runs perfectly well on an x86-64 runner, since the compiler is a native x86-64 binary emitting ARM code. Executing 32-bit ARM binaries on an ARM64 runner is a separate question with a separate answer, covered on the 32-bit ARM builds page. Mixing the two up sends teams looking for ARM64 runners they do not need. The cross-compilation glossary entry has the vocabulary for build, host, and target.
CI observability streams system metrics from the runner agent correlated with GitHub Actions job logs, which distinguishes a job stalled on a download from one saturating every core. The Action Debugger pauses a workflow and opens an SSH session on the live runner, which is the shortest path to inspecting a half-populated SDK directory or a failing linker script in place. Larger image-based pipelines that assemble a full Linux distribution have a different bottleneck profile, covered on the Yocto builds page.
Proof
The nearest checkable public evidence is a cross-compilation matrix in an open repository. The bitcoin/bitcoin GitHub Actions workflow runs its windows-cross job on warp-ubuntu-latest-x64-4x with a matrix over two C runtimes, each leg uploading its own artifact set (x86_64-w64-mingw32-executables and x86_64-w64-mingw32ucrt-executables), and runs its macOS cross builds to arm64 and x86_64 on the same label, with the heavier sanitizer and fuzz jobs on the 8x and 16x sizes (checked on 2026-08-13). The mechanics are the ones this page describes: one cross toolchain per target, per-target artifact names, and a runner size chosen per matrix leg rather than one size for the whole workflow.
The rest is measurable in your own repository without taking anyone's word for it. Run the board matrix unchanged on the label you use today, then on warp-ubuntu-latest-x64-8x, and compare per-step durations at P75 rather than a single run, since one run hides queue time and cache-miss variance. Separate the setup steps from the compile steps in that comparison, because the caching work above moves the first group and the runner size moves the second.
FAQ
Which runner size fits an embedded firmware build?
Start the board matrix on warp-ubuntu-latest-x64-8x at $0.016 per minute when a leg compiles a vendor SDK from source or runs static analysis over the tree, and keep host-side unit tests on warp-ubuntu-latest-x64-4x at $0.008 per minute. A single small board image rarely saturates 8 vCPUs, so buy width through matrix legs rather than cores per leg.
How do I stop a vendor SDK install from running on every job?
Cache the SDK directory with WarpBuilds/cache@v1 keyed on the SDK version string rather than a source hash, so the key changes only when you bump the SDK. For installs that take minutes and write across the filesystem, capture the whole runner VM with WarpBuilds/snapshot-save and boot later jobs from that alias using snapshot.key on the runs-on label.
What can GitHub Actions verify about firmware without a board attached?
A hosted runner compiles and links each board image, runs static analysis, checks flash and RAM against the map file, runs host-compiled unit tests of portable logic, and runs emulated boot tests where a QEMU or Renode model exists. Timing against real peripheral silicon, analog behavior, power draw, and radio interop need a physical board on a self-hosted runner.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.