How to Build an Arduino CI/CD Pipeline

How to Build an Arduino CI/CD Pipeline cover image

CI/CD is two abbreviations people glue together.

CI, continuous integration: every change is built (and maybe tested) automatically so a broken sketch cannot hide on main.

CD, continuous delivery or deployment: the built firmware is packaged, and sometimes installed, without a ritual of mystery clicks.

On a web app, CD might mean "ship to the server." On Arduino, GitHub's rented computers do not have your Uno. An honest pipeline is:

  1. Compile every pull request (CI).
  2. Save the .hex / .bin as an artifact so a human can download it (the start of CD).
  3. Flash on a bench, or on a self-hosted runner that actually has USB, if you ever go that far.

A realistic CI/CD pipeline from pull request through a tagged GitHub Release, with a human flashing the board on the bench outside GitHub.

This article is that pipeline with GitHub Actions and Arduino CLI. The GitHub Actions article in this folder is the compile-only starter. Part 3 of the Mastery Series is still the firmware you are gating.

Stage 1: compile is the gate

Keep the compile workflow from the previous article. Nothing merges to main if Uno (and Nano, if you care) fail to compile. That is already CI.

Add paths filters if the repo also has docs and you do not want a README typo to compile ESP32 for eight minutes:

on:
  pull_request:
    paths:                        # Only run when files under these paths change
      - "CliBlink/**"             # ** means "anything inside this folder"
      - "Dht22Bench/**"
      - ".github/workflows/**"    # Also re-run if the workflow itself changes

Stage 2: export binaries as artifacts

Locally:

# Compile, and also save the finished .hex files into CliBlink\build\
arduino-cli compile --fqbn arduino:avr:uno --export-binaries .\CliBlink

That writes under CliBlink\build\.... In Actions, using the raw CLI:

      - uses: arduino/setup-arduino-cli@v2

      - name: Install AVR core
        run: |
          arduino-cli core update-index
          arduino-cli core install arduino:avr

      - name: Compile and export
        run: arduino-cli compile --fqbn arduino:avr:uno --export-binaries ./CliBlink

      - uses: actions/upload-artifact@v4   # GitHub's official "save files from this run" Action
        with:
          name: cliblink-uno-hex           # Name shown in the Artifacts list
          path: CliBlink/build/**          # Everything the export step wrote

After the job, Actions → the run → Artifacts. A person downloads the hex and uploads with arduino-cli upload or IDE 2. That is delivery: the firmware file is named, dated, and tied to a git SHA (the unique ID of the exact commit it was built from).

compile-sketches can record size reports. If you use that Action, read its README for report paths instead of inventing --export-binaries on top unless you confirmed it passes cli-compile-flags.

Keep artifacts a few days, not forever, unless you have a compliance reason. GitHub's retention settings are in the repo.

Stage 3: more than one job

A small pipeline as three jobs:

  1. lint / format (optional). arduino-cli is not a full C++ linter. Some people run arduino/arduino-lint on library repos. For a sketch repo, compile with --warnings all is the practical lint. Treat warnings as failures only when the team agreed to that.
  2. compile Uno.
  3. compile Nano (or ESP32), needs: compile-uno only if you want a chain. Usually they run in parallel in a matrix instead.

Do not add a "test" job that claims to run the sketch on GitHub-hosted hardware. There is no board. Unit-testing (automatically checking small pieces of code, such as a single function, against known answers) is possible for C++ that does not touch hardware registers, using a test harness that runs on a desktop. That is a different, larger project. Say you do not have on-target tests yet. Honesty beats a green job named test that only compiles.

Version your firmware so artifacts mean something

A pile of artifacts named CliBlink.ino.hex is hard to trust a month later. Give the firmware a version string that you print at boot and tie to the git tag. Then when a board misbehaves, one line in the Serial Monitor tells you exactly which build it is running.

// Bump this in the same commit you tag. The Serial banner proves which build
// is actually on the board, which is the first thing to check in any bug report.
const char FW_VERSION[] = "1.2.0";

void setup() {
  Serial.begin(115200);
  Serial.print(F("firmware v"));
  Serial.println(FW_VERSION);
}

It is a small discipline, but it turns an anonymous .hex file into something you can reason about.

A release workflow you can copy

Here is the "tag a release" option from above as a complete file. It runs only when you push a tag that starts with v, compiles, and attaches the hex file to a GitHub Release page where anyone can download it.

name: Release firmware

on:
  push:
    tags: ["v*"]            # Runs on tags like v1.2.0, never on ordinary pushes

permissions:
  contents: write           # Needed so the workflow may create a Release

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: arduino/setup-arduino-cli@v2

      - name: Build
        run: |
          arduino-cli core update-index
          arduino-cli core install arduino:avr
          arduino-cli compile --fqbn arduino:avr:uno --export-binaries ./CliBlink

      - uses: softprops/action-gh-release@v2   # Creates the Release and uploads files
        with:
          files: CliBlink/build/**/*.hex

