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:<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

  1. The runner checks out your code and builds the firmware.
  2. It installs the embeddedci SDK and runs pytest.
  3. The test connects to your pod through embeddedci.com with embeddedci:<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 pods 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 pod

Skip the registration if benchpod discover says the pod is already registered to your account (a pod set up for you is). Otherwise, 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 pod or the specific pods 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 the board wired to the pod 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:<name>; [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.elf

The 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 board

Why 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 / envDefaultPurpose
--benchpod-connection—Set to embeddedci:<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_BASEhttps://www.embeddedci.comEmbeddedCI 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-wait600Seconds to wait for the shared pod when another run holds its lease, before failing with DeviceBusyError. Concurrent jobs queue on the pod.
--benchpod-no-leaseoffSkip 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. It is 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.

Publish the firmware for manual flashing

To make a CI build flashable from the web UI without running tests, add the upload-artifact action after the build step. It uses the same OIDC token and the same trusted-repository entry as the tests above, touches no BenchPod, and records the build with the wiring that pre-fills the flash dialog:

      - uses: embeddedci-com/submit-job-action/upload-artifact@v1
        with:
          firmware: selftest-stm32/build/selftest.elf
          build_target: stm32f4
          openocd_target: target/stm32f4x.cfg
          swclk: "11"
          swdio: "12"
          efuse: "1"

Its full input list and the build_id output are on the Cloud builds page.