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

  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 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.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-connectionSet to embeddedci:<device-name> for the cloud.
--benchpod-la-voltage / BENCHPOD_LA_VOLTAGEOverride 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-firmwarePath to the firmware image the test flashes.
--benchpod-api-base / BENCHPOD_API_BASEhttps://www.embeddedci.comEmbeddedCI server base URL.
--benchpod-api-key / BENCHPOD_API_KEYAn 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 device when another run holds its lease, before failing with DeviceBusyError. Concurrent jobs queue on the device.
--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 — 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.