Back to resources
CI
HIL
Testing

Running Hardware CI with GitHub Actions

Build firmware on a GitHub runner, then flash and test it on a real board over the cloud — no BenchPod on the runner, no secrets, just the workflow's GitHub OIDC token.

Edward Viaene · June 9, 2026 · 3 min read

Most embedded CI stops at "it compiled." You can build firmware on a GitHub runner all day, but the runner has no board, no power control, and no debug probe — so the test that matters, does this actually run on the hardware we ship, gets done by hand at someone's desk. That's the gap a BenchPod closes: the pod lives wherever the hardware is plugged in, connects out to embeddedci.com, and your GitHub Action drives it as if it were on the runner.

A BenchPod driving a development board, the same setup a CI job reaches over the cloud
A BenchPod driving a development board, the same setup a CI job reaches over the cloud

How it fits together

There is no BenchPod on the GitHub runner. The runner does what runners are good at — checking out code and building firmware — and then hands the build to a pod over the cloud:

  1. The workflow builds the firmware (for example selftest.cselftest.elf with the Arm GNU toolchain).
  2. It installs the embeddedci Python SDK and runs pytest.
  3. The test connects to the device through embeddedci.com using the connection string embeddedci:<device-name>, flashes the firmware over the tunnel, power-cycles the target, and asserts on the UART output — all on real hardware.

The same test runs unchanged on your desk (point --benchpod-connection at the pod's IP) or in CI (point it at embeddedci:<device-name>).

No secrets — the auth is the OIDC token

Notice what isn't in the workflow: an API key. The job proves which repository it is with a GitHub OIDC token, exactly the way PyPI Trusted Publishing works. The EmbeddedCI server exchanges that token for a short-lived session scoped to the devices your repo is allowed to drive, then bridges a byte tunnel to the pod. Nothing long-lived is stored in GitHub.

That requires one line of permissions in the job:

permissions:
  id-token: write   # lets the job mint a GitHub OIDC token for embeddedci
  contents: read

The workflow

A complete job that builds and then runs on a device registered as benchpod-v1.0.0:

name: Selftest (cloud HIL)
on:
  push:
    paths:
      - "selftest-stm32/selftest.c"
      - "selftest-stm32/tests/**"
  workflow_dispatch: {}

permissions:
  id-token: write
  contents: read

jobs:
  selftest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      # Build the firmware on the runner
      - uses: carlosperate/arm-none-eabi-gcc-action@v1
        with:
          release: "13.2.Rel1"
      - run: make -C selftest-stm32

      # Run it on the real board over the cloud
      - uses: actions/setup-python@v6
        with:
          python-version: "3.12"
      - run: pip install pytest "embeddedci[cloud]"
      - run: |
          pytest selftest-stm32/tests -v \
            --benchpod-connection=embeddedci:benchpod-v1.0.0 \
            --benchpod-firmware=selftest-stm32/build/selftest.elf

One detail worth knowing: flashing goes through OpenOCD's remote_bitbang adapter in SWD mode, and SWD support there only exists in OpenOCD master (post-0.12.0). Ubuntu's packaged openocd is 0.12.0 and fails, so the workflow installs an xPack OpenOCD snapshot on the runner. The full, copy-pasteable setup — including the OpenOCD step — is in the GitHub Actions HIL docs.

The test reads like any other pytest

The cloud is invisible to the test itself. It asks for a benchpod, flashes, and asserts on UART:

from types import SimpleNamespace

@pytest.fixture
def wiring(pins):  # this bench's DUT-signal -> LA-channel map (pins are generic LA1-12)
    return SimpleNamespace(swclk=pins.pin_11, swdio=pins.pin_12,
                           uart_rx=pins.pin_5, uart_tx=pins.pin_4, efuse=pins.efuse)


@pytest.mark.hardware
def test_selftest_boots_over_cloud(benchpod, wiring, firmware):
    assert benchpod.flash(
        file=firmware, target="target/stm32f4x.cfg",
        swclk=wiring.swclk, swdio=wiring.swdio,
        target_power=wiring.efuse,
    ).ok

    benchpod.power_off(wiring.efuse)
    benchpod.power_on(wiring.efuse, delay=1.5)
    with benchpod.open_uart(rx=wiring.uart_rx, tx=wiring.uart_tx) as uart:
        assert uart.read_until(r"APP_OK", timeout=12)

Without a connection configured, the benchpod fixture skips instead of failing — so the same suite stays green on runners that don't have a device wired up.

Setting up your project

The one-time setup is two steps in the EmbeddedCI web app: give the device a stable name on the BenchPod page, then trust your repository under BenchPod → GitHub Actions and allow it to drive that device. After that, every push that touches your firmware runs on real hardware. The step-by-step is in Run HIL in GitHub Actions; for the Python side, see the pytest framework docs.