Production Arduino Binary Builds with GitHub Actions

Preface

This post covers a number of aspects and potential challenges when using the Arduino software and tooling for a production application. I am aware there are other tools, environments and platforms out there that may be better but for this project I had to balance the learning curve and time taken against existing knowledge.

Introduction

This article covers setting up a project and Arduino build process, with multiple targets and/or configurations, using GitHub Actions to enable a stable and reproducible build process.

One of our projects required an embedded hardware solution. Usually my use of embedded hardware is quite simple – one board, limited peripherals (if any, other than LEDs) and very much a single-threaded application and approach.

This project was complex in the hardware, the concurrency, the use of external libraries — everything really! In addition I was developing for an STM32 NUCLEO-F767ZI due to the excellent range of hardware features provided for an equally excellent price, and this 3rd party board required pulling in the external board configuration and tools too.

It has over 100 GPIOs, 8 UART ports and much more for around £20 inc. VAT – you can read more about it here: https://os.mbed.com/platforms/ST-Nucleo-F767ZI.

The project will likely lead to other posts about Production Arduino strategies.

For my hobbyist projects I work out of the standard ~/Arduino folder (which is symlinked a file sharing too, to easily sync between my computers) and contains any libraries added, builds are executed and uploaded by the Arduino IDE and most projects have a single .ino file with the code in.

It’s nice and easy for write (mostly) once and run programs, but it’s quite fragile:

  • The IDE does not associate a board or it’s related configuration with a given ino file (aka a project).
  • The “project” is not aware of any libraries or boards it is designed to work with
  • Libraries are expected in a shared and predefined location
  • Complex project file structures are not easily supported, i.e. sub-folders, headers, etc

As a result, without intervention, it is impossible to have a deterministic build process for a given commit on any minimally configured computer – for example, a CI build agent.

For this project, it was important to be able to have a stable and reproducible build process. In addition there was a requirement for configuration variants (in this case through the use of C++ macros), and changing #define statements manually is tedious and potentially error prone.

IDE

After using the Arduino IDE for a brief period I quickly moved to Visual Studio Code.

As noted above the Arduino IDE is generally more simple and limited, mostly for good reason, but due to my desire to use C++ classes and folders to create a more organised structure I needed something a little more comprehensive.

VS Code along with the Arduino extension generally makes it easier to have a more complex project.

There are exceptions:

  • It throws up warnings and errors that are due to dependency resolution and still pass the build process
  • It doesn’t always resolve macros #define and #ifdef statements properly
  • The build process copies the code to a sub-folder, which is confusing when finding files by name
  • I had issues looking at COM ports (a minor issue, mostly I use minicom: minicom -D /dev/ttyACM0 -b 115200 -w -C minicom.cap)

But if you fall back to checking issues with the compiler, it’s pretty good. It can build and upload well enough too as well has having similar built in features such as the board and library managers, and support for debugging official Arduino boards (untested – another post on debugging the STM32 will come in due course).

It still suffers from the underlying issues mentioned before around shared libraries. It can however have a project specific configuration for a board and other aspects.

We can tell VS Code about the C++ configuration using c_cpp_properties.json file:

{
    "configurations": [
        {
            "name": "Linux",
            "includePath": [
                "/home/paul/.arduino15/packages/STM32/tools/**",
                "/home/paul/.arduino15/packages/STM32/hardware/stm32/1.9.0/**",
                "./libraries/**"
            ],
            "forcedInclude": [
                "/home/paul/.arduino15/packages/STM32/hardware/stm32/1.9.0/cores/arduino/Arduino.h"
            ],
            "intelliSenseMode": "gcc-x64",
            "compilerPath": "/usr/bin/gcc",
            "cStandard": "gnu18",
            "cppStandard": "gnu++14"
        }
    ],
    "version": 4
}

In the example above we include the STM dependencies and a local folder for libraries (formerly libs following the C/C++ convention).

With arduino.json we can also setup the board and a few other config elements including the output path and a prebuild script. Much of this file is generated and managed by the VS Code UI under Arduino: Board Config function:

