From bd15ca7288519f117e01110f55474b3e2f9e498c Mon Sep 17 00:00:00 2001 From: Daniel Aharoni Date: Thu, 12 Mar 2026 22:22:38 -0700 Subject: [PATCH 1/9] config: add runner.org field Read the GitHub org from config instead of hardcoding in install_runner.sh. Defaults to "Aharoni-Lab" for backwards compat. Co-Authored-By: Claude Opus 4.6 --- configs/config.template.yaml | 1 + src/hilbench/config.py | 1 + tests/conftest.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/configs/config.template.yaml b/configs/config.template.yaml index 3820ff7..ef0887b 100644 --- a/configs/config.template.yaml +++ b/configs/config.template.yaml @@ -6,6 +6,7 @@ bench_name: my-bench-01 # unique bench identifier hostname: my-bench-01 # optional — set by bootstrap runner: + org: my-github-org # GitHub org for the runner labels: - self-hosted - linux diff --git a/src/hilbench/config.py b/src/hilbench/config.py index 6b256d6..8912d40 100644 --- a/src/hilbench/config.py +++ b/src/hilbench/config.py @@ -17,6 +17,7 @@ class RunnerConfig(BaseModel): + org: str = "Aharoni-Lab" labels: list[str] = Field(default_factory=lambda: ["self-hosted", "linux", "ARM64", "hil"]) diff --git a/tests/conftest.py b/tests/conftest.py index 972de10..8c6ea6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ SAMPLE_CONFIG = { "bench_name": "test-bench-01", "hostname": "test-bench-01", - "runner": {"labels": ["self-hosted", "linux", "ARM64", "hil"]}, + "runner": {"org": "test-org", "labels": ["self-hosted", "linux", "ARM64", "hil"]}, "targets": { "samd51": { "family": "samd51", From 760a800f0cacd65afe69310ac3a82e8bec2492dd Mon Sep 17 00:00:00 2001 From: Daniel Aharoni Date: Thu, 12 Mar 2026 22:23:31 -0700 Subject: [PATCH 2/9] fix: install_runner.sh reads org/labels from config, creates workdir, writes .env - Read runner.org and runner.labels from config via venv Python instead of hardcoding ORG and using tr to split bench name into labels - Always append bench_name to labels if not already present - mkdir -p /opt/hil-bench/_work to avoid permission errors - Write .env with venv on PATH so benchctl is available in runner jobs - Pass config path from bootstrap_pi.sh Fixes: runner label mangling, benchctl not found, workdir missing Co-Authored-By: Claude Opus 4.6 --- bootstrap/bootstrap_pi.sh | 2 +- bootstrap/install_runner.sh | 60 ++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/bootstrap/bootstrap_pi.sh b/bootstrap/bootstrap_pi.sh index 5b6b6da..efd9300 100755 --- a/bootstrap/bootstrap_pi.sh +++ b/bootstrap/bootstrap_pi.sh @@ -27,7 +27,7 @@ echo "" "${SCRIPT_DIR}/install_health_timer.sh" "$REPO_DIR" if [[ -n "$GITHUB_TOKEN" ]]; then - "${SCRIPT_DIR}/install_runner.sh" "$BENCH_NAME" "$GITHUB_TOKEN" + "${SCRIPT_DIR}/install_runner.sh" "$BENCH_NAME" "$GITHUB_TOKEN" /etc/hil-bench/config.yaml else echo "--- Skipping runner install (no token provided) ---" echo "Run manually: sudo ${SCRIPT_DIR}/install_runner.sh $BENCH_NAME " diff --git a/bootstrap/install_runner.sh b/bootstrap/install_runner.sh index 01f6b88..7648db8 100755 --- a/bootstrap/install_runner.sh +++ b/bootstrap/install_runner.sh @@ -1,19 +1,52 @@ #!/usr/bin/env bash -# Download and configure GitHub Actions runner at org scope (Aharoni-Lab). +# Download and configure GitHub Actions runner at org scope. +# Reads org and labels from bench config via the installed venv. set -euo pipefail -BENCH_NAME="${1:?Usage: $0 }" -GITHUB_TOKEN="${2:?Usage: $0 }" +BENCH_NAME="${1:?Usage: $0 [config-path]}" +GITHUB_TOKEN="${2:?Usage: $0 [config-path]}" +CONFIG_PATH="${3:-/etc/hil-bench/config.yaml}" RUNNER_DIR="/opt/hil-bench/actions-runner" RUNNER_VERSION="2.321.0" -ORG="Aharoni-Lab" +VENV="/opt/hil-bench/venv" +VENV_PYTHON="${VENV}/bin/python" + +# ── Read org and labels from config via venv Python ────────────────────── + +if [[ ! -x "$VENV_PYTHON" ]]; then + echo "ERROR: venv not found at ${VENV} — run install_python_env.sh first" + exit 1 +fi + +ORG=$("$VENV_PYTHON" -c " +import yaml, pathlib, sys +cfg = yaml.safe_load(pathlib.Path('${CONFIG_PATH}').read_text()) +print(cfg.get('runner', {}).get('org', 'Aharoni-Lab')) +") + +# Build labels: start with config labels, ensure bench_name is included +LABELS=$("$VENV_PYTHON" -c " +import yaml, pathlib +cfg = yaml.safe_load(pathlib.Path('${CONFIG_PATH}').read_text()) +labels = cfg.get('runner', {}).get('labels', ['self-hosted', 'linux', 'ARM64', 'hil']) +bench = cfg.get('bench_name', '${BENCH_NAME}') +if bench not in labels: + labels.append(bench) +print(','.join(labels)) +") echo "--- Installing GitHub Actions runner (org: ${ORG}) ---" +echo "Labels: ${LABELS}" + +# ── Create work directory ──────────────────────────────────────────────── + +mkdir -p /opt/hil-bench/_work + +# ── Download runner ────────────────────────────────────────────────────── mkdir -p "$RUNNER_DIR" -# Download runner if not present if [[ ! -f "$RUNNER_DIR/run.sh" ]]; then ARCH=$(dpkg --print-architecture) case "$ARCH" in @@ -29,14 +62,15 @@ if [[ ! -f "$RUNNER_DIR/run.sh" ]]; then curl -sL "$URL" | tar xz -C "$RUNNER_DIR" fi -# Configure runner at org level (idempotent — skips if .runner exists) +# ── Configure runner ───────────────────────────────────────────────────── + if [[ ! -f "$RUNNER_DIR/.runner" ]]; then echo "Configuring runner for org ${ORG}..." "$RUNNER_DIR/config.sh" \ --url "https://github.com/${ORG}" \ --token "$GITHUB_TOKEN" \ --name "$BENCH_NAME" \ - --labels "self-hosted,linux,ARM64,hil,$(echo "$BENCH_NAME" | tr '-' ',')" \ + --labels "$LABELS" \ --work /opt/hil-bench/_work \ --unattended \ --replace @@ -44,7 +78,17 @@ else echo "Runner already configured" fi -# Install and start systemd service +# ── Write .env so runner jobs see the venv on PATH ─────────────────────── + +cat > "$RUNNER_DIR/.env" </dev/null; then cd "$RUNNER_DIR" ./svc.sh install From f2618b328d36e3a1b1066502e9bd0d3e901de124 Mon Sep 17 00:00:00 2001 From: Daniel Aharoni Date: Thu, 12 Mar 2026 22:24:29 -0700 Subject: [PATCH 3/9] feat: add --check/-c filter to health command Add repeatable --check option to run a subset of health checks. Valid categories: config, probe, serial, gpio_chip, runner_service. When omitted, all checks run (existing behavior). This allows CI workflows to skip checks that require hardware not available in all contexts (e.g. serial device). Co-Authored-By: Claude Opus 4.6 --- src/hilbench/cli/health_cmd.py | 15 ++++++++++++--- src/hilbench/health.py | 32 +++++++++++++++++++++++++------- tests/test_cli.py | 8 ++++++++ tests/test_health.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/hilbench/cli/health_cmd.py b/src/hilbench/cli/health_cmd.py index d7d3e0f..74540d6 100644 --- a/src/hilbench/cli/health_cmd.py +++ b/src/hilbench/cli/health_cmd.py @@ -8,20 +8,29 @@ from rich.console import Console from rich.table import Table -from hilbench.health import results_to_dicts, run_all_checks +from hilbench.health import CHECK_CATEGORIES, results_to_dicts, run_checks @click.command() @click.option("--json-output", "--json", "as_json", is_flag=True, help="Output as JSON.") +@click.option( + "--check", + "-c", + "checks", + multiple=True, + type=click.Choice(CHECK_CATEGORIES, case_sensitive=False), + help="Run only specific checks (repeatable). Default: all.", +) @click.pass_obj -def health(ctx: object, as_json: bool) -> None: +def health(ctx: object, as_json: bool, checks: tuple[str, ...]) -> None: """Run health checks on the bench.""" from hilbench.cli.main import Context assert isinstance(ctx, Context) console = Console() cfg = ctx.config - results = run_all_checks(cfg) + categories = list(checks) if checks else None + results = run_checks(cfg, categories=categories) try: from hilbench.publisher import on_health_complete diff --git a/src/hilbench/health.py b/src/hilbench/health.py index 42fa7de..00877c4 100644 --- a/src/hilbench/health.py +++ b/src/hilbench/health.py @@ -15,6 +15,8 @@ logger = logging.getLogger(__name__) +CHECK_CATEGORIES: list[str] = ["config", "probe", "serial", "gpio_chip", "runner_service"] + @dataclass class CheckResult: @@ -96,12 +98,28 @@ def check_runner_service() -> CheckResult: ) -def run_all_checks(config: BenchConfig) -> list[CheckResult]: - """Run all health checks and return results.""" +_CHECK_RUNNERS: dict[str, Any] = { + "config": lambda cfg: [check_config(cfg)], + "probe": check_probe, + "serial": check_serial, + "gpio_chip": lambda cfg: [check_gpio_chip()], + "runner_service": lambda cfg: [check_runner_service()], +} + + +def run_checks( + config: BenchConfig, + categories: list[str] | None = None, +) -> list[CheckResult]: + """Run health checks, optionally filtered to specific categories.""" + selected = categories if categories else CHECK_CATEGORIES results: list[CheckResult] = [] - results.append(check_config(config)) - results.extend(check_probe(config)) - results.extend(check_serial(config)) - results.append(check_gpio_chip()) - results.append(check_runner_service()) + for cat in selected: + runner = _CHECK_RUNNERS[cat] + results.extend(runner(config)) return results + + +def run_all_checks(config: BenchConfig) -> list[CheckResult]: + """Run all health checks and return results.""" + return run_checks(config) diff --git a/tests/test_cli.py b/tests/test_cli.py index aa84553..2955b40 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -115,6 +115,14 @@ def test_health_no_config(self) -> None: # Should fail — no config at default path assert result.exit_code != 0 + def test_health_check_config_only(self, sample_config_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + cli, ["--config", str(sample_config_path), "health", "--check", "config"] + ) + assert result.exit_code == 0 + assert "config" in result.output + def test_publish_config_no_env(self) -> None: runner = CliRunner() result = runner.invoke(cli, ["publish", "config"]) diff --git a/tests/test_health.py b/tests/test_health.py index 2f139ad..bfe560f 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -10,6 +10,7 @@ check_gpio_chip, check_runner_service, run_all_checks, + run_checks, ) if TYPE_CHECKING: @@ -41,3 +42,34 @@ def test_run_all_checks(self, sample_config: BenchConfig) -> None: assert len(results) >= 4 names = [r.name for r in results] assert "config" in names + + def test_run_checks_filtered(self, sample_config: BenchConfig) -> None: + """Only config check runs when filtered to ['config'].""" + results = run_checks(sample_config, categories=["config"]) + assert len(results) == 1 + assert results[0].name == "config" + assert results[0].passed + + def test_run_checks_multiple_categories(self, sample_config: BenchConfig) -> None: + """Multiple categories run their respective checks.""" + with patch("hilbench.health.probe_factory") as mock_pf: + mock_probe = type("P", (), {"is_connected": lambda self: False})() + mock_pf.return_value = mock_probe + results = run_checks(sample_config, categories=["config", "probe"]) + + names = [r.name for r in results] + assert "config" in names + assert any(n.startswith("probe:") for n in names) + # serial, gpio_chip, runner_service should NOT be present + assert not any(n.startswith("serial:") for n in names) + assert "gpio_chip" not in names + assert "runner_service" not in names + + def test_run_checks_none_means_all(self, sample_config: BenchConfig) -> None: + """Passing categories=None runs all checks (same as run_all_checks).""" + with patch("hilbench.health.probe_factory") as mock_pf: + mock_probe = type("P", (), {"is_connected": lambda self: False})() + mock_pf.return_value = mock_probe + results = run_checks(sample_config, categories=None) + + assert len(results) >= 4 From c7a2604ff35cfe36ef8b6b3c9c93e2280c45dda5 Mon Sep 17 00:00:00 2001 From: Daniel Aharoni Date: Thu, 12 Mar 2026 22:25:04 -0700 Subject: [PATCH 4/9] fix: resolve firmware paths against CWD before workspace GitHub Actions downloads artifacts into the job's working directory, not the runner workspace. Try CWD first, then fall back to workspace. Glob resolution follows the same order. Co-Authored-By: Claude Opus 4.6 --- src/hilbench/artifacts.py | 47 ++++++++++++------- tests/test_artifacts.py | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 tests/test_artifacts.py diff --git a/src/hilbench/artifacts.py b/src/hilbench/artifacts.py index dcc4e0e..a883977 100644 --- a/src/hilbench/artifacts.py +++ b/src/hilbench/artifacts.py @@ -16,12 +16,13 @@ def resolve_firmware_path( firmware: str | Path, workspace: Path = RUNNER_WORK_DIR, ) -> Path: - """Resolve a firmware path — absolute or relative to runner workspace. + """Resolve a firmware path — absolute, relative to CWD, or relative to workspace. - Accepts: - - Absolute path: returned as-is if it exists - - Relative path: resolved against the runner workspace directory - - Glob pattern: if exactly one match, return it + Resolution order: + 1. Absolute path: returned as-is + 2. Relative to CWD: if it exists + 3. Relative to runner workspace: if it exists + 4. Glob against CWD, then workspace: if exactly one match """ path = Path(firmware) @@ -29,17 +30,31 @@ def resolve_firmware_path( if path.is_absolute(): return path - # Relative to workspace — check direct match first, then try glob - resolved = workspace / path - if resolved.exists(): - return resolved + # Relative to CWD first + cwd_resolved = Path.cwd() / path + if cwd_resolved.exists(): + return cwd_resolved - # Try glob + # Relative to workspace + ws_resolved = workspace / path + if ws_resolved.exists(): + return ws_resolved + + # Try glob — CWD first, then workspace pattern = str(firmware) - matches = sorted(workspace.glob(pattern)) - if len(matches) == 1: - return matches[0] - if len(matches) > 1: - raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(matches)} files") - raise ArtifactError(f"firmware {firmware!r} not found in workspace {workspace}") + cwd_matches = sorted(Path.cwd().glob(pattern)) + if len(cwd_matches) == 1: + return cwd_matches[0] + if len(cwd_matches) > 1: + raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(cwd_matches)} files in CWD") + + ws_matches = sorted(workspace.glob(pattern)) + if len(ws_matches) == 1: + return ws_matches[0] + if len(ws_matches) > 1: + raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace") + + raise ArtifactError( + f"firmware {firmware!r} not found in CWD ({Path.cwd()}) or workspace ({workspace})" + ) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..42f6bc7 --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,96 @@ +"""Tests for firmware artifact path resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hilbench.artifacts import resolve_firmware_path +from hilbench.exceptions import ArtifactError + + +class TestResolveFirmwarePath: + def test_absolute_path_returned_as_is(self, tmp_path: Path) -> None: + fw = tmp_path / "firmware.elf" + fw.write_bytes(b"\x00") + result = resolve_firmware_path(str(fw), workspace=tmp_path / "ws") + assert result == fw + + def test_cwd_takes_priority_over_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A file in CWD is found before the workspace is checked.""" + cwd_dir = tmp_path / "cwd" + ws_dir = tmp_path / "ws" + cwd_dir.mkdir() + ws_dir.mkdir() + + cwd_fw = cwd_dir / "test.bin" + cwd_fw.write_bytes(b"\x01") + ws_fw = ws_dir / "test.bin" + ws_fw.write_bytes(b"\x02") + + monkeypatch.chdir(cwd_dir) + result = resolve_firmware_path("test.bin", workspace=ws_dir) + assert result == cwd_fw + + def test_workspace_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Falls back to workspace when CWD doesn't contain the file.""" + cwd_dir = tmp_path / "cwd" + ws_dir = tmp_path / "ws" + cwd_dir.mkdir() + ws_dir.mkdir() + + ws_fw = ws_dir / "test.bin" + ws_fw.write_bytes(b"\x02") + + monkeypatch.chdir(cwd_dir) + result = resolve_firmware_path("test.bin", workspace=ws_dir) + assert result == ws_fw + + def test_glob_cwd_first(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Glob matches CWD before workspace.""" + cwd_dir = tmp_path / "cwd" + ws_dir = tmp_path / "ws" + cwd_dir.mkdir() + ws_dir.mkdir() + + cwd_fw = cwd_dir / "firmware.elf" + cwd_fw.write_bytes(b"\x01") + + monkeypatch.chdir(cwd_dir) + result = resolve_firmware_path("*.elf", workspace=ws_dir) + assert result == cwd_fw + + def test_glob_workspace_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Glob falls back to workspace when no CWD matches.""" + cwd_dir = tmp_path / "cwd" + ws_dir = tmp_path / "ws" + cwd_dir.mkdir() + ws_dir.mkdir() + + ws_fw = ws_dir / "firmware.elf" + ws_fw.write_bytes(b"\x02") + + monkeypatch.chdir(cwd_dir) + result = resolve_firmware_path("*.elf", workspace=ws_dir) + assert result == ws_fw + + def test_not_found_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cwd_dir = tmp_path / "cwd" + ws_dir = tmp_path / "ws" + cwd_dir.mkdir() + ws_dir.mkdir() + monkeypatch.chdir(cwd_dir) + + with pytest.raises(ArtifactError, match="not found"): + resolve_firmware_path("nope.bin", workspace=ws_dir) + + def test_ambiguous_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cwd_dir = tmp_path / "cwd" + cwd_dir.mkdir() + (cwd_dir / "a.elf").write_bytes(b"\x01") + (cwd_dir / "b.elf").write_bytes(b"\x02") + monkeypatch.chdir(cwd_dir) + + with pytest.raises(ArtifactError, match="ambiguous"): + resolve_firmware_path("*.elf", workspace=tmp_path / "ws") From 6afe7e0cbd756d91131a23f256640d91a81bfbe7 Mon Sep 17 00:00:00 2001 From: Daniel Aharoni Date: Thu, 12 Mar 2026 22:25:35 -0700 Subject: [PATCH 5/9] feat: add bootstrap/update.sh for lightweight updates Idempotent script to run after git pull: 1. pip install -e to update benchctl 2. Ensure work directory exists 3. Refresh runner .env (if installed) 4. Refresh systemd health timer Does NOT touch config or runner registration. Co-Authored-By: Claude Opus 4.6 --- bootstrap/update.sh | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 bootstrap/update.sh diff --git a/bootstrap/update.sh b/bootstrap/update.sh new file mode 100644 index 0000000..b89146e --- /dev/null +++ b/bootstrap/update.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Lightweight idempotent update script. +# Run after `git pull` to apply code + config changes without re-registering +# the runner or touching /etc/hil-bench/config.yaml. +# +# Usage: sudo ./bootstrap/update.sh [config-path] +set -euo pipefail + +CONFIG_PATH="${1:-/etc/hil-bench/config.yaml}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +VENV="/opt/hil-bench/venv" +RUNNER_DIR="/opt/hil-bench/actions-runner" + +echo "=== HIL Bench Update ===" +echo "Repo: ${REPO_DIR}" +echo "Config: ${CONFIG_PATH}" +echo "" + +# ── 1. Reinstall benchctl into venv ────────────────────────────────────── + +if [[ -d "$VENV" ]]; then + echo "--- Updating benchctl in venv ---" + "${VENV}/bin/pip" install --quiet -e "$REPO_DIR" + echo "benchctl updated" +else + echo "WARNING: venv not found at ${VENV} — run install_python_env.sh first" +fi + +# ── 2. Ensure work directory exists ────────────────────────────────────── + +mkdir -p /opt/hil-bench/_work +echo "Work directory: /opt/hil-bench/_work" + +# ── 3. Refresh runner .env (if runner is installed) ────────────────────── + +if [[ -d "$RUNNER_DIR" ]]; then + echo "--- Refreshing runner .env ---" + cat > "$RUNNER_DIR/.env" < Date: Thu, 12 Mar 2026 22:26:09 -0700 Subject: [PATCH 6/9] docs: update example workflow with health check and simplified labels - Simplify runs-on to use bench_name label (added automatically by runner) - Add benchctl health --check config --check probe step before flashing - Update concurrency group to be repo+ref scoped - Add comment explaining bench_name as concurrency key Co-Authored-By: Claude Opus 4.6 --- examples/firmware-ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/firmware-ci.yml b/examples/firmware-ci.yml index f61c9d6..0393a01 100644 --- a/examples/firmware-ci.yml +++ b/examples/firmware-ci.yml @@ -8,9 +8,11 @@ on: branches: [main] pull_request: -# Only one HIL test per bench at a time +# Only one HIL test per bench at a time. +# Use the bench_name from your config as the concurrency group to ensure +# multiple repos sharing a bench don't run simultaneously. concurrency: - group: hil-samd51-bench01 + group: hil-${{ github.repository }}-${{ github.ref }} cancel-in-progress: false jobs: @@ -33,14 +35,17 @@ jobs: hil-test: needs: build - # Target a specific bench by its runner labels - runs-on: [self-hosted, linux, ARM64, hil, samd51, bench01] + # Use your bench_name as a label — install_runner.sh adds it automatically + runs-on: [self-hosted, hil, my-bench-01] timeout-minutes: 10 steps: - uses: actions/download-artifact@v4 with: name: firmware + - name: Bench health check + run: benchctl health --check config --check probe + - name: Flash firmware run: | benchctl flash \ From 2e5c7417bd28d1621e378a9c6739f8ee9a4ead13 Mon Sep 17 00:00:00 2001 From: daharoni Date: Thu, 12 Mar 2026 22:38:19 -0700 Subject: [PATCH 7/9] Simplify: deduplicate bootstrap scripts and tighten types - Merge two Python subprocess calls in install_runner.sh into one - Extract duplicated .env heredoc into shared write_runner_env.sh - Cache Path.cwd() in resolve_firmware_path instead of calling 3x - Derive CHECK_CATEGORIES from _CHECK_RUNNERS keys to prevent divergence - Replace dict[str, Any] with proper Callable type for _CHECK_RUNNERS Co-Authored-By: Claude Opus 4.6 --- bootstrap/install_runner.sh | 20 +++++--------------- bootstrap/update.sh | 7 +------ bootstrap/write_runner_env.sh | 15 +++++++++++++++ src/hilbench/artifacts.py | 8 +++++--- src/hilbench/health.py | 7 ++++--- 5 files changed, 30 insertions(+), 27 deletions(-) create mode 100755 bootstrap/write_runner_env.sh diff --git a/bootstrap/install_runner.sh b/bootstrap/install_runner.sh index 7648db8..8b13bf6 100755 --- a/bootstrap/install_runner.sh +++ b/bootstrap/install_runner.sh @@ -19,21 +19,15 @@ if [[ ! -x "$VENV_PYTHON" ]]; then exit 1 fi -ORG=$("$VENV_PYTHON" -c " -import yaml, pathlib, sys -cfg = yaml.safe_load(pathlib.Path('${CONFIG_PATH}').read_text()) -print(cfg.get('runner', {}).get('org', 'Aharoni-Lab')) -") - -# Build labels: start with config labels, ensure bench_name is included -LABELS=$("$VENV_PYTHON" -c " +read -r ORG LABELS < <("$VENV_PYTHON" -c " import yaml, pathlib cfg = yaml.safe_load(pathlib.Path('${CONFIG_PATH}').read_text()) +org = cfg.get('runner', {}).get('org', 'Aharoni-Lab') labels = cfg.get('runner', {}).get('labels', ['self-hosted', 'linux', 'ARM64', 'hil']) bench = cfg.get('bench_name', '${BENCH_NAME}') if bench not in labels: labels.append(bench) -print(','.join(labels)) +print(org, ','.join(labels)) ") echo "--- Installing GitHub Actions runner (org: ${ORG}) ---" @@ -80,12 +74,8 @@ fi # ── Write .env so runner jobs see the venv on PATH ─────────────────────── -cat > "$RUNNER_DIR/.env" < "$RUNNER_DIR/.env" < +set -euo pipefail + +RUNNER_DIR="${1:?Usage: $0 }" +VENV="${2:?Usage: $0 }" +CONFIG_PATH="${3:?Usage: $0 }" + +cat > "$RUNNER_DIR/.env" < 1: @@ -56,5 +58,5 @@ def resolve_firmware_path( raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace") raise ArtifactError( - f"firmware {firmware!r} not found in CWD ({Path.cwd()}) or workspace ({workspace})" + f"firmware {firmware!r} not found in CWD ({cwd}) or workspace ({workspace})" ) diff --git a/src/hilbench/health.py b/src/hilbench/health.py index 00877c4..033ad29 100644 --- a/src/hilbench/health.py +++ b/src/hilbench/health.py @@ -6,6 +6,7 @@ import subprocess from dataclasses import asdict, dataclass from pathlib import Path +from collections.abc import Callable from typing import TYPE_CHECKING, Any from hilbench.probe import probe_factory @@ -15,8 +16,6 @@ logger = logging.getLogger(__name__) -CHECK_CATEGORIES: list[str] = ["config", "probe", "serial", "gpio_chip", "runner_service"] - @dataclass class CheckResult: @@ -98,7 +97,7 @@ def check_runner_service() -> CheckResult: ) -_CHECK_RUNNERS: dict[str, Any] = { +_CHECK_RUNNERS: dict[str, Callable[[BenchConfig], list[CheckResult]]] = { "config": lambda cfg: [check_config(cfg)], "probe": check_probe, "serial": check_serial, @@ -106,6 +105,8 @@ def check_runner_service() -> CheckResult: "runner_service": lambda cfg: [check_runner_service()], } +CHECK_CATEGORIES: list[str] = list(_CHECK_RUNNERS.keys()) + def run_checks( config: BenchConfig, From a18cbdff86a846fa97b4d9923d00d70ce1cb0e70 Mon Sep 17 00:00:00 2001 From: daharoni Date: Thu, 12 Mar 2026 22:43:24 -0700 Subject: [PATCH 8/9] Fix ruff lint errors: line length, import sorting, TC003 Co-Authored-By: Claude Opus 4.6 --- src/hilbench/artifacts.py | 6 ++++-- src/hilbench/health.py | 3 ++- tests/test_artifacts.py | 13 ++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/hilbench/artifacts.py b/src/hilbench/artifacts.py index 7113ca4..14d3698 100644 --- a/src/hilbench/artifacts.py +++ b/src/hilbench/artifacts.py @@ -49,13 +49,15 @@ def resolve_firmware_path( if len(cwd_matches) == 1: return cwd_matches[0] if len(cwd_matches) > 1: - raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(cwd_matches)} files in CWD") + msg = f"ambiguous firmware path {firmware!r}: matched {len(cwd_matches)} files in CWD" + raise ArtifactError(msg) ws_matches = sorted(workspace.glob(pattern)) if len(ws_matches) == 1: return ws_matches[0] if len(ws_matches) > 1: - raise ArtifactError(f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace") + msg = f"ambiguous firmware path {firmware!r}: matched {len(ws_matches)} files in workspace" + raise ArtifactError(msg) raise ArtifactError( f"firmware {firmware!r} not found in CWD ({cwd}) or workspace ({workspace})" diff --git a/src/hilbench/health.py b/src/hilbench/health.py index 033ad29..c9ea04d 100644 --- a/src/hilbench/health.py +++ b/src/hilbench/health.py @@ -6,12 +6,13 @@ import subprocess from dataclasses import asdict, dataclass from pathlib import Path -from collections.abc import Callable from typing import TYPE_CHECKING, Any from hilbench.probe import probe_factory if TYPE_CHECKING: + from collections.abc import Callable + from hilbench.config import BenchConfig logger = logging.getLogger(__name__) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 42f6bc7..ca25fde 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -2,10 +2,13 @@ from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from pathlib import Path + from hilbench.artifacts import resolve_firmware_path from hilbench.exceptions import ArtifactError @@ -17,7 +20,9 @@ def test_absolute_path_returned_as_is(self, tmp_path: Path) -> None: result = resolve_firmware_path(str(fw), workspace=tmp_path / "ws") assert result == fw - def test_cwd_takes_priority_over_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_cwd_takes_priority_over_workspace( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """A file in CWD is found before the workspace is checked.""" cwd_dir = tmp_path / "cwd" ws_dir = tmp_path / "ws" @@ -61,7 +66,9 @@ def test_glob_cwd_first(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) - result = resolve_firmware_path("*.elf", workspace=ws_dir) assert result == cwd_fw - def test_glob_workspace_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_glob_workspace_fallback( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Glob falls back to workspace when no CWD matches.""" cwd_dir = tmp_path / "cwd" ws_dir = tmp_path / "ws" From 3594f3bdd388acf1b1e98e2d5dcbacaa026d00ee Mon Sep 17 00:00:00 2001 From: daharoni Date: Thu, 12 Mar 2026 22:48:06 -0700 Subject: [PATCH 9/9] Fix executable bit on bootstrap/update.sh Co-Authored-By: Claude Opus 4.6 --- bootstrap/update.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bootstrap/update.sh diff --git a/bootstrap/update.sh b/bootstrap/update.sh old mode 100644 new mode 100755