CI for firmware
Objectives
Section titled “Objectives”By the end of this lesson, you will be able to
- Write a GitHub Actions workflow that runs the host unit tests on every pull request.
- Add a clang-format check and cppcheck static analysis step.
- Explain what CI on public runners can check, and what still needs a real board.
Takes about 70 minutes (concepts 15 · practice 25 · lab 25 · check 5). You need everything from lesson 6.1, plus python3. If you want to run every step locally, you also need cppcheck and gcc-arm-none-eabi (on Ubuntu: sudo apt-get install cppcheck gcc-arm-none-eabi). The lab needs a GitHub account.
Before you start
Section titled “Before you start”Two review questions.
- The
pre-commithook from lesson 2.3 checks every commit on your own machine. Why still isn’t that enough for a team (hint:git commit --no-verify)? - Lesson 6.1 said what a passing test can prove — and how did you prove it can genuinely fail?
See it work first
Section titled “See it work first”The SDK repository this course uses has exactly one GitHub Actions workflow at commit ef72c1b. Below is its header and first step.
on: release: types: [published] workflow_dispatch:
permissions: contents: read# ... (concurrency and env omitted)jobs: notify: runs-on: ubuntu-latest steps: - name: Ask the relay to re-read GitHub run: | set -euo pipefail echo "POST $RELAY/api/firmware/github/refresh" code=$(curl -s -o /tmp/refresh.json -w '%{http_code}' -X POST --max-time 120 \ "$RELAY/api/firmware/github/refresh") echo "HTTP $code"; cat /tmp/refresh.json; echo test "$code" = "200"Source: .github/workflows/notify-flash-relay.yml lines 20-46 (Apache-2.0, tesaiot-pse84-devkit-sdk)
Predict before reading on, three questions: when does this workflow run? Does it build firmware? And if curl gets an HTTP 500, does this step turn green or red?
The answers: it runs when a release is published (or triggered manually). It does not build firmware — it just tells the Remote Flash relay to fetch the new release. A later step in the same file then verifies with sha256 that the .hex file actually arrived (“Firing the refresh is not the same as the file arriving”). If it gets a 500, the last line, test "$code" = "200", returns non-zero, so the step turns red. That line is what makes this step able to fail — without it, the echo above would print 500 and still finish with 0. This lesson applies the same principle to every check.
Concepts
Section titled “Concepts”1. Workflow, job, step, and “a thin workflow, a thick script”
Section titled “1. Workflow, job, step, and “a thin workflow, a thick script””GitHub Actions reads YAML files from a repo’s .github/workflows/. Three terms worth telling apart:
| Term | What it is | In the file |
|---|---|---|
| workflow | One file, woken by an event such as pull_request or push |
on: |
| job | One unit of work on its own fresh virtual machine; different jobs can run in parallel and don’t see each other’s files | jobs.<name>.runs-on |
| step | One command inside a job; a step that returns non-zero fails the job | steps: entries with uses: (a packaged action) or run: (a shell command) |
Three conventions used throughout this lesson.
- A thin workflow, a thick script. Put the real checking commands into ci.sh in the repo, and have the workflow just install tools and call
bash ci.sh <stage>. You can run the exact same commands locally before pushing, without waiting on CI to find out the result. - Least privilege. GitHub’s documentation recommends “It’s good security practice to set the default permission for the
GITHUB_TOKENto read access only for repository contents” — that’spermissions: contents: read, the same as the SDK’s workflow above — and once any permission is stated explicitly, every unstated one becomesnone. - Pin everything that can move on its own. A tag like
v7on an action can be moved to point at a different commit. GitHub’s documentation states “Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release.” A runner label likeubuntu-latestcan move too (runner-images says it “point[s] towards the newest stable OS version available”), which changes the version of cppcheck and the compiler that apt provides. This lesson therefore usesubuntu-24.04, and setstimeout-minutes, because without it, a hung job can run for up to 360 minutes.
2. Four checking stages, and making every one able to fail
Section titled “2. Four checking stages, and making every one able to fail”| Stage | Tool | Catches |
|---|---|---|
tests |
Unity + prove_red.sh from lesson 6.1 |
Wrong logic, and tests that don’t actually test anything |
format |
clang-format 18.1.3 with .clang-format | Code formatted differently than the team agreed, keeping pull request diffs readable |
static |
cppcheck | Bugs visible without running the code, such as writing past an array’s bounds |
cross |
arm-none-eabi-gcc for Cortex-M33 and Cortex-M55 | Logic that compiles on a computer but not on the microcontroller |
The most common CI trap is a stage that reports a problem but still stays green. We tested this with both tools this lesson uses, and got the same result both times.
clang-format --dry-runfinds badly formatted code, prints a warning, and returns 0. You must add--Werrorto get 1.cppcheckfinds anarrayIndexOutOfBounds, printserror:, and returns 0. You must add--error-exitcode=1.- A command ending in
|| true, or a step withcontinue-on-error: true, is always green no matter what happens inside. - In bash,
( steps ) || rc=$?turns offset -efor every command inside the subshell — a command that fails partway through gets skipped, and the whole thing ends with 0. (ci.sh notes this inside itsrun_stagefunction.)
The cross stage uses Cortex-M33’s flags, following the SDK template’s own library Makefile.
# Toolchain (same as project)GCC_PATH := /Applications/mtb-gcc-arm-eabi/14.2.1/gcc/binCC := $(GCC_PATH)/arm-none-eabi-gccAR := $(GCC_PATH)/arm-none-eabi-ar
# Compiler flags for PSoC Edge Cortex-M33 (MUST match project: softfp)CFLAGS := -mcpu=cortex-m33 -mthumb -mfloat-abi=softfp -mfpu=fpv5-sp-d16Source: bento_libs/claw/kit-pse84-ai/tesaiot/Makefile lines 25-31 (Apache-2.0, tesaiot-pse84-devkit-sdk)
Notice GCC_PATH points at a folder on one particular macOS machine — this line doesn’t work as-is on a CI runner. This is exactly the kind of hidden assumption about a machine that CI helps surface. And the compiler that line names is ModusToolbox’s GCC 14.2.1, while gcc-arm-none-eabi from Ubuntu 24.04’s apt is 13.2.1 — so the cross stage checks that the logic compiles for the board’s CPU, not that it produces the exact same binary as a real build.
3. What CI on a public runner can check, and what needs a board
Section titled “3. What CI on a public runner can check, and what needs a board”| CI on a public runner can check | Needs a real board |
|---|---|
| Logic that lives behind a seam (lesson 6.1) | Interrupt and timer timing (lessons 4.1, 4.2) |
| Code formatting, and bugs statically analysable | Cache and DMA ordering with barriers (lesson 4.4) — that lesson’s host tests can simulate it, but can’t prove it |
| Logic that compiles for Cortex-M33 and Cortex-M55 | Real signals on the UART, I2C, SPI wires (module 5) |
| Files that shouldn’t be in the repo, such as what lesson 2.3’s hook checks | A real watchdog reset, and a board booting after an unplug-replug (lessons 4.3, 2.1) |
Why not build the whole firmware image in CI? The same Makefile states one reason plainly: “Source files (tesaiot_*.c) are proprietary”, distributing a prebuilt libtesaiot.a instead (lines 10-11). And the SDK’s own git repository has no .a files at all, since .gitignore excludes them — lesson 2.1 has you build from the release zip instead. A full build therefore needs ModusToolbox fully installed, along with the release’s package, which you did on your own machine following lesson 2.1 and the TESAIoT firmware course’s build and flash steps.
The way to connect a board to CI is a self-hosted runner with a board plugged in (hardware-in-the-loop), but GitHub’s documentation warns: “Self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment.” A public repository should therefore keep board testing in a private repository, or do it by hand, and record the evidence.
Worked example
Section titled “Worked example”The examples/ folder has five files.
- host-tests.yml: the smallest usable workflow — one job, running lesson 6.1’s tests. Comments in the file explain workflow, job and step.
- ci.sh: the four checking stages. Every stage ends with
PASS,FAIL, orNOT CHECKED(the tool is missing, or there’s no file to check), andNOT CHECKEDreturns non-zero, just likeFAIL. - .clang-format: this course’s code style. Lesson 6.1’s C files are already formatted with this file.
- check_workflow.py: checks a workflow’s structure against this lesson’s four-job rule, locally (it does not run a real workflow).
- .gitignore: excludes the
.venv/created during practice.
Run all four stages against lesson 6.1’s code locally (you must have cloned Unity into lesson 6.1’s examples/unity first):
cd examplespython3 -m venv .venv.venv/bin/pip install clang-format==18.1.3 pyyaml==6.0.3SRC_DIR=../../l01-unit-tests-on-host/examples TEST=../solution/test_level_alarm.c \ CLANG_FORMAT=.venv/bin/clang-format bash ci.sh allThe result we got: tests, format, static and cross all show PASS. If your machine has no cppcheck, static will show NOT CHECKED, and the command ends with a non-zero status — which is correct.
Try making each stage turn red, predicting the result first. Do this in a copy of lesson 6.1’s example folder, not the real files.
format: remove the spaces around=in one line,a->count = 0u;.static: add a function with a loopfor (int i = 0; i <= 4; i++)writing intoint history[4].cross: add_Static_assert(sizeof(long) == 8, "assumes a 64-bit long");right after the#includes. Thetestsstage still passes, becauselongon a 64-bit computer is 8 bytes — butcrossturns red, because on Cortex-M it’s 4 bytes. A data type’s size depends on the architecture (lesson 1.1).tests: withTEST=test_level_alarm_first.c, both tests pass, yet this stage turns red, becauseprove_red.shfinds a bug that survives.
Practice
Section titled “Practice”Open practice/firmware-ci.yml. The tests job is already given; there are 6 gaps to fill in — more than in earlier lessons, matching this module’s pace.
- Run on every pull request.
- Restrict
GITHUB_TOKEN’s permission to read-only. - Pin every action with a full commit SHA.
- The
formatjob: install the pinned version of clang-format, then runbash ci.sh format. - The
static-analysisjob: install cppcheck, then runbash ci.sh static. - The
cross-compilejob: install gcc-arm-none-eabi, then runbash ci.sh cross.
Check it locally before pushing:
cd examples.venv/bin/python check_workflow.py ../practice/firmware-ci.ymlBefore you fill anything in, the checker shows 10 failed. The work is done once it shows 0 failed. This checker also catches || true and continue-on-error — try adding one and watch it turn red.
Solution
Section titled “Solution”Try it yourself for at least 15 minutes first, then open solution/firmware-ci.yml. We checked the solution: the checker shows 21 passed, 0 failed. Worth comparing:
- The
formatjob installs clang-format via pip at version 18.1.3, instead of whatever ships with the runner, because theubuntu-24.04image carries three clang-format versions (16.0.6, 17.0.6, 18.1.3, per the image’s list, 20260920 revision), and different versions can format differently. pip gives the same version on Linux, Windows and macOS runners alike. concurrencywithcancel-in-progress: truecancels an older run when a new push lands on the same pull request, while the SDK’s workflow sets it tofalse, letting a run that’s already started finish. Which is right depends on the job: checking old code is pointless once new code exists, but a notification cancelled partway through might never reach its destination.
Check your understanding
Section titled “Check your understanding”Answer the 5 questions in quiz.yaml (shown at the bottom of this page on the website), covering all three objectives. Getting 4 or more right counts as finishing the lesson.
Task: have GitHub check your work on every pull request, and prove it can turn red.
- Create your own repository (private or public), laid out like this:
ci.shand.clang-formatfrom this lesson at the root; ahost-tests/folder holding the files from lesson 6.1’sexamples/(exceptunity/andbuild/), with your fully-completedtest_level_alarm.c; and the workflow you wrote, at.github/workflows/firmware-ci.yml. (If you did lesson 6.1’s lab with your own logic instead, you can use that — just adjust the filenames inprove_red.shto match.) - Push, then open the Actions tab. All four jobs must be green.
- Open a pull request that plants one bug from the worked example section. Check that the job that should turn red actually does, then close that pull request without merging.
- Move one check from lesson 2.3’s
pre-commithook into a stage insideci.sh, and prove with a pull request that--no-verifycan no longer skip it. - If you have a board, write a short note on what kind of change in this repository could leave CI green while you’d still need to flash a board to check it — and what exactly you’d be checking.
Evidence to keep in your portfolio: the repository’s link, a picture or link of a fully green run, the link to the red pull request with a note on what the bug was and which job caught it, and your answer to question 5.
Going further
Section titled “Going further”- Read the SDK’s notify-flash-relay.yml workflow in full. What does its second step check, and why did the author separate it from another workflow that also runs on a release? (The header comment refers to
firmware-index.yml, which doesn’t exist at this commit, but the reasoning is fully there in the comments.) - Try turning on cppcheck’s
--enable=styleagainst your own code. Which messages are useful, and which should be suppressed with// cppcheck-suppressand a reason?
Module 6, and this course, are now finished. Go back and look at the end-of-module checkpoint and the course page. The next course that builds on everything here is the TESAIoT Firmware course.
Reflect
Section titled “Reflect”- If CI has been green every time for six months, how would you know it’s still checking anything?
- Which part of your work still relies on “it passes on my machine,” and what would it take to move it into CI?
References
Section titled “References”- GitHub Actions documentation
- ClangFormat
- Cppcheck
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Docs: Workflow syntax
- actions/runner-images
Review questions
Answer on your own first, then open the answer.
-
The team wants every pull request to pass the unit tests before merging. Which part of the workflow makes GitHub run it when a pull request is opened or updated? (Objective 1)
- pull_request ใต้ on:
- runs-on: ubuntu-24.04
- permissions: contents: read
- timeout-minutes: 10
Show answer
Answer: A. pull_request ใต้ on:
on: บอกว่าเหตุการณ์ใดปลุก workflow runs-on เลือกเครื่อง permissions จำกัดสิทธิ์ของ token และ timeout-minutes จำกัดเวลา ไม่มีข้อไหนนอกจาก on: ที่กำหนดว่าจะรันเมื่อไร
-
Which make a workflow behave the same when it runs again months later? (choose all that apply) (Objective 1)
- ล็อก action ด้วย commit SHA เต็ม 40 ตัว แทน tag อย่าง v7
- ใช้ runs-on: ubuntu-24.04 แทน ubuntu-latest
- ติดตั้ง clang-format ด้วย pip ที่รุ่น 18.1.3
- ใช้ ubuntu-latest เพื่อให้ได้เครื่องมือรุ่นใหม่เสมอ
Show answer
Answer: A. ล็อก action ด้วย commit SHA เต็ม 40 ตัว แทน tag อย่าง v7 · B. ใช้ runs-on: ubuntu-24.04 แทน ubuntu-latest · C. ติดตั้ง clang-format ด้วย pip ที่รุ่น 18.1.3
tag ของ action ย้ายได้ และเอกสาร GitHub ระบุว่าการล็อกด้วย SHA เต็มเป็นทางเดียวที่ได้รุ่นที่ไม่เปลี่ยน ubuntu-latest ชี้รุ่นล่าสุดของระบบปฏิบัติการ จึงเปลี่ยนรุ่นของเครื่องมือที่ apt ให้ได้ ส่วน clang-format รุ่นต่างกันจัดรูปแบบต่างกันได้
-
The CI format step runs clang-format --dry-run on a misformatted file. The log shows warnings but the job is green. What is the most likely cause? (Objective 2)
- ขาด --Werror จึงพิมพ์คำเตือนแต่คืน 0
- runner ไม่มี clang-format
- ไฟล์ .clang-format เสีย
- GitHub ไม่อ่านค่าที่คืนจาก step
Show answer
Answer: A. ขาด --Werror จึงพิมพ์คำเตือนแต่คืน 0
เราทดลองกับ clang-format 18.1.3 แล้ว --dry-run อย่างเดียวคืน 0 แม้เจอโค้ดที่จัดรูปแบบผิด ต้องเติม --Werror จึงคืน 1 ถ้าไม่มีเครื่องมือหรือ config เสีย จะคืนค่าที่ไม่ใช่ 0 และ job จะแดง
-
Which can make a CI check green even when the tool found a problem? (choose all that apply) (Objective 2)
- cppcheck ที่ไม่มี --error-exitcode
- ต่อท้ายคำสั่งด้วย || true
- ตั้ง continue-on-error: true ให้ step
- ล็อก action ด้วย commit SHA
Show answer
Answer: A. cppcheck ที่ไม่มี --error-exitcode · B. ต่อท้ายคำสั่งด้วย || true · C. ตั้ง continue-on-error: true ให้ step
cppcheck พิมพ์ error แต่คืน 0 ถ้าไม่ระบุ --error-exitcode ส่วน || true และ continue-on-error กลืนความล้มเหลวทั้งหมด การล็อก SHA ไม่เกี่ยวกับผลของการตรวจ วิธีจับคือจงใจใส่บั๊กแล้วดูให้ขั้นนั้นแดง แบบเดียวกับบทเรียน 6.1
-
CI on public runners is green on all four stages. Which still need checking on a real board? (choose all that apply) (Objective 3)
- ข้อมูลที่ DMA เขียนแล้ว CPU อ่านผ่าน cache ถูกต้อง
- watchdog รีเซ็ตบอร์ดจริงเมื่องานค้าง
- ตรรกะของ state machine เปลี่ยนสถานะเมื่อค่าถึงเกณฑ์พอดี
- ไฟล์ C คอมไพล์ได้สำหรับ Cortex-M33
Show answer
Answer: A. ข้อมูลที่ DMA เขียนแล้ว CPU อ่านผ่าน cache ถูกต้อง · B. watchdog รีเซ็ตบอร์ดจริงเมื่องานค้าง
cache กับ DMA และการรีเซ็ตของ watchdog เกิดในฮาร์ดแวร์ test บนคอมพิวเตอร์จำลองได้แต่พิสูจน์ไม่ได้ ส่วนตรรกะของ state machine ตรวจด้วย unit test และการคอมไพล์สำหรับ Cortex-M33 ตรวจด้วยขั้น cross บน runner ได้แล้ว
Cite this lesson
If you teach from this lesson or reuse it in slides or documents, credit it with the text below. If you changed it, add (adapted) after the title.
"CI for firmware" from TESA Open Knowledge by the Thai Embedded Systems Association (TESA), https://github.com/tesaiot/tesa-qualification-program, licensed under CC BY-NC 4.0
Thai attribution: "CI สำหรับเฟิร์มแวร์" จาก TESA Open Knowledge โดยสมาคมสมองกลฝังตัวไทย (Thai Embedded Systems Association: TESA) https://github.com/tesaiot/tesa-qualification-program สัญญาอนุญาต CC BY-NC 4.0
This lesson adapts the source below; keep its credit too.
https://github.com/tesaiot/tesaiot-pse84-devkit-sdk/tree/ef72c1b658178eee8c38b1e47d28b006f80a59b5 · SDK examples and docs are linked at this commit, not copied into this course. Lessons quote short excerpts (at most 25 lines) with a link to the file at this commit and the credit (Apache-2.0, tesaiot-pse84-devkit-sdk).
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · CC BY-NC 4.0
Content is licensed CC BY-NC 4.0. Reuse it non-commercially and credit the Thai Embedded Systems Association (TESA) every time. · How to cite TESA