You trigger it from your PC by creating and pushing a tag:

# Marks the current commit as release 1.2.0, then sends that mark to GitHub.
# The push of the tag is what starts the workflow above.
git tag v1.2.0
git push origin v1.2.0

Flashing a downloaded artifact at the bench

Someone downloads the zip, unpacks it, and uploads without compiling anything. The CLI can upload straight from a folder of build output:

# --input-dir points at the exported build folder, so no sketch source or
# compiler is needed on this machine, only the CLI and the right core.
arduino-cli upload -p COM3 --fqbn arduino:avr:uno --input-dir .\build\arduino.avr.uno

That separation is the real payoff of this pipeline. The machine that builds and the machine that flashes no longer have to be the same one.

Stage 4: when does flashing happen?

Options, from safest to most machinery:

  • Human on the bench. Download artifact, arduino-cli upload --fqbn ... --port COMx. Default for makers.
  • Tag a release. On push of v1.2.3, compile, upload-artifact, and create a GitHub Release with the hex attached (softprops/action-gh-release or similar). Still a human flashes.
  • Self-hosted runner (your own computer registered with GitHub to run jobs) in the lab, with Linux USB permissions set up (udev rules and the dialout group) and a known COM device. A workflow can arduino-cli upload there. Label the runner. Protect main. Know that a bad sketch can brick a device that is plugged in. I would not start here.

GitHub-hosted ubuntu-latest cannot be option three.

Make the compile check mandatory

A pipeline only protects main if the repository actually refuses broken changes. GitHub calls this a branch protection rule. In the repository settings, open Branches, add a rule for main, and turn on the option that requires status checks to pass before merging. Then pick your compile job from the list. From that moment, a pull request with a red X cannot be merged until someone fixes it.

For a solo project this can feel like overkill, since nobody is stopping you from merging anyway. I still turn it on, because it removes the temptation to merge "just this once" at the end of a long day. The point of automation is that it does not get tired, and neither does a rule that says no.

If the job name does not appear in the picker, the workflow has not run on that branch yet. Push a small change, let it finish once, and the check becomes selectable.

Secrets and extra URLs

ESP32 index URLs are public. Put them in the YAML. Private library tokens go in GitHub Secrets, then ${{ secrets.NAME }} in the workflow. Never commit a password for a board or a Cloud API key in a sketch that Actions will log.

A realistic first pipeline

For a TMWB-style sketch repo:

Event What runs
Pull request Compile Uno (and Nano if the sketch claims both)
Push to main Same compile, plus export hex artifact
Git tag v* Compile, artifact, attach to a Release

No auto-flash. No pretend tests. That is already more discipline than most Arduino GitHub repos.

Local same as CI

Run the same arduino-cli compile --fqbn ... on your PC before you push. CI should not be the first compiler you used that day. If local passes and CI fails, the usual causes are: missing lib install on the runner, different FQBN, or a file you never git added.

Wrap-up

An Arduino CI/CD pipeline is compile-on-every-PR, hex files saved as artifacts, optional releases, and flashing kept where there is a USB cable. Arduino CLI is the compiler in every stage. GitHub-hosted runners do CI and packaging. They do not replace the bench.

Start with the compile Action from the previous article. Add --export-binaries and upload-artifact when you want a file you can flash without rebuilding. Stop before auto-upload until you have a runner that owns the hardware on purpose.

Hack The World and Make Awesome.

Sub-Category

Add new comment

Restricted HTML

  • You can align images (data-align="center"), but also videos, blockquotes, and so on.
  • You can caption images (data-caption="Text"), but also videos, blockquotes, and so on.