HomeAbout UsContact Us

Automating Firmware Release Pipelines with GitHub Actions

By Jithin Tom
August 23, 2026
2 min read
Automating Firmware Release Pipelines with GitHub Actions

Table Of Contents

01
The Problem: Manual Firmware Releases Don't Scale
02
What This Pipeline Solves
03
Architecture Overview
04
Step 1: Pin Your Toolchain in Docker
05
Step 2: Embed Version and Git Metadata in the Binary
06
Step 3: Matrix Build for Multiple Targets
07
Step 4: Sign Artifacts with Cosign (Keyless)
08
Step 5: Create GitHub Release with All Artifacts
09
Step 6: Hardware-in-the-Loop Testing (Optional but Recommended)
10
Step 7: Enforce Clean Tree and Required Checks
11
Release Pipeline Flow
12
Step 8: Verify the Release Locally
13
Common Pitfalls
14
Summary
15
Related Reading
16
References
17
Frequently Asked Questions

The Problem: Manual Firmware Releases Don’t Scale

You push a tag. Someone clones the repo, checks out the tag, runs the build script manually, copies artifacts to a shared drive, emails the team. A week later, production runs a binary nobody can trace back to source. No SHA. No version string. No signature. Just a .hex file named firmware_v2_final_REAL.hex.

This is how embedded teams ship. It works until it doesn’t — until a customer reports a bug in “v2.1” but your git history shows three different v2.1 builds from that week, each with different compiler flags.

Automated release pipelines fix this. Every release artifact is reproducible, traceable, and verifiable. GitHub Actions makes this practical without maintaining Jenkins servers.

What This Pipeline Solves

Pain PointPipeline Solution
“Which commit produced this binary?”Git SHA embedded in binary, visible at boot
“Is this the exact artifact from CI?”SHA256 checksums + cosign signatures on every release
“Did the release build pass all tests?”Required status checks before tag push allowed
“Can I reproduce this build locally?”Dockerfile with pinned toolchain versions
“Does it run on actual hardware?”Optional HIL job on self-hosted runner for release candidates

Architecture Overview

+------------------+ +------------------+ +------------------+
| Push Tag |---->| Matrix Build |---->| Collect & Sign |
| (v1.2.3) | | (STM32/ESP32/ | | Artifacts |
| | | nRF52) | | |
+------------------+ +------------------+ +------------------+
|
v
+------------------+ +------------------+ +------------------+
| GitHub Release |<----| Attach |<----| Verify |
| (auto-generated)| | Artifacts | | Checksums/Sigs |
+------------------+ +------------------+ +------------------+

Step 1: Pin Your Toolchain in Docker

Reproducibility starts with the build environment. Don’t rely on apt-get install gcc-arm-none-eabi — versions drift.

Dockerfile Structure

