Firmware CI/CD on GitHub Actions — the golden procedure for building, testing, reviewing and releasing firmware on hosted runners, with flashing and on-device tests left to the bench.
Every change builds, tests, lints and simulates; every pull request is reviewed; every tag releases; the bench does what hosted runners cannot.
workflow
trigger
runs
output
Build
push to main, pull request, manual
one job per variant; toolchain from a container built from the repo's Dockerfile with a layer cache, or a minimal SDK cached on the plain runner when the image does not fit the runner disk; a fan-in job gives the matrix one check name
run artefacts per variant named <variant>-<sha>, retention-days 14–30 (release artefacts 90); size report
Unit tests
push, pull request
host tests in their own test container, coverage, JUnit
JUnit published as a check, coverage artefact
Fast lints
push, pull request
scripts that read only the source tree (layering, dependency manifest), no container, no submodules, seconds; also dry-run any release-only generator here, so one broken since the last release fails on a pull request, not on tag day
pass/fail
Simulation regression
push, pull request
host build of the algorithm model, fixed scenarios; needs the submodule token only for the submodule it uses (path: input), not all of them
pass/fail
Automated review
pull request opened or updated, not drafts
agent reads the diff against the repo rules file
review comment; not a required check, because it skips drafts and doc-only changes
Release
tag vX.Y.Z (manual dry-run optional)
version from the tag into the build; all variants, bootloader package, update images signed with the release key; changelog since the previous tag; dependency manifest checked against the tree, then snapshotted
draft GitHub Release: images, update set, bootloader, manifest, changelog, snapshot, one zip
recipe
how
private submodules
mint a token per run with actions/create-github-app-token (App client ID as a repository variable, private key as a secret); rewrite https://github.com/ and git@github.com: to https://x-access-token:${TOKEN}@github.com/ with git config --global --add url.<token url>.insteadOf (twice, --add so the second mapping does not replace the first); git submodule sync, then git submodule update --init --depth 1; a personal token works too but expires, and renewing it means gh secret set, not only the account
container
docker/build-push-action from the repo Dockerfile with cache-from/cache-to: type=gha and load: true, then docker run --user $(id -u):$(id -g) -v $GITHUB_WORKSPACE:/work; if the image does not fit the 14 GB runner disk, install a minimal toolchain on the plain runner and cache it with actions/cache keyed on its version
required check
a job is required only when branch protection or a ruleset lists its name; a matrix needs one fan-in job (needs, if: always(), fail unless every result is success); a required workflow must not use path filters or skip drafts, or such pull requests wait forever
test report
publish the JUnit XML with a test-reporter action (java-junit, fail on error) under job permission checks: write; upload coverage and the XML with actions/upload-artifact, retention 7–30 days
release
checkout with fetch-depth: 0; VERSION=${GITHUB_REF#refs/tags/}; changelog = git log $(git describe --tags --abbrev=0 ${VERSION}^)..${VERSION} --pretty='- %s (%h)'; artefacts <project>-<variant>-<version>.<ext> plus one zip; publish with a release action as a draft, prerelease when the version has a suffix, under contents: write; write the signing-key PEM from the secret to $RUNNER_TEMP, and without the secret sign with the dev key and emit a warning; the same key signs the images and is built into the bootloader package, or release bootloaders would accept dev-signed images; dry-run = workflow_dispatch with a version input, the version step takes the tag when github.event_name == 'push' else the input, and the draft-release step runs only if: startsWith(github.ref, 'refs/tags/')
automated review
uses: anthropics/claude-code-action@<commit-sha> with claude_code_oauth_token: ${{ secrets.<review token> }} and additional_permissions: actions: read (the job permission alone does not let the agent read CI results); job permissions contents: read, pull-requests: write, id-token: write, actions: read; prompt: points at the rules file (CLAUDE.md / REVIEW.md); claude_args restricts the model and the allowed tools to pull-request reading commands; skip drafts; one concurrency group per pull request
secret or variable
purpose
rule
App client ID (variable) + App private key (secret)
mints the short-lived submodule token
preferred: nothing expires unnoticed
personal access token (secret)
same, when no App exists
expires; renew in the repo secret
release signing key (secret)
signs the update images
secret only; a device accepts only images signed with the key its bootloader carries
review-agent token (secret)
the automated review action
secret only; the action that reads it is pinned to a commit SHA, never a floating tag
GITHUB_TOKEN
creates the draft release
needs permissions: contents: write on that job
Every workflow also sets concurrency per ref with cancel-in-progress, timeout-minutes, permissions: contents: read by default, and path filters on non-required workflows. Hosted runners build, test, lint, review and sign; flashing and on-device tests are done on the bench, or by a self-hosted runner on the bench machine.