Run HIL in GitHub Actions
Build firmware on a GitHub runner, then flash and test it on a real board over the cloud. The BenchPod lives wherever the hardware is plugged in and connects out to embeddedci.com; the job drives it through the connection string embeddedci:<device-name>. There is no BenchPod on the runner, and no API key or secret is stored — auth is the workflow's GitHub OIDC token.
How it works
- The runner checks out your code and builds the firmware.
- It installs the
embeddedciSDK and runspytest. - The test connects to your device through embeddedci.com with
embeddedci:<device-name>, flashes the firmware over the tunnel, power-cycles the target, and asserts on the UART output — all on real hardware.
The job proves which repository it is with a GitHub OIDC token. The server exchanges it for a short-lived session scoped to the devices that repo is allowed to drive, then bridges a byte tunnel to the pod — exactly like PyPI Trusted Publishing. That needs one permission block in the job:
permissions: id-token: write # REQUIRED — lets the job mint a GitHub OIDC token for embeddedci contents: read
One-time setup
1) Register and name the device
From the machine wired to the pod, register it, then give it a stable name on the BenchPod page (URL-safe, unique per org — e.g. benchpod-v1.0.0):
benchpod register --connection <pod-ip>
2) Trust your repository
On BenchPod → GitHub Actions, add your repository as OWNER/REPO (click Look up to fill the numeric ids) and choose Any device or the specific device(s) this repo may drive. No secret is exchanged — the repo identity comes from the OIDC token at run time.
The workflow
A complete .github/workflows/selftest-cloud.yml that builds the firmware and runs it on benchpod-v1.0.0 over the cloud:
name: Selftest (cloud HIL)
# Runs the firmware on a physical device registered as "benchpod-v1.0.0",
# driven over embeddedci.com from pytest — no BenchPod on the runner.
on:
push:
paths:
- "selftest-stm32/selftest.c"
- "selftest-stm32/tests/**"
- ".github/workflows/selftest-cloud.yml"
workflow_dispatch: {}
permissions:
id-token: write # REQUIRED: mints the GitHub OIDC token for embeddedci
contents: read
jobs:
selftest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# --- Build the firmware (arm-none-eabi + STM32CubeF4 HAL) ---
- name: Install ARM toolchain
uses: carlosperate/arm-none-eabi-gcc-action@v1
with:
release: "13.2.Rel1"
- name: Install xPack OpenOCD
# Flashing uses OpenOCD's cmsis-dap TCP backend (cmsis_dap_tcp), which only exists in
# builds newer than 0.12.0 — Ubuntu's apt openocd 0.12.0 lacks it. xPack 0.12.0-7 has it.
run: |
V=0.12.0-7
curl -fsSL "https://github.com/xpack-dev-tools/openocd-xpack/releases/download/v$V/xpack-openocd-$V-linux-x64.tar.gz" \
| sudo tar xz -C /opt
echo "/opt/xpack-openocd-$V/bin" >> "$GITHUB_PATH"
- name: Build firmware
run: make -C selftest-stm32
# --- Run it on benchpod-v1.0.0 over the cloud ---
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install the embeddedci SDK (cloud + pytest extras)
# [cloud] adds the WebSocket client for embeddedci:<device>; [pytest] installs pytest itself.
run: pip install "embeddedci[cloud,pytest]"
- name: Self-test on benchpod-v1.0.0 (cloud)
run: |
pytest selftest-stm32/tests -v \
--benchpod-connection=embeddedci:benchpod-v1.0.0 \
--benchpod-firmware=selftest-stm32/build/selftest.elfThe workflow passes no LA voltage. The pod refuses flashing, UART and capture until its LA bank voltage is chosen, and that is a property of the board, so the test suite declares it once in its conftest.py and the benchpod fixture selects it on connect:
# selftest-stm32/tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def benchpod_la_voltage():
return 3.3 # the board's I/O voltage — change to 1.8 for a 1V8 boardWhy the OpenOCD step
OpenOCD runs on the runner and flashes through the pod's on-board CMSIS-DAP probe using its cmsis-dap adapter's TCP backend (cmsis_dap_tcp), bridged through the cloud tunnel to the device. That backend only exists in OpenOCD builds newer than 0.12.0 — Ubuntu's apt openocd 0.12.0 lacks it, and the SDK stops with a FlashError saying so. The workflow installs an xPack OpenOCD snapshot (0.12.0-7), which carries the backend.
Config options
| Option / env | Default | Purpose |
|---|---|---|
--benchpod-connection | — | Set to embeddedci:<device-name> for the cloud. |
--benchpod-la-voltage / BENCHPOD_LA_VOLTAGE | — | Override the DUT's logic level (1.8 or 3.3) for one run — e.g. a job for a 1V8 board variant. Normally set once in conftest.py via the benchpod_la_voltage fixture; the flag wins over it, and the env var is the fallback. |
--benchpod-firmware | — | Path to the firmware image the test flashes. |
--benchpod-api-base / BENCHPOD_API_BASE | https://www.embeddedci.com | EmbeddedCI server base URL. |
--benchpod-api-key / BENCHPOD_API_KEY | — | An API key to authenticate with instead of OIDC — for running the same suite from a laptop or a non-GitHub CI system. When set, it is used in preference to OIDC. |
--benchpod-lease-wait | 600 | Seconds to wait for the shared device when another run holds its lease, before failing with DeviceBusyError. Concurrent jobs queue on the device. |
--benchpod-no-lease | off | Skip the exclusive lease (only safe when no other run uses the device). |
Without an API key the SDK mints a GitHub OIDC token. If that fails, the error says exactly why — one of: not running inside a GitHub Action (set BENCHPOD_API_KEY instead), the job is missing id-token: write, or the token request itself failed.