From 0a5d049aba526ba0a01cd1216422c1aeeb885b21 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Wed, 10 Jun 2026 17:21:56 -0400 Subject: [PATCH 1/9] test: labgrid HW-CI board map (place tags -> runHWTests board names) --- test/hw_ci/board_map.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/hw_ci/board_map.yaml diff --git a/test/hw_ci/board_map.yaml b/test/hw_ci/board_map.yaml new file mode 100644 index 00000000..12ea8587 --- /dev/null +++ b/test/hw_ci/board_map.yaml @@ -0,0 +1,32 @@ +# HighSpeedConverterToolbox board map for labgrid HW-CI (matlab-hw-request.yml@v3). +# +# Maps a coordinator place's tags -> the MATLAB board name runHWTests() +# understands (the switch in test/runHWTests.m). Matching rules +# (adi_lg_plugins.hw_ci.board_map): `daughter-board` required; `carrier` / +# `hdl-config`, when present, must also match the place's tags; the MOST +# specific matching row wins; ties -> first row in file order. +# +# NOTE: duplicates knowledge in test/runHWTests.m's switch — keep in sync. +# AD9172 has a switch case but no test class; intentionally absent here. + +boards: + # --- AD9081 (zcu102; the hdl-config tag selects the JESD build variant) --- + - {carrier: zcu102, daughter-board: ad9081, hdl-config: v204b-txmode9-rxmode4, matlab_board: zynqmp-zcu102-rev10-ad9081-v204b-txmode9-rxmode4} + - {carrier: zcu102, daughter-board: ad9081, hdl-config: v204c-txmode0-rxmode1, matlab_board: zynqmp-zcu102-rev10-ad9081-v204c-txmode0-rxmode1} + - {carrier: zcu102, daughter-board: ad9081, hdl-config: vm4-l8, matlab_board: zynqmp-zcu102-rev10-ad9081-vm4-l8} + - {carrier: zcu102, daughter-board: ad9081, hdl-config: vm8-l4, matlab_board: zynqmp-zcu102-rev10-ad9081-vm8-l4} + # Carrier-only fallback for places that carry no hdl-config tag: set this + # to the variant the lab actually flashes (check the place's bootfile). + - {carrier: zcu102, daughter-board: ad9081, matlab_board: zynqmp-zcu102-rev10-ad9081-vm8-l4} + + # --- DAQ2 --- + - {carrier: zcu102, daughter-board: daq2, matlab_board: zynqmp-zcu102-rev10-fmcdaq2} + - {carrier: zc706, daughter-board: daq2, matlab_board: zynq-zc706-adv7511-fmcdaq2} + - {carrier: arria10, daughter-board: daq2, matlab_board: socfpga-arria10-socdk-daq2} + + # --- DAQ3 --- + - {carrier: zcu102, daughter-board: daq3, matlab_board: zynqmp-zcu102-rev10-fmcdaq3} + - {carrier: zc706, daughter-board: daq3, matlab_board: zynq-zc706-adv7511-fmcdaq3-revC} + + # --- FMCOMMS11 --- + - {carrier: zc706, daughter-board: fmcomms11, matlab_board: zynq-zc706-adv7511-fmcomms11} From 4aa47ccdb5411d4853d6c1a0e95e5024280ebc56 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Wed, 10 Jun 2026 17:22:07 -0400 Subject: [PATCH 2/9] ci: vendor stdlib Prism uploader (+client module) --- .../__pycache__/_prism_client.cpython-312.pyc | Bin 0 -> 9978 bytes .github/scripts/_prism_client.py | 190 +++++++++++++ .github/scripts/prism_upload_run.py | 266 ++++++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 .github/scripts/__pycache__/_prism_client.cpython-312.pyc create mode 100644 .github/scripts/_prism_client.py create mode 100755 .github/scripts/prism_upload_run.py diff --git a/.github/scripts/__pycache__/_prism_client.cpython-312.pyc b/.github/scripts/__pycache__/_prism_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d94bd21feee84ce3bb96279e6b401c35e412eeb GIT binary patch literal 9978 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 pGyl;*(VKru)%}LD|Awj||1kfly None: + # Reject non-http(s) schemes up front so the noqa: S310 below is true. + # urllib's default opener handles file://, ftp://, data://, etc., which + # an attacker who can set PRISM_URL could abuse for local-file + # disclosure / SSRF. See ruff S310. This guard must mirror the one in + # clients/python-pytest/src/pytest_prism/client.py since this file is + # the vendoring source for pyadi-iio's prism-report plugin. + scheme = urllib.parse.urlsplit(base_url).scheme.lower() + if scheme not in ("http", "https"): + raise ValueError( + f"PrismClient base_url must be http or https; got scheme {scheme!r}" + ) + self.base_url = base_url.rstrip("/") + self.jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar) + ) + + # --------------------------------------------------------------------- # + # Low-level request plumbing + # --------------------------------------------------------------------- # + + def _read_cookie(self, name: str) -> str | None: + for c in self.jar: + if c.name == name: + return c.value + return None + + def _request( + self, + method: str, + path: str, + *, + body: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> tuple[int, bytes]: + url = f"{self.base_url}{path}" + # base_url's scheme is validated to http/https in __init__, so this is safe. + req = urllib.request.Request( + url, data=body, method=method, headers=headers or {} + ) # noqa: S310 + csrf = self._read_cookie("prism_csrf") + if csrf and method in ("POST", "PUT", "PATCH", "DELETE"): + req.add_header("X-Prism-Csrf", csrf) + try: + with self.opener.open(req) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + # --------------------------------------------------------------------- # + # Auth + # --------------------------------------------------------------------- # + + def login(self, email: str, password: str) -> None: + payload = json.dumps({"email": email, "password": password}).encode("utf-8") + code, body = self._request( + "POST", + "/api/v1/auth/login", + body=payload, + headers={"Content-Type": "application/json"}, + ) + if code != 200: + raise RuntimeError(f"login failed: HTTP {code} {body!r}") + + # --------------------------------------------------------------------- # + # Projects + # --------------------------------------------------------------------- # + + def ensure_project(self, slug: str, name: str, description: str = "") -> None: + """Create the project if it doesn't exist. 409 (already exists) is fine.""" + payload = json.dumps( + {"slug": slug, "name": name, "description": description} + ).encode("utf-8") + code, body = self._request( + "POST", + "/api/v1/projects", + body=payload, + headers={"Content-Type": "application/json"}, + ) + if code not in (201, 409): + raise RuntimeError(f"project create failed: HTTP {code} {body!r}") + + def project_exists(self, slug: str) -> bool: + code, _ = self._request("GET", f"/api/v1/projects/{urllib.parse.quote(slug)}") + return code == 200 + + # --------------------------------------------------------------------- # + # Runs + # --------------------------------------------------------------------- # + + def list_runs(self, project_slug: str) -> list[dict[str, object]]: + code, body = self._request( + "GET", f"/api/v1/runs?project={urllib.parse.quote(project_slug)}" + ) + if code != 200: + raise RuntimeError(f"list runs failed: HTTP {code} {body!r}") + result: list[dict[str, object]] = json.loads(body) + return result + + def get_run(self, run_id: str) -> dict[str, object]: + code, body = self._request("GET", f"/api/v1/runs/{urllib.parse.quote(run_id)}") + if code != 200: + raise RuntimeError(f"get run failed: HTTP {code} {body!r}") + result: dict[str, object] = json.loads(body) + return result + + def delete_run(self, run_id: str) -> None: + code, body = self._request( + "DELETE", f"/api/v1/runs/{urllib.parse.quote(run_id)}" + ) + if code not in (204, 404): + raise RuntimeError(f"delete run failed: HTTP {code} {body!r}") + + def upload_run( + self, + *, + project_slug: str, + run_name: str, + junit_xml: bytes, + archive_zip: bytes | None = None, + tags: dict[str, str] | None = None, + ) -> dict[str, object]: + """POST /api/v1/runs with a hand-built multipart body. + + Returns the parsed JSON response (includes run `id` + initial `status`). + Raises RuntimeError on any non-201. + """ + boundary = f"----prism{uuid.uuid4().hex}" + parts: list[bytes] = [] + + def _add_field(name: str, value: str) -> None: + parts.append(f"--{boundary}\r\n".encode()) + parts.append( + f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + ) + parts.append(value.encode("utf-8")) + parts.append(b"\r\n") + + def _add_file( + name: str, filename: str, content_type: str, content: bytes + ) -> None: + parts.append(f"--{boundary}\r\n".encode()) + parts.append( + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode() + ) + parts.append(f"Content-Type: {content_type}\r\n\r\n".encode()) + parts.append(content) + parts.append(b"\r\n") + + metadata = {"project_slug": project_slug, "name": run_name, "tags": tags or {}} + _add_field("metadata", json.dumps(metadata)) + _add_file("junit", "junit.xml", "application/xml", junit_xml) + if archive_zip is not None: + _add_file("archive", "archive.zip", "application/zip", archive_zip) + parts.append(f"--{boundary}--\r\n".encode()) + body = b"".join(parts) + headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"} + code, resp_body = self._request( + "POST", "/api/v1/runs", body=body, headers=headers + ) + if code != 201: + raise RuntimeError(f"upload {run_name!r} failed: HTTP {code} {resp_body!r}") + result: dict[str, object] = json.loads(resp_body) + return result diff --git a/.github/scripts/prism_upload_run.py b/.github/scripts/prism_upload_run.py new file mode 100755 index 00000000..4860ccce --- /dev/null +++ b/.github/scripts/prism_upload_run.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Upload a pytest JUnit XML result to a Prism server. + +Typical use from CI: + + python3 scripts/upload_run.py results.xml \\ + --project my-service --run-name "$CI_JOB_ID" \\ + --tag branch=$GIT_BRANCH --tag sha=$GIT_SHA --wait + +All `--foo` flags fall back to `PRISM_FOO`-style environment variables, +so a hardened CI pipeline can skip the command-line switches entirely: + + PRISM_URL=... PRISM_EMAIL=... PRISM_PASSWORD=... \\ + PRISM_PROJECT=my-service PRISM_RUN_NAME="$CI_JOB_ID" \\ + python3 scripts/upload_run.py results.xml + +Exit codes: + 0 success + 2 bad argument or input file not found + 3 authentication failed + 4 project not found (pass --auto-create-project to create it) + 5 upload failed (HTTP error from the server) + 6 --wait timed out before ingest finished + +Stdlib-only — works on any CI runner with Python 3.10+. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path + +# The shared client lives next to this file; use a path-relative import so the +# script works when invoked from anywhere (e.g. `python3 /repo/scripts/...`). +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _prism_client import PrismClient # noqa: E402 (import after sys.path tweak) + +EXIT_OK = 0 +EXIT_BAD_INPUT = 2 +EXIT_AUTH = 3 +EXIT_NO_PROJECT = 4 +EXIT_UPLOAD = 5 +EXIT_WAIT_TIMEOUT = 6 + + +def _parse_tag(raw: str) -> tuple[str, str]: + if "=" not in raw: + raise argparse.ArgumentTypeError(f"--tag expects key=value, got {raw!r}") + k, v = raw.split("=", 1) + k = k.strip() + v = v.strip() + if not k: + raise argparse.ArgumentTypeError(f"--tag key is empty in {raw!r}") + return k, v + + +def _env(name: str, default: str | None = None) -> str | None: + v = os.environ.get(name) + return v if v else default + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="upload_run.py", + description="Upload a pytest JUnit XML to Prism.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Each --foo flag also accepts a PRISM_FOO environment variable " + "(e.g. PRISM_URL, PRISM_EMAIL, PRISM_PROJECT, PRISM_RUN_NAME)." + ), + ) + p.add_argument("junit", type=Path, help="Path to the JUnit XML file.") + p.add_argument( + "--url", + default=_env("PRISM_URL", "http://localhost:8000"), + help="Prism API base URL (env: PRISM_URL).", + ) + p.add_argument( + "--email", default=_env("PRISM_EMAIL"), help="Login email (env: PRISM_EMAIL)." + ) + p.add_argument( + "--password", + default=_env("PRISM_PASSWORD"), + help="Login password (env: PRISM_PASSWORD).", + ) + p.add_argument( + "--project", + default=_env("PRISM_PROJECT"), + help="Target project slug (env: PRISM_PROJECT).", + ) + p.add_argument( + "--run-name", + dest="run_name", + default=_env("PRISM_RUN_NAME"), + help="Name for this Test Suite Run (env: PRISM_RUN_NAME).", + ) + p.add_argument( + "--tag", + action="append", + type=_parse_tag, + default=[], + metavar="key=value", + help="Repeatable. Arbitrary tag on the run (branch=main, sha=abc123).", + ) + p.add_argument( + "--archive", + type=Path, + default=None, + help="Optional .zip of measurement artifacts to upload alongside the JUnit.", + ) + p.add_argument( + "--auto-create-project", + action="store_true", + help="Create the target project if it doesn't exist.", + ) + p.add_argument( + "--wait", + nargs="?", + type=int, + const=60, + default=None, + metavar="SECONDS", + help="After upload, poll every 2s until the run is no longer " + "pending. Bare flag uses a 60s timeout; pass a value for longer.", + ) + verbosity = p.add_mutually_exclusive_group() + verbosity.add_argument( + "--quiet", + "-q", + action="store_true", + help="Only print the final result line; errors still go to stderr.", + ) + verbosity.add_argument( + "--verbose", "-v", action="store_true", help="Print each step to stdout." + ) + return p + + +def _require(args: argparse.Namespace, name: str, env: str) -> str | None: + val: str | None = getattr(args, name, None) + if not val: + print( + f"error: --{name.replace('_', '-')} is required (or set {env})", + file=sys.stderr, + ) + return None + return val + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + # --- Validate required args ------------------------------------------- # + missing = [] + for attr, env in ( + ("email", "PRISM_EMAIL"), + ("password", "PRISM_PASSWORD"), + ("project", "PRISM_PROJECT"), + ("run_name", "PRISM_RUN_NAME"), + ): + if not getattr(args, attr, None): + missing.append(f"--{attr.replace('_', '-')} (or {env})") + if missing: + print( + "error: missing required argument(s): " + ", ".join(missing), + file=sys.stderr, + ) + return EXIT_BAD_INPUT + + if not args.junit.exists() or not args.junit.is_file(): + print(f"error: JUnit file not found: {args.junit}", file=sys.stderr) + return EXIT_BAD_INPUT + if args.archive is not None and ( + not args.archive.exists() or not args.archive.is_file() + ): + print(f"error: archive file not found: {args.archive}", file=sys.stderr) + return EXIT_BAD_INPUT + + say = (lambda _m: None) if args.quiet else print + + # --- Auth ------------------------------------------------------------- # + client = PrismClient(args.url) + if args.verbose: + say(f"→ logging in to {args.url} as {args.email}") + try: + client.login(args.email, args.password) + except RuntimeError as exc: + print(f"error: authentication failed — {exc}", file=sys.stderr) + return EXIT_AUTH + + # --- Project ---------------------------------------------------------- # + if args.auto_create_project: + client.ensure_project( + args.project, args.project, description="Auto-created by upload_run.py" + ) + elif not client.project_exists(args.project): + print( + f"error: project {args.project!r} not found. " + "Pass --auto-create-project to create it, or create it in the UI first.", + file=sys.stderr, + ) + return EXIT_NO_PROJECT + + # --- Read payloads ---------------------------------------------------- # + junit_bytes = args.junit.read_bytes() + archive_bytes = args.archive.read_bytes() if args.archive is not None else None + tags = dict(args.tag) + + if args.verbose: + say( + f"→ uploading {args.junit.name} ({len(junit_bytes)} bytes) " + f"as run {args.run_name!r} in project {args.project!r}" + + ( + f" with archive {args.archive.name} ({len(archive_bytes or b'')} bytes)" + if archive_bytes + else "" + ) + ) + + # --- Upload ----------------------------------------------------------- # + try: + result = client.upload_run( + project_slug=args.project, + run_name=args.run_name, + junit_xml=junit_bytes, + archive_zip=archive_bytes, + tags=tags, + ) + except RuntimeError as exc: + print(f"error: upload failed — {exc}", file=sys.stderr) + return EXIT_UPLOAD + + run_id: str = str(result["id"]) + status: str = str(result.get("status", "pending")) + + # --- Optional wait-for-ingest ---------------------------------------- # + if args.wait is not None: + if args.verbose: + say(f"→ waiting up to {args.wait}s for ingest to finish …") + deadline = time.monotonic() + args.wait + while status == "pending": + if time.monotonic() >= deadline: + print( + f"warning: timed out after {args.wait}s; run {run_id} is still pending", + file=sys.stderr, + ) + # Still print the normal success line so CI can capture the ID. + print(f"uploaded {args.run_name} (id={run_id}, status=pending)") + return EXIT_WAIT_TIMEOUT + time.sleep(2) + try: + detail = client.get_run(run_id) + except RuntimeError as exc: + print(f"warning: could not poll run status — {exc}", file=sys.stderr) + break + status = str(detail.get("status", status)) + + print(f"uploaded {args.run_name} (id={run_id}, status={status})") + return EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 8017dc3b297248e7a854c4a6e821969dd0f9fc68 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Wed, 10 Jun 2026 17:22:11 -0400 Subject: [PATCH 3/9] ci: remove accidentally committed __pycache__ from scripts --- .../__pycache__/_prism_client.cpython-312.pyc | Bin 9978 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/scripts/__pycache__/_prism_client.cpython-312.pyc diff --git a/.github/scripts/__pycache__/_prism_client.cpython-312.pyc b/.github/scripts/__pycache__/_prism_client.cpython-312.pyc deleted file mode 100644 index 4d94bd21feee84ce3bb96279e6b401c35e412eeb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9978 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 pGyl;*(VKru)%}LD|Awj||1kfly Date: Wed, 10 Jun 2026 17:22:37 -0400 Subject: [PATCH 4/9] ci: labgrid hardware CI via matlab-hw-request@v3 --- .github/workflows/hw-matlab.yml | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/hw-matlab.yml diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml new file mode 100644 index 00000000..9a12a4be --- /dev/null +++ b/.github/workflows/hw-matlab.yml @@ -0,0 +1,45 @@ +name: Hardware Tests (matlab-hw-request v3) + +# First labgrid hardware CI for this toolbox. Discovery intersects +# test/hw_ci/board_map.yaml with live coordinator places; each leg boots a +# matching board (iiod-verified, bounded retry, boot failures annotated +# distinctly), runs runHWTests() against $IIO_URI +# (HardwareTests.m reads it), and parses the JUnit for pass/fail. + +permissions: + contents: read + checks: write + pull-requests: write + +on: + workflow_dispatch: + push: + branches: [master] + pull_request: + types: [labeled, opened, synchronize, reopened] + schedule: + - cron: "30 5 * * *" # nightly slots: pyadi-iio 04:00, pyadi-dt 04:30, TT 05:00 + +jobs: + hw-matlab: + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'hw-test') + uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3 + with: + coordinator: ${{ vars.LG_COORDINATOR }} # gRPC :20408, NOT :8000 + 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 }} + prism-upload: ${{ vars.PRISM_UPLOAD_ENABLED == 'true' }} + prism-project: highspeedconverter-toolbox + prism-upload-cmd: >- + python3 .github/scripts/prism_upload_run.py "$PRISM_JUNIT" + --url "$PRISM_URL" --project "$PRISM_PROJECT" --run-name "$PRISM_RUN_NAME" + --auto-create-project + --tag "board=$PRISM_BOARD" --tag "carrier=$PRISM_CARRIER" + --tag "place=$PRISM_PLACE" --tag "sha=$GITHUB_SHA" + secrets: + PRISM_EMAIL: ${{ secrets.PRISM_EMAIL }} + PRISM_PASSWORD: ${{ secrets.PRISM_PASSWORD }} From 2716ff708dcaabaa9cdf8dd0ca4698e809fab2a9 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Wed, 10 Jun 2026 17:23:02 -0400 Subject: [PATCH 5/9] doc: hardware CI section (labgrid, board map, local repro) --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index e101a632..6d548fd3 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,25 @@ We provide support for certain releases of MATLAB. This does not mean older rele All support questions should be posted in our [EngineerZone](https://ez.analog.com/linux-device-drivers/linux-software-drivers) forums. Documentation is included within the toolbox but additional documentation is avaible on the [ADI Wiki](https://wiki.analog.com/resources/tools-software/hsx-toolbox). +## Hardware Testing (CI) + +Hardware tests run on real lab boards via [labgrid](https://github.com/labgrid-project/labgrid), using the reusable [`matlab-hw-request.yml@v3`](https://github.com/tfcollins/labgrid-plugins/blob/main/.github/workflows/matlab-hw-request.yml) workflow from [tfcollins/labgrid-plugins](https://github.com/tfcollins/labgrid-plugins). + +**Triggers** — the `hw-matlab` job runs on: +- Pull requests labeled `hw-test` +- Pushes to `master` +- Manual `workflow_dispatch` +- Nightly schedule (05:30 UTC) + +**Board map** — `test/hw_ci/board_map.yaml` maps coordinator place tags (`carrier`, `daughter-board`, `hdl-config`) to the board name `runHWTests()` expects. The most-specific matching row wins. Keep this file in sync with the `switch` statement in `test/runHWTests.m`. + +**IIO URI** — `HardwareTests.m` picks up the booted board's URI from the `$IIO_URI` environment variable, which the workflow sets after iiod is verified reachable. + +**Local reproduction** — to request a board and run the tests manually: + +```bash +adi-lg request --part ad9081 --carrier zcu102 --wait 300 \ + --run "matlab -batch \"addpath(genpath('hdl')); addpath(genpath('test')); runHWTests('zynqmp-zcu102-rev10-ad9081-vm8-l4')\"" +``` + From a5e2ad55ebdf791379020a402e900c18f51df8cd Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Thu, 11 Jun 2026 20:08:24 -0400 Subject: [PATCH 6/9] ci: bump to labgrid-plugins v3.2 (coordinator robustness fixes) --- .github/workflows/hw-matlab.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml index 9a12a4be..90917ce1 100644 --- a/.github/workflows/hw-matlab.yml +++ b/.github/workflows/hw-matlab.yml @@ -1,4 +1,4 @@ -name: Hardware Tests (matlab-hw-request v3) +name: Hardware Tests (matlab-hw-request v3.2) # First labgrid hardware CI for this toolbox. Discovery intersects # test/hw_ci/board_map.yaml with live coordinator places; each leg boots a @@ -25,7 +25,7 @@ jobs: if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'hw-test') - uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3 + uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.2 with: coordinator: ${{ vars.LG_COORDINATOR }} # gRPC :20408, NOT :8000 board-map: "test/hw_ci/board_map.yaml" From 018e2f3acdde15a3bac9e345be4a595254c5724e Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Fri, 12 Jun 2026 00:14:40 -0400 Subject: [PATCH 7/9] ci: bump to labgrid-plugins v3.3 (DHCP-race fix in URI resolution) --- .github/workflows/hw-matlab.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml index 90917ce1..f2c7e73e 100644 --- a/.github/workflows/hw-matlab.yml +++ b/.github/workflows/hw-matlab.yml @@ -1,4 +1,4 @@ -name: Hardware Tests (matlab-hw-request v3.2) +name: Hardware Tests (matlab-hw-request v3.3) # First labgrid hardware CI for this toolbox. Discovery intersects # test/hw_ci/board_map.yaml with live coordinator places; each leg boots a @@ -25,7 +25,7 @@ jobs: if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'hw-test') - uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.2 + uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.3 with: coordinator: ${{ vars.LG_COORDINATOR }} # gRPC :20408, NOT :8000 board-map: "test/hw_ci/board_map.yaml" From 3423cac15044806dc4a2cb2597e3b5069378eb12 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Fri, 12 Jun 2026 09:30:48 -0400 Subject: [PATCH 8/9] ci: bump to labgrid-plugins v3.4 (lg_feature parity in rendered envs) --- .github/workflows/hw-matlab.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml index f2c7e73e..356687bd 100644 --- a/.github/workflows/hw-matlab.yml +++ b/.github/workflows/hw-matlab.yml @@ -1,4 +1,4 @@ -name: Hardware Tests (matlab-hw-request v3.3) +name: Hardware Tests (matlab-hw-request v3.4) # First labgrid hardware CI for this toolbox. Discovery intersects # test/hw_ci/board_map.yaml with live coordinator places; each leg boots a @@ -25,7 +25,7 @@ jobs: if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'hw-test') - uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.3 + uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.4 with: coordinator: ${{ vars.LG_COORDINATOR }} # gRPC :20408, NOT :8000 board-map: "test/hw_ci/board_map.yaml" From 55bb7780102e91166256047b13a9c019059a8593 Mon Sep 17 00:00:00 2001 From: "Travis F. Collins" Date: Fri, 12 Jun 2026 10:55:28 -0400 Subject: [PATCH 9/9] ci: bump to labgrid-plugins v3.5 (stable per-place MACs on TFTP boot) --- .github/workflows/hw-matlab.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw-matlab.yml b/.github/workflows/hw-matlab.yml index 356687bd..027e5ad7 100644 --- a/.github/workflows/hw-matlab.yml +++ b/.github/workflows/hw-matlab.yml @@ -1,4 +1,4 @@ -name: Hardware Tests (matlab-hw-request v3.4) +name: Hardware Tests (matlab-hw-request v3.5) # First labgrid hardware CI for this toolbox. Discovery intersects # test/hw_ci/board_map.yaml with live coordinator places; each leg boots a @@ -25,7 +25,7 @@ jobs: if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'hw-test') - uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.4 + uses: tfcollins/labgrid-plugins/.github/workflows/matlab-hw-request.yml@v3.5 with: coordinator: ${{ vars.LG_COORDINATOR }} # gRPC :20408, NOT :8000 board-map: "test/hw_ci/board_map.yaml"