
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.
| Pain Point | Pipeline 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 |
+------------------+ +------------------+ +------------------+| Push Tag |---->| Matrix Build |---->| Collect & Sign || (v1.2.3) | | (STM32/ESP32/ | | Artifacts || | | nRF52) | | |+------------------+ +------------------+ +------------------+|v+------------------+ +------------------+ +------------------+| GitHub Release |<----| Attach |<----| Verify || (auto-generated)| | Artifacts | | Checksums/Sigs |+------------------+ +------------------+ +------------------+
Reproducibility starts with the build environment. Don’t rely on apt-get install gcc-arm-none-eabi — versions drift.
# .github/docker/arm-toolchain/DockerfileFROM ubuntu:22.04# Pin exact versions -- update intentionally, not accidentallyARG GCC_VERSION=13.2.rel1ARG CMAKE_VERSION=3.28.3ARG NINJA_VERSION=1.11.1RUN 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"# CMakeRUN 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"# NinjaRUN 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.zipENV 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 /workspaceENTRYPOINT ["/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-toolchaindocker push ghcr.io/yourorg/arm-toolchain:13.2.rel1
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.
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 firmwareexecute_process(COMMAND git rev-parse HEADWORKING_DIRECTORY ${CMAKE_SOURCE_DIR}OUTPUT_VARIABLE GIT_SHA1OUTPUT_STRIP_TRAILING_WHITESPACE)execute_process(COMMAND git rev-parse --short HEADWORKING_DIRECTORY ${CMAKE_SOURCE_DIR}OUTPUT_VARIABLE GIT_SHA1_SHORTOUTPUT_STRIP_TRAILING_WHITESPACE)# Check for uncommitted changesexecute_process(COMMAND git status --porcelainWORKING_DIRECTORY ${CMAKE_SOURCE_DIR}OUTPUT_VARIABLE GIT_STATUSOUTPUT_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 fallbackexecute_process(COMMAND git describe --tags --always --dirty=-dirtyWORKING_DIRECTORY ${CMAKE_SOURCE_DIR}OUTPUT_VARIABLE FULL_VERSIONOUTPUT_STRIP_TRAILING_WHITESPACE)# Build timestamp (ISO 8601)string(TIMESTAMP CMAKE_BUILD_TIME "%Y-%m-%dT%H:%M:%SZ" UTC)# Generate version headerconfigure_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.
One workflow, multiple MCUs. The matrix strategy scales:
# .github/workflows/release.ymlname: Firmware Releaseon:push:tags:- 'v*' # Only trigger on version tagspermissions:contents: write # Create release, upload artifactsid-token: write # For cosign signingattestations: write # For SLSA provenanceenv:DOCKER_IMAGE: ghcr.io/yourorg/arm-toolchain@sha256:<digest>ARTIFACT_RETENTION_DAYS: 90jobs:build:name: Build ${{ matrix.target }}runs-on: ubuntu-latesttimeout-minutes: 30strategy:fail-fast: falsematrix:include:- target: stm32f407board: STM32F407VGcmake_args: "-DMCU=STM32F407VGT6 -DBOARD=STM32F4_DISCOVERY"artifact_name: "firmware-stm32f407"- target: esp32board: ESP32-WROOM-32cmake_args: "-DMCU=ESP32 -DBOARD=ESP32_DEVKIT_V1"artifact_name: "firmware-esp32"- target: nrf52840board: nRF52840-DKcmake_args: "-DMCU=NRF52840 -DBOARD=NRF52840_DK"artifact_name: "firmware-nrf52840"container:image: ${{ env.DOCKER_IMAGE }}options: --user rootsteps:- name: Checkoutuses: actions/checkout@v4with:fetch-depth: 0 # Full history for git describesubmodules: recursive- name: Configure CMakerun: |cmake -B build/${{ matrix.target }} \-G Ninja \-DCMAKE_BUILD_TYPE=Release \${{ matrix.cmake_args }}- name: Buildrun: |cmake --build build/${{ matrix.target }} -- -j$(nproc)- name: Verify artifacts existrun: |ls -la build/${{ matrix.target }}/*.{bin,hex,elf,map} || exit 1- name: Generate checksumsrun: |cd build/${{ matrix.target }}sha256sum *.bin *.hex *.elf > checksums.txtcat checksums.txt- name: Upload artifactsuses: actions/upload-artifact@v4with:name: ${{ matrix.artifact_name }}path: |build/${{ matrix.target }}/*.binbuild/${{ matrix.target }}/*.hexbuild/${{ matrix.target }}/*.elfbuild/${{ matrix.target }}/*.mapbuild/${{ matrix.target }}/checksums.txtretention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
Keyless signing via OIDC — no long-lived secrets in GitHub:
sign:name: Sign Artifactsneeds: buildruns-on: ubuntu-latestpermissions:id-token: writeattestations: writecontents: readsteps:- name: Download all artifactsuses: actions/download-artifact@v4with:pattern: firmware-*path: artifactsmerge-multiple: true- name: Install cosignuses: sigstore/cosign-installer@v3with:cosign-release: 'v2.4.0'- name: Sign each artifactrun: |for f in artifacts/*.bin artifacts/*.hex artifacts/*.elf; do[ -f "$f" ] || continuecosign sign-blob --yes --bundle "${f}.bundle" "$f"done- name: Upload signaturesuses: actions/upload-artifact@v4with:name: signaturespath: artifacts/*.bundleretention-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.
release:name: Create GitHub Releaseneeds: [build, sign]runs-on: ubuntu-latestpermissions:contents: writesteps:- name: Checkoutuses: actions/checkout@v4with:fetch-depth: 0 # Full history for release notes generation- name: Download all artifactsuses: actions/download-artifact@v4with:pattern: firmware-*path: release-assetsmerge-multiple: true- name: Download signaturesuses: actions/download-artifact@v4with:name: signaturespath: release-assets- name: Verify all checksumsrun: |cd release-assetsfor dir in firmware-*/; doif [ -f "${dir}checksums.txt" ]; thenecho "Verifying ${dir}"(cd "$dir" && sha256sum -c checksums.txt) || exit 1fidone- name: Generate release notesid: changelogrun: |# Extract commits since last tagPREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")if [ -n "$PREV_TAG" ]; thengit log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD > release_notes.mdelsegit log --pretty=format:"- %s (%h)" -n 20 > release_notes.mdficat release_notes.md- name: Create Releaseuses: softprops/action-gh-release@v2with:tag_name: ${{ github.ref_name }}name: Release ${{ github.ref_name }}body_path: release_notes.mdfiles: |release-assets/firmware-*/*.binrelease-assets/firmware-*/*.hexrelease-assets/firmware-*/*.elfrelease-assets/firmware-*/*.maprelease-assets/firmware-*/checksums.txtrelease-assets/*.bundledraft: falseprerelease: ${{ contains(github.ref_name, '-rc') }}generate_release_notes: false
For release candidates, run on real hardware:
hil-test:name: HIL Test ${{ matrix.target }}needs: buildif: contains(github.ref_name, '-rc')runs-on: [self-hosted, linux, arm64, hil] # Your lab runnertimeout-minutes: 60strategy:fail-fast: falsematrix:include:- target: stm32f407openocd_cfg: "board/stm32f4discovery.cfg"test_binary: "build/stm32f407/firmware-stm32f407.elf"- target: nrf52840openocd_cfg: "interface/jlink.cfg -f target/nrf52.cfg"test_binary: "build/nrf52840/firmware-nrf52840.elf"steps:- name: Checkoutuses: actions/checkout@v4- name: Install OpenOCD + GDBrun: |apt-get update && apt-get install -y openocd gdb-multiarch- name: Download test binaryuses: actions/download-artifact@v4with:name: firmware-${{ matrix.target }}path: firmware- name: Flash and run smoke testrun: |openocd -f ${{ matrix.openocd_cfg }} \-c "program ${{ matrix.test_binary }} verify reset exit" \2>&1 | tee flash.log- name: Run automated test suiterun: |# Connect via GDB, run test functions, check resultsgdb-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.
Branch protection + workflow rules prevent accidental releases:
# .github/workflows/pr-checks.yml -- runs on every PRname: PR Checkson: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-lateststrategy:fail-fast: falsematrix:include:- target: stm32f407cmake_args: "-DMCU=STM32F407VGT6 -DBOARD=STM32F4_DISCOVERY"- target: esp32cmake_args: "-DMCU=ESP32 -DBOARD=ESP32_DEVKIT_V1"- target: nrf52840cmake_args: "-DMCU=NRF52840 -DBOARD=NRF52840_DK"container:image: ${{ env.DOCKER_IMAGE }}steps:- uses: actions/checkout@v4with: { submodules: recursive, fetch-depth: 0 }- name: Configurerun: cmake -B build/${{ matrix.target }} -G Ninja -DCMAKE_BUILD_TYPE=Release ${{ matrix.cmake_args }}- name: Buildrun: cmake --build build/${{ matrix.target }}- name: Static analysisrun: |# cppcheck, clang-tidy, or your preferred analyzercppcheck --enable=all --std=c11 --error-exitcode=1 src/
Branch protection rule on main:
Build Check stm32f407, Build Check esp32, Build Check nrf52840Now you cannot push a tag unless all targets build clean.
+-----------------------------------------------------------------------+| 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 | || +----------+ +----------+ +----------+ || |+-----------------------------------------------------------------------+
Anyone can verify a release artifact matches the source:
#!/bin/bash# verify-release.sh -- run after downloading release artifactsTAG="v1.2.3"REPO="yourorg/your-firmware"# Download artifactsgh release download "$TAG" -R "$REPO" -D ./release-$TAG# Verify checksumscd ./release-$TAGfor f in firmware-*/checksums.txt; doecho "Verifying $f"(cd "$(dirname "$f")" && sha256sum -c checksums.txt) || exit 1done# Verify cosign signatures (keyless)# Note: For keyless, you must specify the exact workflow identity that signed itIDENTITY="https://github.com/${REPO}/.github/workflows/release.yml@refs/tags/${TAG}"ISSUER="https://token.actions.githubusercontent.com"for bundle in *.bundle; doartifact="${bundle%.bundle}"if [ -f "$artifact" ]; thenecho "Verifying signature for $artifact"cosign verify-blob \--bundle "$bundle" \--certificate-identity "$IDENTITY" \--certificate-oidc-issuer "$ISSUER" \"$artifact" || exit 1fidone# Extract git SHA embedded in binary and display itfor elf in firmware-*/*.elf; doSHA=$(strings "$elf" | grep -E '^[0-9a-f]{40}$' | head -1)echo "$elf -> Git SHA: $SHA"doneecho "All verifications passed."
| Pitfall | Fix |
|---|---|
git describe fails in shallow clone | Use fetch-depth: 0 in checkout |
| Toolchain version drift between runs | Pin Docker image by SHA256 digest |
| Release artifacts overwrite each other | Use unique artifact names per target (firmware-<target>) |
| Signing fails on fork PRs | Restrict release workflow to push on tags only; forks can’t push tags to upstream |
| HIL tests flaky | Add retries, isolate test hardware, run only on -rc tags |
| Binary size grows unnoticed | Add size step in build job, fail if .text exceeds threshold |
Automated firmware releases aren’t a luxury — they’re the minimum viable process for any team shipping to production. The pipeline above gives you:
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.
Quick Links
Legal Stuff