{
    "sketch": "myproject.ino",
    "board": "STM32:stm32:Nucleo_144",
    "configuration": "pnum=NUCLEO_F767ZI,upload_method=MassStorage,xserial=generic,usb=none,xusb=FS,opt=osstd,rtlib=nano",
    "port": "/dev/ttyACM0",
    "output": "./build",
    "prebuild": "bash prebuild.sh"
}

In the workspace or global settings.json we can also define extra board URLs, by doing so in the workspace settings and committing them ties it to the project:

{
  "arduino.additionalUrls": "https://github.com/stm32duino/BoardManagerFiles/raw/master/STM32/package_stm_index.json",
}

Arduino CLI

The “Arduino CLI” is a (self proclaimed) all-in-one solution that provides builder, Boards/Library Manager, uploader, discovery and many other tools needed to use any Arduino compatible board and platforms.

At the time of writing the latest version is 0.14.0 – considered to be “under active development” and potentially unstable.

It lets us execute a wide range of Arduino IDE functions including management of boards, updating indexes and actually compiling the binaries.

The Build Script

This is a variant of my build script:

#!/bin/bash
set -e
cd "$(dirname "$0")/.."

DATE=$(date --iso-8601=seconds | tr : - | tr T _ | grep -oP '^.*\d\+' | grep -oP '^.*\d')
mkdir -p ./build/$DATE

export ARDUINO_DIRECTORIES_USER=$(pwd)
export ARDUINO_OUTPUT_DIR=./build/$DATE
export ARDUINO_ADDITIONAL_URLS=https://raw.githubusercontent.com/stm32duino/BoardManagerFiles/master/STM32/package_stm_index.json

arduino-cli core update-index
arduino-cli core install STM32:stm32
exec arduino-cli compile -b STM32:stm32:Nucleo_144 -e --output-dir=$ARDUINO_OUTPUT_DIR --clean --build-property="compiler.cpp.extra_flags=\"-DMY_MACRO\"" $@

Lets break this down, the first 3 lines set bash as the interpreter, tell it to fail on any error and ensure the working directory is the project directory (the parent of the script since it lives in ./scripts/).

Next we generate a sane DATE variable, replacing invalid path characters with _ and -, and removing the timezone, eg: 2020-12-29_22-34-20.

The export statements set a number of environment variables. The documentation is not completely clear but suggests a number of the arduino-cli flags can be provided as variables prefixed with ARDUINO – for example, --output-dir=xyz becomes ARDUINO_OUTPUT_DIR=xyz.

With the exports we set the User Directory, the Output Dir and the Additional URLs.

The User Director is essentially the ~/Arduino folder on a common setup. It expects a libraries sub-folder with any external libraries.

The Output Dir is the folder where any build artifacts end up.

The Additional URLs and 3rd party board definitions, in this case for the STM32.

Finally, we do 3 last things – update the board indices, install the STM32 board support and compile the project.

Breaking down the compiler command:

  • exec – pass control to the child process
  • arduino-cli compile – execute the compile command
  • -b STM32:stm32:Nucleo_144 – set the board (see arduino-cli board listall)
  • -e – export binaries
  • --output-dir=$ARDUINO_OUTPUT_DIR – saves build artefacts here
  • --clean – ensure a clean build by cleaning up the build folder and do not use any cached build.
  • --build-property="compiler.cpp.extra_flags="-DMY_MACRO"" – overrides a build property
  • $@ – pass in any extra arguments that are passed to ./scripts/build.sh, eg ./uploads/build.sh -u -t which would build, upload and verify.

Macros and preprocessor directives

In C++ it is commonplace to use macros and preprocessor directives to include or exclude elements of code.

They are evaluated at an early stage such that the code is either included or excluded, it is not condition at execution but at compilation.

This is often useful when building for different hardware architectures since whole capabilities or features sets can be included or excluded based on hardware support.

The architecture information can be passed in as a defined macro.

Lets say we have the following (semi-pseudo) code:

#ifdef DEBUG
  print "Here's some debug info"
#endif

If the program is compiled with the DEBUG macro defined via -DDEBUG the sentence will print, otherwise it wont.

Macros can be defined in code, e.g. #define DEBUG or as noted above via compiler flags.

Our script could take arguments to vary any definitions to either create a variety of builds or just to vary the configuration.

