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)

uri

hw-request.yml

@pytest.mark.iio_hardware(["<part>"]) markers

builds bare-metal firmware, JTAG-flashes it, validates over serial

flash

noos-hw-request.yml

a tools/hw_ci/projects.yaml manifest

runs MATLAB runHWTests against a URI

matlab

matlab-hw-request.yml

board_map.yaml

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 conftest.py (uri), and three repo variables

Hub (this repo)

nothing — you consume the reusable workflow and CLI

Coordinator

a board_catalog.yaml entry per part + a live place tagged for it (lab admin)

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 maptest/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 3MATLAB_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

LG_COORDINATOR

<host>:20408

the gRPC port, NOT REST :8000; the workflow derives REST from it

HW_REQUEST_RUNNER

e.g. hw-lab

fallback runner label for the per-board legs

HW_PREFLIGHT_RUNNER

e.g. hw-coordinator

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 needs image; flash needs a flash: block. After any catalog edit the coordinator host must be redeployed.

  • A live place tagged daughter-board=<part> carrier=<carrier> boot-strategy=<Strategy> (+ optional runner=<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 --scopes and 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_dir in the catalog (e.g. zynq-zc706-adv7511-adrv937x for adrv9371).

  • Reservation HTTP 400LG_COORDINATOR points at REST :8000; use gRPC :20408.