# .github/docker/arm-toolchain/Dockerfile
FROM ubuntu:22.04
# Pin exact versions -- update intentionally, not accidentally
ARG GCC_VERSION=13.2.rel1
ARG CMAKE_VERSION=3.28.3
ARG NINJA_VERSION=1.11.1
RUN apt-get update && apt-get install -y --no-install-recommends \
wget ca-certificates xz-utils python3 python3-pip git \
&& rm -rf /var/lib/apt/lists/*
# ARM GCC from Arm developer site (stable URLs)
RUN wget -q "https://developer.arm.com/-/media/Files/downloads/gnu/${GCC_VERSION}/binrel/arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi.tar.xz" \
&& tar -xf "arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi.tar.xz" -C /opt \
&& ln -s "/opt/arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi/bin/arm-none-eabi-gcc" /usr/local/bin/arm-none-eabi-gcc \
&& rm "arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi.tar.xz"
# CMake
RUN wget -q "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
&& tar -xf "cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" -C /opt \
&& ln -s "/opt/cmake-${CMAKE_VERSION}-linux-x86_64/bin/cmake" /usr/local/bin/cmake \
&& rm "cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz"
# Ninja
RUN wget -q "https://github.com/ninja-build/ninja/releases/download/v${NINJA_VERSION}/ninja-linux.zip" \
&& unzip -q ninja-linux.zip -d /opt/ninja \
&& ln -s /opt/ninja/ninja /usr/local/bin/ninja \
&& rm ninja-linux.zip
ENV PATH="/opt/arm-gnu-toolchain-${GCC_VERSION}-x86_64-arm-none-eabi/bin:/opt/cmake-${CMAKE_VERSION}-linux-x86_64/bin:/opt/ninja:${PATH}"
WORKDIR /workspace
ENTRYPOINT ["/bin/bash"]

Build and push once; reference by digest in workflows:

docker build -t ghcr.io/yourorg/arm-toolchain:13.2.rel1 .github/docker/arm-toolchain
docker push ghcr.io/yourorg/arm-toolchain:13.2.rel1

Version Pinning Strategy

Pin the Docker image by SHA256 digest in workflows, not by tag. Tags are mutable; digests are immutable. Update the digest intentionally when you validate a new toolchain version.

Step 2: Embed Version and Git Metadata in the Binary

The binary must know its own identity. Generate a header at configure time:

# cmake/Version.cmake
# Runs at configure time -- embeds version + git SHA into firmware
execute_process(
COMMAND git rev-parse HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_SHA1
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_SHA1_SHORT
OUTPUT_STRIP_TRAILING_WHITESPACE
)
# Check for uncommitted changes
execute_process(
COMMAND git status --porcelain
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_STATUS
OUTPUT_STRIP_TRAILING_WHITESPACE
)
set(GIT_DIRTY "${GIT_STATUS}")
if(GIT_DIRTY)
set(GIT_DIRTY "1")
else()
set(GIT_DIRTY "0")
endif()
# Version from git tag or fallback
execute_process(
COMMAND git describe --tags --always --dirty=-dirty
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE FULL_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
# Build timestamp (ISO 8601)
string(TIMESTAMP CMAKE_BUILD_TIME "%Y-%m-%dT%H:%M:%SZ" UTC)
# Generate version header
configure_file(
${CMAKE_SOURCE_DIR}/include/version.h.in
${CMAKE_BINARY_DIR}/generated/version.h
@ONLY
)
target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_BINARY_DIR}/generated)
// include/version.h.in -- template
#pragma once
#define FIRMWARE_VERSION "@FULL_VERSION@"
#define FIRMWARE_GIT_SHA "@GIT_SHA1@"
#define FIRMWARE_GIT_SHA_SHORT "@GIT_SHA1_SHORT@"
#define FIRMWARE_GIT_DIRTY @GIT_DIRTY@
#define FIRMWARE_BUILD_TIMESTAMP "@CMAKE_BUILD_TIME@"
// src/main.c -- print at boot
#include "version.h"
void print_firmware_info(void) {
printf("\n========================================\n");
printf("Firmware: %s\n", FIRMWARE_VERSION);
printf("Git SHA: %s\n", FIRMWARE_GIT_SHA_SHORT);
if (FIRMWARE_GIT_DIRTY) {
printf("WARNING: Built from dirty tree!\n");
}
printf("Built: %s\n", FIRMWARE_BUILD_TIMESTAMP);
printf("========================================\n\n");
}

Now every boot identifies the exact source.

Step 3: Matrix Build for Multiple Targets

One workflow, multiple MCUs. The matrix strategy scales:

# .github/workflows/release.yml
name: Firmware Release
on:
push:
tags:
- 'v*' # Only trigger on version tags
permissions:
contents: write # Create release, upload artifacts
id-token: write # For cosign signing
attestations: write # For SLSA provenance
env:
DOCKER_IMAGE: ghcr.io/yourorg/arm-toolchain@sha256:<digest>
ARTIFACT_RETENTION_DAYS: 90
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- target: stm32f407
board: STM32F407VG
cmake_args: "-DMCU=STM32F407VGT6 -DBOARD=STM32F4_DISCOVERY"
artifact_name: "firmware-stm32f407"
- target: esp32
board: ESP32-WROOM-32
cmake_args: "-DMCU=ESP32 -DBOARD=ESP32_DEVKIT_V1"
artifact_name: "firmware-esp32"
- target: nrf52840
board: nRF52840-DK
cmake_args: "-DMCU=NRF52840 -DBOARD=NRF52840_DK"
artifact_name: "firmware-nrf52840"
container:
image: ${{ env.DOCKER_IMAGE }}
options: --user root
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git describe
submodules: recursive
- name: Configure CMake
run: |
cmake -B build/${{ matrix.target }} \
-G Ninja \
-DCMAKE_BUILD_TYPE=Release \
${{ matrix.cmake_args }}
- name: Build
run: |
cmake --build build/${{ matrix.target }} -- -j$(nproc)
- name: Verify artifacts exist
run: |
ls -la build/${{ matrix.target }}/*.{bin,hex,elf,map} || exit 1
- name: Generate checksums
run: |
cd build/${{ matrix.target }}
sha256sum *.bin *.hex *.elf > checksums.txt
cat checksums.txt
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
build/${{ matrix.target }}/*.bin
build/${{ matrix.target }}/*.hex
build/${{ matrix.target }}/*.elf
build/${{ matrix.target }}/*.map
build/${{ matrix.target }}/checksums.txt
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}

Step 4: Sign Artifacts with Cosign (Keyless)

Keyless signing via OIDC — no long-lived secrets in GitHub:

sign:
name: Sign Artifacts
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: firmware-*
path: artifacts
merge-multiple: true
- name: Install cosign
uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.4.0'
- name: Sign each artifact
run: |
for f in artifacts/*.bin artifacts/*.hex artifacts/*.elf; do
[ -f "$f" ] || continue
cosign sign-blob --yes --bundle "${f}.bundle" "$f"
done
- name: Upload signatures
uses: actions/upload-artifact@v4
with:
name: signatures
path: artifacts/*.bundle
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}

The signatures are verifiable by anyone using the Sigstore bundle (certificate + transparency log proof published to Rekor). No secret management or long-lived keys needed.

Step 5: Create GitHub Release with All Artifacts

release:
name: Create GitHub Release
needs: [build, sign]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for release notes generation
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: firmware-*
path: release-assets
merge-multiple: true
- name: Download signatures
uses: actions/download-artifact@v4
with:
name: signatures
path: release-assets
- name: Verify all checksums
run: |
cd release-assets
for dir in firmware-*/; do
if [ -f "${dir}checksums.txt" ]; then
echo "Verifying ${dir}"
(cd "$dir" && sha256sum -c checksums.txt) || exit 1
fi
done
- name: Generate release notes
id: changelog
run: |
# Extract commits since last tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
git log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD > release_notes.md
else
git log --pretty=format:"- %s (%h)" -n 20 > release_notes.md
fi
cat release_notes.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body_path: release_notes.md
files: |
release-assets/firmware-*/*.bin
release-assets/firmware-*/*.hex
release-assets/firmware-*/*.elf
release-assets/firmware-*/*.map
release-assets/firmware-*/checksums.txt
release-assets/*.bundle
draft: false
prerelease: ${{ contains(github.ref_name, '-rc') }}
generate_release_notes: false

For release candidates, run on real hardware:

hil-test:
name: HIL Test ${{ matrix.target }}
needs: build
if: contains(github.ref_name, '-rc')
runs-on: [self-hosted, linux, arm64, hil] # Your lab runner
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- target: stm32f407
openocd_cfg: "board/stm32f4discovery.cfg"
test_binary: "build/stm32f407/firmware-stm32f407.elf"
- target: nrf52840
openocd_cfg: "interface/jlink.cfg -f target/nrf52.cfg"
test_binary: "build/nrf52840/firmware-nrf52840.elf"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OpenOCD + GDB
run: |
apt-get update && apt-get install -y openocd gdb-multiarch
- name: Download test binary
uses: actions/download-artifact@v4
with:
name: firmware-${{ matrix.target }}
path: firmware
- name: Flash and run smoke test
run: |
openocd -f ${{ matrix.openocd_cfg }} \
-c "program ${{ matrix.test_binary }} verify reset exit" \
2>&1 | tee flash.log
- name: Run automated test suite
run: |
# Connect via GDB, run test functions, check results
gdb-multiarch -ex "target extended-remote :3333" \
-ex "load" -ex "continue" \
-batch ${{ matrix.test_binary }} \
2>&1 | tee test.log
# Parse test.log for PASS/FAIL

Self-hosted runner in your lab connects to target boards via OpenOCD. The workflow flashes, runs a test suite, reports results. Only for -rc tags — keeps PR checks fast.

Step 7: Enforce Clean Tree and Required Checks

Branch protection + workflow rules prevent accidental releases:

# .github/workflows/pr-checks.yml -- runs on every PR
name: PR Checks
on:
pull_request:
branches: [main]
env:
DOCKER_IMAGE: ghcr.io/yourorg/arm-toolchain@sha256:<digest>
jobs:
build-check:
name: Build Check ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: stm32f407
cmake_args: "-DMCU=STM32F407VGT6 -DBOARD=STM32F4_DISCOVERY"
- target: esp32
cmake_args: "-DMCU=ESP32 -DBOARD=ESP32_DEVKIT_V1"
- target: nrf52840
cmake_args: "-DMCU=NRF52840 -DBOARD=NRF52840_DK"
container:
image: ${{ env.DOCKER_IMAGE }}
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- name: Configure
run: cmake -B build/${{ matrix.target }} -G Ninja -DCMAKE_BUILD_TYPE=Release ${{ matrix.cmake_args }}
- name: Build
run: cmake --build build/${{ matrix.target }}
- name: Static analysis
run: |
# cppcheck, clang-tidy, or your preferred analyzer
cppcheck --enable=all --std=c11 --error-exitcode=1 src/

Branch protection rule on main:

  • Require status checks: Build Check stm32f407, Build Check esp32, Build Check nrf52840
  • Require linear history
  • Require signed commits (optional but recommended)
  • No force pushes

Now you cannot push a tag unless all targets build clean.

Release Pipeline Flow

+-----------------------------------------------------------------------+
| RELEASE PIPELINE FLOW |
+-----------------------------------------------------------------------+
| |
| DEVELOPER CI |
| +----------+ +----------+ +----------+ +----------+ |
| | Code | | Push | | Matrix | | Tag | |
| | Changes | ---> | Branch | ---> | Build | ---> | Push | |
| +----------+ +----------+ +----------+ +----------+ |
| | | | | |
| | | | | |
| v v v v |
| +----------+ +----------+ +----------+ +----------+ |
| | PR | | Checks | | Artifacts| | Release | |
| | Review | | Pass | | Generated| | Created | |
| +----------+ +----------+ +----------+ +----------+ |
| |
| HARDWARE LAB (self-hosted runner) |
| +----------+ +----------+ +----------+ |
| | Flash | ---> | Run Test | ---> | Report | |
| | Binary | | Suite | | Results | |
| +----------+ +----------+ +----------+ |
| |
+-----------------------------------------------------------------------+

Step 8: Verify the Release Locally

Anyone can verify a release artifact matches the source:

#!/bin/bash
# verify-release.sh -- run after downloading release artifacts
TAG="v1.2.3"
REPO="yourorg/your-firmware"
# Download artifacts
gh release download "$TAG" -R "$REPO" -D ./release-$TAG
# Verify checksums
cd ./release-$TAG
for f in firmware-*/checksums.txt; do
echo "Verifying $f"
(cd "$(dirname "$f")" && sha256sum -c checksums.txt) || exit 1
done
# Verify cosign signatures (keyless)
# Note: For keyless, you must specify the exact workflow identity that signed it
IDENTITY="https://github.com/${REPO}/.github/workflows/release.yml@refs/tags/${TAG}"
ISSUER="https://token.actions.githubusercontent.com"
for bundle in *.bundle; do
artifact="${bundle%.bundle}"
if [ -f "$artifact" ]; then
echo "Verifying signature for $artifact"
cosign verify-blob \
--bundle "$bundle" \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"$artifact" || exit 1
fi
done
# Extract git SHA embedded in binary and display it
for elf in firmware-*/*.elf; do
SHA=$(strings "$elf" | grep -E '^[0-9a-f]{40}$' | head -1)
echo "$elf -> Git SHA: $SHA"
done
echo "All verifications passed."

Common Pitfalls

PitfallFix
git describe fails in shallow cloneUse fetch-depth: 0 in checkout
Toolchain version drift between runsPin Docker image by SHA256 digest
Release artifacts overwrite each otherUse unique artifact names per target (firmware-<target>)
Signing fails on fork PRsRestrict release workflow to push on tags only; forks can’t push tags to upstream
HIL tests flakyAdd retries, isolate test hardware, run only on -rc tags
Binary size grows unnoticedAdd size step in build job, fail if .text exceeds threshold

Summary

Automated firmware releases aren’t a luxury — they’re the minimum viable process for any team shipping to production. The pipeline above gives you:

  • Reproducibility: Pinned toolchain in Docker, full git history at build time
  • Traceability: Git SHA, version, dirty flag embedded in every binary
  • Verifiability: SHA256 checksums + cosign signatures on every artifact
  • Scalability: Matrix strategy adds new targets in 5 lines of YAML
  • Hardware validation: Optional HIL tests on real boards for release candidates

Start with the Dockerfile and version embedding — those two changes alone eliminate the “which commit built this?” problem. Add matrix builds, signing, and HIL incrementally. The first automated release pays for the setup time.

References

  1. GitHub Actions Documentation — “Using matrix strategies for multi-platform builds” — https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
  2. Sigstore Cosign — “Keyless signing with GitHub Actions OIDC” — https://docs.sigstore.dev/cosign/
  3. ARM Embedded Toolchain — “Arm GNU Toolchain releases on GitLab” — https://gitlab.arm.com/tooling/gnu-toolchains-for-arm/-/tree/main
  4. CMake Documentation — “configure_file() for generating version headers” — https://cmake.org/cmake/help/latest/command/configure_file.html
  5. Semantic Versioning 2.0.0 — “Specification for versioning schemes” — https://semver.org/
  6. SLSA Framework — “Supply chain Levels for Software Artifacts” — https://slsa.dev/

Frequently Asked Questions

Why use GitHub Actions for embedded firmware releases instead of Jenkins or GitLab CI?

GitHub Actions runs natively in your repository with zero infrastructure setup. Its matrix strategy handles multi-target builds (STM32, ESP32, nRF52) elegantly, and the release workflow triggers on tag push with automatic artifact attachment. For teams already on GitHub, it eliminates context switching and reduces maintenance burden compared to self-hosted Jenkins.

How do you prevent releasing firmware built from uncommitted or dirty working trees?

The workflow must enforce a clean git state before building. Use `git status --porcelain` in a pre-build step to fail if uncommitted changes exist. Additionally, embed the git commit SHA and dirty flag into the binary via linker symbols or a generated header so the running firmware can report its exact provenance at runtime.

What's the recommended versioning scheme for embedded firmware releases?

Use Semantic Versioning (MAJOR.MINOR.PATCH) with git tags matching `v*` pattern. MAJOR for breaking API/hardware changes, MINOR for new features, PATCH for bug fixes. Embed the version in the binary via `VERSION` linker symbol and expose it over a debug CLI or boot log. Never rely solely on build numbers -- they don't convey compatibility semantics.

How do you handle signing and verification of release artifacts?

Generate SHA256 checksums for all artifacts (`.bin`, `.hex`, `.elf`) and publish them alongside releases. For production, sign artifacts with a code-signing certificate using `cosign` or `gpg` in the workflow. Verify signatures in the bootloader before flashing. Store public keys in the repository; private keys only in GitHub Environments with required reviewers.

Can you run target hardware tests in GitHub Actions?

GitHub-hosted runners cannot access physical hardware. For hardware-in-the-loop (HIL) testing, use self-hosted runners connected to your lab (e.g., Raspberry Pi + OpenOCD + target board) or cloud device farms like Memfault. The workflow can conditionally trigger HIL jobs only for release candidates, keeping PR checks fast on cloud runners.

Tags

github-actionsci-cdfirmware-releaseembedded-buildautomationversioning

Share


Previous Article
Fixing FreeRTOS Task Starvation: Priority Boosting & Aging
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Managing Vendor SDK Updates Without Breaking Embedded Builds
Managing Vendor SDK Updates Without Breaking Embedded Builds
August 20, 2026
4 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media