In this project we had a number of connectivity configurations and it was useful to have multiple builds for the same commit supporting different modems and carriers. The modems were managed by TinyGSM and had to be declared exclusively using macros.

For the connectivity settings the #ifdef gets a bit more complex:

#if defined(APN_ONE)
const char apn[] = "one.com";
const char gprsUser[] = "one";
const char gprsPass[] = "";
#elif defined(APN_TWO)
const char apn[] = "two";
const char gprsUser[] = "22two";
const char gprsPass[] = "two";
#else
#warning Defaulting APN to Vodafone
const char apn[] = "3three.com";
const char gprsUser[] = "three";
const char gprsPass[] = "three";
#endif

In the last case we opt to allow a fallback since the VS Code build process doesn’t set any flags and these are the settings we need for local builds. In an #else we could raise an #error No APN Set to halt the compilation.

Reproducible builds

Now, using this build script, we can produce fairly deterministic builds on any machine that has our code and the arduino CLI. The script will set up the boards and compile based on relative information.

It’s worth noting here that the libraries folder contains all external libraries installed by the library manager. Mostly I copy and paste these manually or copy from github.

In some cases they are tweaked.

Running the build script on a clean machine or CI worker can highlight missing libraries that are still assumed to be in ~/Arduino/libraries.

The GitHub Action

GitHub actions are great, and a potentially long overdue feature. It seems the jury is out on whether they can or should be used for a true CI/CD setup or just to augment various checks and processes.

We use them for setting a Pull Request assignee (if blank), sometimes linting or compiling javascript but less often for actual builds or deploys, for those we still use Jenkins.

Note: an arduino-cli container with the build script above would likely work on Jenkins or GitHub actions.

The Script

The name and on directives at the root level are simple enough, setting the action name and when to run it.

jobs

is a container for all jobs, here we just have build.

Note: The full opts directive was required to produce a build that was executable.

Build

build has a strategynameruns-on and steps directive.

name is the name.

runs-on is effectively the container OS (Ubuntu).

strategy is more complex. GitHub Actions can produce matrix builds, commonly used for testing different targets. You can define various arrays of parameters which end up producing a matrix of results.

For example, if we set letter: ["a", "b"] and number: [1, 2] we’d end up with the following parameters set: [[a, 1], [a, 2], [b, 1], [b, 2]] passed to our steps.

Each variable can be substituted via ${{ matrix.varname }} e.g. ${{ matrix.letter }}.

Steps

1: Checkout

The first step checks out the code to the GitHub Action runner’s working directory.

2: Install the arduino-cli

The next action sets up (installs) the Arduino CLI.

3: Install the board definition

Then the boards are setup, similarly to the build script, updating the indices and installing the STM32 boards. Here there --additional-urls flag was used rather than the Environment Variable – I had issues with stability using the latter but did not investigate to deeply as this worked.

This step uses the matrix variables to install the correct definition (assuming more exist).

4: Compile

Then we compile the code. The User Directory (a.k.a ARDUINO_DIRECTORIES_USER is set inline as we substitute in the working folder rather than hard code it).

This command also takes the board identifier from the matrix variables along with the APN as a compiler flag.

Note: The config dump command helped debug path issues, along with ls **/*

run: ARDUINO_DIRECTORIES_USER=$GITHUB_WORKSPACE/mydir arduino-cli config dump
GitHub Action Runner directory screenshot
3 Jobs

5: Upload Artifacts

For each matrix combination the resulting bin file is uploaded. The same could be done with the hex, elf, and other files. The path can be a glob as the result is a zip.

GitHub Action Runner directory screenshot 2
3 Artifacts

Conclusion

It took a large number of iterations to get this process right and working reliably.

Developing CI processes is often quite tedious without a local means to replicate the process (that said I did not investigate my options there as from the outset I expected it to be easier).

The end result though is as desired, reliable builds with localised project settings and 3rd party libraries.

The STM32 Nucleo boards when connected to via the ST-Link interface expose a Mass Storage Device (i.e it looks like a USB Drive), a build can be uploaded simply by copying it to the Mass Storage Device which causes the STM32 to update and reset.