Onboarding a Consumer Repo¶
This is the single, prescriptive guide for wiring a new repo onto the adi-labgrid-plugins hardware-CI flow. It consolidates what is otherwise spread across the reference pages (GitHub Actions (Reusable Workflows), Hardware CI by part (hw-request), Hardware-CI Runner Setup (no-os flash mode)) into one ordered procedure with copy-paste templates.
For AI coding agents
An agent can follow AGENTS.md at the repo root — it is the same procedure in an
executable, checklist form. This page is the human reference.
How the flow works¶
A consumer repo’s CI calls a reusable workflow hosted here. A preflight job asks the lab coordinator which of the consumer’s wanted boards are live, then fans out one CI leg per board onto a self-hosted runner co-located with that board. The board is reserved, provisioned, exercised, and released automatically — the consumer never defines labgrid drivers or strategies.
Step 1 — choose a mode¶
If the consumer… |
Mode |
Reusable workflow |
Discovery |
|---|---|---|---|
runs pytest against a booted Linux board over libIIO (a URI) |
|
|
|
builds bare-metal firmware, JTAG-flashes it, validates over serial |
|
|
a |
runs MATLAB |
|
|
|
Reference consumers: pyadi-iio (uri), no-os (flash), TransceiverToolbox (matlab). See GitHub Actions (Reusable Workflows) for the full when-to-use comparison of every reusable workflow. The per-mode sections below cover uri, flash, and matlab.
Step 2 — what you’ll touch¶
Location |
What changes |
|---|---|
Consumer repo |
the workflow file, markers (uri) or manifest (flash), a |
Hub (this repo) |
nothing — you consume the reusable workflow and CLI |
Coordinator |
a |
Lab |
runners registered on the consumer’s GitHub scope (+ Vivado/JTAG for flash) |
uri mode (pytest over libIIO)¶
Workflow — copy into .github/workflows/hw-request.yml and set test-root +
install-cmd:
# Template — copy into <consumer-repo>/.github/workflows/hw-request.yml; replace <PLACEHOLDERS>.
#
# uri-mode hardware CI: labgrid selects a free matching board, boots it, exports
# IIO_URI, runs your pytest suite against the live libIIO URI, then releases the board.
# Discovery harvests @pytest.mark.iio_hardware([...]) markers under `test-root` and
# intersects them with live coordinator boards (GET /api/match). A wanted board with no
# live board is skipped with an annotation.
#
# Repo prerequisites (Settings -> Secrets and variables -> Actions -> Variables):
# vars.LG_COORDINATOR gRPC coordinator host:port, e.g. 10.0.0.41:20408 (NOT :8000)
# vars.HW_REQUEST_RUNNER self-hosted runner label for the per-board legs
# vars.HW_PREFLIGHT_RUNNER self-hosted runner label that can reach the coordinator
# The lab runners must be registered on THIS repo's (or its org's) scope, or jobs queue
# forever. See the onboarding guide + AGENTS.md in tfcollins/labgrid-plugins.
name: HW Request
permissions:
contents: read
checks: write
pull-requests: write
on:
workflow_dispatch:
pull_request:
types: [labeled]
jobs:
hw-request:
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'hw-request')
uses: tfcollins/labgrid-plugins/.github/workflows/hw-request.yml@v3.5 # bump when a new release tags
with:
coordinator: ${{ vars.LG_COORDINATOR }}
# Directory holding your @pytest.mark.iio_hardware(["<board>"]) tests.
test-root: "<TEST_ROOT>" # e.g. test/hw
runner-label: ${{ vars.HW_REQUEST_RUNNER }}
preflight-runner-label: ${{ vars.HW_PREFLIGHT_RUNNER }}
# Reserve mode — for suites that drive boot themselves via labgrid (pytest plugin
# + LG_ENV, e.g. per-test DTBs). Uncomment to reserve the board without booting it.
# Private deps: pass an INSTALL_GIT_TOKEN secret (see the hub github-actions docs).
# request-mode: "reserve"
# Install your package + test deps + adi-labgrid-plugins (brings the
# adi_lg_plugins pytest plugin used for per-board HW_DAUGHTER narrowing).
# Runs with $VENV_DIR exported. Replace <YOUR_INSTALL_ARGS> with your deps.
install-cmd: |
set -euo pipefail
uv pip install --quiet --python "$VENV_DIR/bin/python" <YOUR_INSTALL_ARGS>
uv pip install --quiet --python "$VENV_DIR/bin/python" \
"adi-labgrid-plugins @ git+https://github.com/tfcollins/labgrid-plugins.git@v3.5"
# Optional: upload each leg's JUnit to Prism (see "Uploading results to Prism"
# in the labgrid-plugins docs). Needs vars.PRISM_URL + the three secrets below.
# prism-upload: ${{ vars.PRISM_UPLOAD_ENABLED == 'true' }}
# prism-project: <PRISM_PROJECT_SLUG>
# Uncomment together with the prism-* inputs above. The secrets must be passed
# explicitly — cross-org `secrets: inherit` does NOT work.
# secrets:
# PRISM_API_TOKEN: ${{ secrets.PRISM_API_TOKEN }}
# PRISM_EMAIL: ${{ secrets.PRISM_EMAIL }}
# PRISM_PASSWORD: ${{ secrets.PRISM_PASSWORD }}
Markers — decorate the hardware tests; the preflight AST-parses these, so the arguments must be string literals:
@pytest.mark.iio_hardware(["ad9081"]) # string literals only
@pytest.mark.iio_carrier(["zcu102"]) # optional carrier narrowing
def test_something(iio_uri):
...
Conftest — copy into test/hw/conftest.py for the iio_uri fixture
(adi-lg request boots the board out of band and exports IIO_URI):
"""Template — copy into <consumer-repo>/test/hw/conftest.py (adapt to your suite).
Provides an ``iio_uri`` fixture for the labgrid-plugins **uri-mode** hw-request flow.
``adi-lg request --run 'pytest ...'`` boots a matching board out of band and exports
``IIO_URI`` to the child pytest; this conftest reads it, waits for iiod to be ready, and
hands tests a usable libIIO URI.
This is the minimal path that suits the ``hw-request.yml`` flow (the board is already
booted by ``adi-lg request``). For the heavier ``hw-matrix`` flow that boots the board
*inside* conftest via a labgrid env (``$LG_ENV``) and discovers the DUT's DHCP IP, see
``pyadi-iio/test/hw/conftest.py`` for the full discover + retry implementation.
"""
from __future__ import annotations
import os
import time
import iio # pylibiio — install via your test deps (e.g. `pip install pylibiio`)
import pytest
def pytest_addoption(parser):
g = parser.getgroup("hw")
g.addoption(
"--iio-uri-override",
# IIO_URI is exported by `adi-lg request` (the low-config hw-request flow):
# it boots a board out of band and hands the URI to the child test command.
# IIO_URI_OVERRIDE is the manual / laptop knob.
default=os.environ.get("IIO_URI_OVERRIDE") or os.environ.get("IIO_URI"),
help="libIIO URI to point tests at (e.g. ip:10.0.0.132). "
"Defaults to $IIO_URI_OVERRIDE, then $IIO_URI.",
)
@pytest.fixture(scope="session")
def iio_uri(request) -> str:
"""A libIIO URI with a reachable iiod, or skip if none was provided."""
uri = request.config.getoption("--iio-uri-override")
if not uri:
pytest.skip(
"no IIO_URI / --iio-uri-override set; run via "
"`adi-lg request --part <board> --run 'pytest ...'`"
)
# eth0 DHCP completing does NOT mean iiod is ready — IIO drivers can take another
# 5-15s to probe (longer on cold boots). Poll until a context opens cleanly.
deadline = time.time() + 120
last_err: Exception | None = None
while time.time() < deadline:
try:
iio.Context(uri)
return uri
except Exception as e: # noqa: BLE001 - any libiio error means "not ready yet"
last_err = e
time.sleep(3)
raise RuntimeError(f"iiod not reachable at {uri!r} after 120s: {last_err}")
Reserve mode (drive boot yourself)
Suites that boot the board themselves via labgrid (the pytest plugin + LG_ENV, e.g.
per-test DTBs) use the same uri workflow with request-mode: "reserve" — the board is
reserved but not booted. See Hardware CI by part (hw-request) “Reserve mode”. For private dependencies,
pass an INSTALL_GIT_TOKEN secret (see GitHub Actions (Reusable Workflows)).
flash mode (no-os firmware)¶
Workflow — copy into .github/workflows/hw-request.yml:
# Template — copy into <consumer-repo>/.github/workflows/hw-request.yml; replace <PLACEHOLDERS>.
#
# flash-mode hardware CI (bare-metal firmware): build a project, JTAG-flash it onto a
# matching physical board, validate a serial banner on-target, then release. Discovery
# reads your manifest (tools/hw_ci/projects.yaml) and intersects it with live
# flash-capable boards (GET /api/match?mode=flash). The reusable workflow's `build-noos`
# step sources each board's HDL .xsa from the Kuiper image, composes the Vivado env, and
# runs make — so the runner only needs Vivado/Vitis installed (no .xsa staging).
#
# Repo prerequisites (Settings -> ... -> Variables):
# vars.LG_COORDINATOR gRPC coordinator host:port, e.g. 10.0.0.41:20408
# vars.HW_REQUEST_RUNNER self-hosted label (Vivado + JTAG) for the per-project legs
# vars.HW_PREFLIGHT_RUNNER self-hosted label that can reach the coordinator
# The leg runner needs Vivado/Vitis + JTAG wiring; the reusable workflow installs the
# `[kuiper]` extra (pytsk3) it needs to read the Kuiper image. Runners must be registered
# on this repo's/org's scope. See the onboarding guide + AGENTS.md.
name: HW Request (flash)
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
types: [labeled]
jobs:
noos-hw-request:
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'hw-request')
uses: tfcollins/labgrid-plugins/.github/workflows/noos-hw-request.yml@v3.5 # bump when a new release tags
with:
coordinator: ${{ vars.LG_COORDINATOR }}
manifest: "tools/hw_ci/projects.yaml"
runner-label: ${{ vars.HW_REQUEST_RUNNER }}
preflight-runner-label: ${{ vars.HW_PREFLIGHT_RUNNER }}
Manifest — copy into tools/hw_ci/projects.yaml, one entry per buildable project
(schema: adi_lg_plugins/hw_ci/noos_manifest.py):
# Template — copy into <consumer-repo>/tools/hw_ci/projects.yaml; replace <PLACEHOLDERS>.
#
# The flash-mode manifest: maps each buildable project to the coordinator `part` it
# flashes onto and the FPGA carriers it supports. `adi-lg-hw-ci noos-matrix` intersects
# this with live flash-capable boards -> one build + JTAG-flash + validate leg per
# buildable project that has a live board; the rest are annotated as skipped.
#
# Schema (source of truth): adi_lg_plugins/hw_ci/noos_manifest.py (NoOSProject).
projects:
- noos_project: <PROJECT> # projects/<noos_project> to build
part: <PART> # coordinator part to flash onto (may be a catalog alias)
carriers: [<CARRIER>] # FPGA carriers, preference order (>= 1 required)
validate_banner: "Successfully initialized" # optional; serial success marker (default shown)
build_vars: {} # NOT wired through the default build-cmd — override the
# `build-cmd` workflow input to pass extra make vars.
# Repeat per project. Example (the proven first cut):
# - noos_project: adrv9009
# part: adrv9009
# carriers: [zc706]
# - noos_project: ad9371
# part: ad9371 # alias -> adrv9371 in the coordinator catalog
# carriers: [zc706]
The reusable workflow’s build-noos step sources each board’s HDL .xsa from the
Kuiper image and composes the Vivado env — the runner only needs Vivado/Vitis installed.
See Hardware-CI Runner Setup (no-os flash mode) for the runner details.
matlab mode (MATLAB runHWTests)¶
Workflow — copy into .github/workflows/hw-matlab.yml:
# Template — copy into <consumer-repo>/.github/workflows/hw-matlab.yml; replace <PLACEHOLDERS>.
#
# matlab-mode hardware CI: labgrid boots a matching board; the leg runs the toolbox's
# runHWTests(<matlab_board>) against the booted board's libIIO URI; JUnit is collected;
# the board is released. Discovery intersects test/hw_ci/board_map.yaml with live boards.
# The leg runner must have MATLAB installed (+ a reachable license).
#
# Repo vars: LG_COORDINATOR (gRPC host:20408), HW_REQUEST_RUNNER, HW_PREFLIGHT_RUNNER,
# MATLAB_BIN (path to the matlab binary on the runner).
name: HW MATLAB
on:
workflow_dispatch:
pull_request:
types: [labeled]
jobs:
hw-matlab:
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'hw-request')
uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.5 # bump when a new release tags
with:
coordinator: ${{ vars.LG_COORDINATOR }} # MUST be the gRPC coordinator host:20408
board-map: "test/hw_ci/board_map.yaml"
runner-label: ${{ vars.HW_REQUEST_RUNNER }}
preflight-runner-label: ${{ vars.HW_PREFLIGHT_RUNNER }}
matlab-bin: ${{ vars.MATLAB_BIN }}
# Optional: upload each leg's JUnit to Prism (see "Uploading results to Prism"
# in the labgrid-plugins docs). Needs vars.PRISM_URL + the three secrets below.
# prism-upload: ${{ vars.PRISM_UPLOAD_ENABLED == 'true' }}
# prism-project: <PRISM_PROJECT_SLUG>
# Uncomment together with the prism-* inputs above. The secrets must be passed
# explicitly — cross-org `secrets: inherit` does NOT work.
# secrets:
# PRISM_API_TOKEN: ${{ secrets.PRISM_API_TOKEN }}
# PRISM_EMAIL: ${{ secrets.PRISM_EMAIL }}
# PRISM_PASSWORD: ${{ secrets.PRISM_PASSWORD }}
Board map — test/hw_ci/board_map.yaml maps each board’s
(daughter-board, carrier, hdl-config) to the MATLAB board name passed to
runHWTests (most-specific entry wins):
boards:
- {carrier: zcu102, daughter-board: adrv9002, matlab_board: zynqmp-zcu102-rev10-adrv9002-vcmos}
- {daughter-board: pluto, matlab_board: pluto}
runHWTests.m reads the URI from $IIO_URI (exported by adi-lg request) and emits
<matlab_board>_HWTestResults.xml — no test-side changes are needed. The leg runner must
have MATLAB installed. matlab mode needs one extra repo variable beyond the three in Step
3 — MATLAB_BIN (the path to the matlab binary on the runner, e.g.
/opt/MATLAB/R2025b/bin/matlab). Verify discovery with:
export LG_COORDINATOR=<host>:20408
adi-lg-hw-ci matlab-matrix --board-map test/hw_ci/board_map.yaml --coord "$LG_COORDINATOR"
Step 3 — set the three repo variables¶
In the consumer repo, Settings → Secrets and variables → Actions → Variables:
Variable |
Value |
Notes |
|---|---|---|
|
|
the gRPC port, NOT REST |
|
e.g. |
fallback runner label for the per-board legs |
|
e.g. |
runner label that can reach the coordinator |
Step 4 — coordinator + lab prerequisites¶
These you do not own — confirm with a lab admin (Step 5 fails clearly if any are missing):
Catalog entry per part in
coordinator/api/board_catalog.yaml(template:onboarding-templates/board-catalog-entry.yaml; schema:coordinator/api/app/catalog.py). uri needsimage; flash needs aflash:block. After any catalog edit the coordinator host must be redeployed.A live place tagged
daughter-board=<part> carrier=<carrier> boot-strategy=<Strategy>(+ optionalrunner=<label>).Runner scope — the lab runners must be registered on the consumer repo’s (or org’s) scope or legs queue forever; see
.github/scripts/register-hw-runners.sh --scopesand Hardware-CI Runner Setup (no-os flash mode).flash only — the leg runner needs Vivado/Vitis + ~10 GB disk for the Kuiper image.
Step 5 — verify before opening the PR¶
Run the discovery preflight against the live coordinator — no hardware needed. This proves the markers/manifest + catalog + places line up:
export LG_COORDINATOR=<host>:20408
# uri mode
adi-lg-hw-ci request-matrix --test-root test/hw --coord "$LG_COORDINATOR"
# flash mode
adi-lg-hw-ci noos-matrix --manifest tools/hw_ci/projects.yaml --coord "$LG_COORDINATOR"
# matlab mode
adi-lg-hw-ci matlab-matrix --board-map test/hw_ci/board_map.yaml --coord "$LG_COORDINATOR"
Success is one matrix.include leg per board you expect, each with a non-empty
runner. A wanted board with no live place is emitted as a ::warning:: skip (fix it
in Step 4).
If a board reports Unknown release version, the coordinator catalog is stale — ask the lab
admin to redeploy the coordinator after the catalog merge.
For flash, also prove the .xsa is extractable:
adi-lg-hw-ci fetch-xsa --release 2023_R2_P1 --board <canonical-board> --carrier <carrier>
Then trigger the workflow (workflow_dispatch or the hw-request PR label) and
confirm the preflight and the per-board legs go green.
Drop in an AGENTS.md¶
Add an AGENTS.md to the consumer repo so the next agent/human knows the wiring — copy
onboarding-templates/AGENTS-consumer-stub.md and fill in the mode + boards.
Troubleshooting¶
Jobs queue forever — no runner with the requested label is registered on this repo’s scope. Register with
register-hw-runners.sh --scopes.A board is always skipped — its catalog entry or a live place is missing/mistagged, or (uri) its marker uses a non-literal argument.
``Unknown release version`` — the coordinator is stale (returns no
image); redeploy it after catalog merges.flash build fails extracting the ``.xsa`` — the board’s Kuiper folder is a family name; set
flash.kuiper_xsa_dirin the catalog (e.g.zynq-zc706-adv7511-adrv937xfor adrv9371).Reservation HTTP 400 —
LG_COORDINATORpoints at REST:8000; use gRPC:20408.