diff --git a/.github/labview/run-unit-tests.ps1 b/.github/labview/run-unit-tests.ps1 index dac7416b..18a673f8 100644 --- a/.github/labview/run-unit-tests.ps1 +++ b/.github/labview/run-unit-tests.ps1 @@ -32,14 +32,32 @@ vitester*/vi-tester* -> VI Tester, utf*/unit-test* -> UTF). .PARAMETER ConfigPath - Path to labview-ci.yml. Default: \.github\labview-ci.yml. + Path to labview-ci.yml. Default: \.github\labview-ci.yml. Ignored + when -Framework is set. + +.PARAMETER Framework + When set, bypasses ConfigPath/labview-ci.yml entirely and runs exactly ONE tool: + 'caraya', 'vi-tester', 'utf', 'lunit', or an arbitrary id paired with -Command. + Used by actions/unit-tests (the step-level composite action), which takes the + framework as an action input instead of a repo-committed config file. + +.PARAMETER TestDir + -Framework only: a single test location (directory, glob, or .lvproj), same + contract as a config tool's `locations[0]`. Empty = whole project/workspace. + +.PARAMETER Command + -Framework only: overrides that tool's default command template (tokens + {ver}{dir}{out}{lv}, or {cli}{proj} for utf/lunit). Empty = built-in default. #> param( [string]$WorkspaceRoot = 'C:\workspace', [string]$ResultsDir = 'C:\workspace\ci-out\unit-tests\results', [string]$ConfigPath = '', [string]$LabVIEWVersion = '2026', - [string]$LabVIEWPath = '' + [string]$LabVIEWPath = '', + [string]$Framework = '', + [string]$TestDir = '', + [string]$Command = '' ) $ErrorActionPreference = 'Stop' @@ -588,7 +606,25 @@ function Invoke-LUnitTests($tool, [int]$index) { } # -- Main --------------------------------------------------------------------- -$tools = Read-UnitTestTools $ConfigPath +# actions/unit-tests passes the framework selection as ENVIRONMENT VARIABLES +# (docker --env-file) rather than as -Framework/-TestDir/-Command arguments, +# because Windows PowerShell 5.1 mangles such values on a native command line: an +# empty one is dropped (breaking parameter binding) and embedded double quotes - +# which every default command template contains, e.g. --junit "{out}" - are +# stripped. An explicit parameter still wins, so the script stays directly +# invokable with -Framework for local runs and other callers. +if (-not $Framework -and $env:LVCI_FRAMEWORK) { $Framework = $env:LVCI_FRAMEWORK } +if (-not $TestDir -and $env:LVCI_TEST_DIR) { $TestDir = $env:LVCI_TEST_DIR } +if (-not $Command -and $env:LVCI_COMMAND) { $Command = $env:LVCI_COMMAND } + +# -Framework runs a single tool straight from parameters (no config file / YAML +# parsing involved), which is what actions/unit-tests uses. Otherwise fall back +# to the repo's own config.unitTests.tools[] as before. +if ($Framework) { + $tools = @([ordered]@{ tool = $Framework.ToLower(); enabled = $true; command = $Command; locations = @($TestDir) }) +} else { + $tools = Read-UnitTestTools $ConfigPath +} if (-not $tools -or $tools.Count -eq 0) { Write-Warning "No config.unitTests.tools configured in $ConfigPath - nothing to run." Write-Host "Wrote 0 JUnit file(s) to $ResultsDir." diff --git a/actions/unit-tests/action.yml b/actions/unit-tests/action.yml new file mode 100644 index 00000000..660af6c4 --- /dev/null +++ b/actions/unit-tests/action.yml @@ -0,0 +1,199 @@ +# Composite action: LabVIEW Unit Tests +# +# Step-level action that runs ONE LabVIEW unit-test framework headlessly inside a +# container and publishes a friendly HTML report, so a consumer repo can run tests +# in CI by adding this single step (plus actions/checkout) and nothing else. +# +# Framework-general by design: `framework` + `command` are wired straight through +# to the bundled run-unit-tests.ps1, which already supports Caraya / JKI VI Tester +# (both via g-cli), NI UTF and Astemes LUnit (both via native LabVIEWCLI +# operations), or any other headless test runner via a `command` template +# ({ver}{dir}{out}{lv} tokens). The framework's OWN tooling (g-cli plugin, VI +# Tester package, UTF toolkit, astemes_lib_lunit_cli, ...) must be baked into +# `image` - this action does not install it. When it's missing, the report shows +# a "container is missing this tooling" banner instead of a bare "no tests found". +# +# Follows the actions/masscompile pattern: mounts BOTH the workspace and +# ${{ github.action_path }} into the container so the bundled runner script +# travels WITH the action; the consumer repo holds no copy of it. Pin this +# action by tag (@v1) to receive updates. +# +# After extraction, keep the bundled scripts in sync with .github/labview/: +# cp /.github/labview/run-unit-tests.ps1 actions/unit-tests/run-unit-tests.ps1 +# cp /.github/labview/build-unittest-report.py actions/unit-tests/build-unittest-report.py +name: 'LabVIEW Unit Tests' +description: 'Run a LabVIEW unit-test framework inside a container and publish an HTML report.' +author: 'labview-ci' + +inputs: + image: + description: 'Container image to run in. Must have the chosen framework''s tooling baked in.' + required: true + framework: + description: > + Test framework to run: 'caraya', 'vi-tester', 'utf', or 'lunit'. Any other + value is accepted too, as long as `command` is also set. + required: true + test-dir: + description: 'Test location (directory, glob, or .lvproj) relative to the workspace. Empty = whole project.' + required: false + default: '' + command: + description: > + Override headless command template for `framework` (tokens: {ver}=LabVIEW + year, {dir}=resolved test directory, {out}=JUnit output path, {lv}=LabVIEW.exe + path, {cli}=LabVIEWCLI path, {proj}=.lvproj path for utf). Required for any + framework other than caraya/vi-tester/utf/lunit; optional override otherwise. + required: false + default: '' + labview-version: + description: 'LabVIEW year (drives the in-container LabVIEW.exe path).' + required: false + default: '2026' + report-dir: + description: 'Report output directory (relative to the workspace).' + required: false + default: 'ci-out/unit-tests' + pages-url: + description: 'Base GitHub Pages URL (for report nav/snapshot links). Defaults to https://.github.io/.' + required: false + default: '' + fail-on-test-failure: + description: 'Fail this step when one or more tests fail.' + required: false + default: 'true' + +outputs: + report-exists: + description: 'true when a report index.html was produced.' + value: ${{ steps.check.outputs.exists }} + tests: + description: 'Total tests run (from results.json), or empty.' + value: ${{ steps.check.outputs.tests }} + passed: + description: 'Passed test count, or empty.' + value: ${{ steps.check.outputs.passed }} + failed: + description: 'Failed + errored test count, or empty.' + value: ${{ steps.check.outputs.failed }} + +runs: + using: 'composite' + steps: + - name: Run Unit Tests inside container + shell: powershell + # Action inputs reach this step as env vars rather than textual ${{ }} + # interpolation into the script body: templating unsanitised input straight + # into a shell script is an injection risk. + env: + UT_IMAGE: ${{ inputs.image }} + UT_REPORT_DIR: ${{ inputs.report-dir }} + UT_LV_VERSION: ${{ inputs.labview-version }} + UT_FRAMEWORK: ${{ inputs.framework }} + UT_TEST_DIR: ${{ inputs.test-dir }} + UT_COMMAND: ${{ inputs.command }} + run: | + docker pull "$env:UT_IMAGE" + + # framework / test-dir / command reach the container as ENVIRONMENT + # VARIABLES via --env-file, never as command-line arguments. Windows + # PowerShell 5.1 (the `shell: powershell` host on windows-2022) cannot pass + # these values on a native command line correctly: + # * an EMPTY value is dropped from the argument list entirely, so the + # following -Param is consumed as the missing value and the script dies + # with "Missing an argument for parameter ...". This hits the DEFAULT + # path, where `command` (and often `test-dir`) is unset. + # * EMBEDDED DOUBLE QUOTES are stripped, silently corrupting a command + # template: --directory "{dir}" -> --directory {dir} , which then + # breaks as soon as a resolved test path contains a space. + # Quoting the arguments does not help ("" is still dropped). docker parses + # --env-file itself, so no shell quoting applies on any hop. + $tmp = $env:RUNNER_TEMP; if (-not $tmp) { $tmp = $env:TEMP } + $envFile = Join-Path $tmp 'lvci-unit-tests.env' + # docker's env-file format is one VAR=value per line, value taken literally + # to end of line - so a newline in a value would corrupt the file. Flatten. + $lines = [string[]]@( + "LVCI_FRAMEWORK=$($env:UT_FRAMEWORK -replace '[\r\n]+', ' ')" + "LVCI_TEST_DIR=$($env:UT_TEST_DIR -replace '[\r\n]+', ' ')" + "LVCI_COMMAND=$($env:UT_COMMAND -replace '[\r\n]+', ' ')" + ) + # UTF-8 *without* BOM: PS 5.1's -Encoding utf8 emits a BOM, which docker + # would read as part of the first variable's NAME. + [System.IO.File]::WriteAllLines($envFile, $lines, (New-Object System.Text.UTF8Encoding($false))) + + # Mount the workspace AND this action's directory (which carries the script) + # into the container. ${{ github.action_path }} is the checked-out action on + # the runner host; mounting it makes run-unit-tests.ps1 available at C:\ci-action. + docker run --rm ` + -e "GITHUB_REPOSITORY=${{ github.repository }}" ` + -e "GITHUB_SHA=${{ github.sha }}" ` + --env-file "$envFile" ` + -v "${{ github.workspace }}:C:\workspace" ` + -v "${{ github.action_path }}:C:\ci-action" ` + "$env:UT_IMAGE" ` + powershell -NonInteractive -ExecutionPolicy Bypass ` + -File "C:\ci-action\run-unit-tests.ps1" ` + -WorkspaceRoot "C:\workspace" ` + -ResultsDir "C:\workspace\$env:UT_REPORT_DIR\results" ` + -LabVIEWVersion "$env:UT_LV_VERSION" ` + -LabVIEWPath "C:\Program Files\National Instruments\LabVIEW $env:UT_LV_VERSION\LabVIEW.exe" + + # Turn the plain JUnit dump into a navigable report (failures first, snapshot + # drawer, deep links into the VI Browser). Runs on the runner host (not in the + # container) so it can enumerate the workspace; the bundled + # build-unittest-report.py is kept in sync with .github/labview/. + - name: Build friendly Unit Tests report + shell: bash + continue-on-error: true + env: + UT_REPORT_DIR: ${{ inputs.report-dir }} + UT_PAGES_URL: ${{ inputs.pages-url }} + UT_LV_VERSION: ${{ inputs.labview-version }} + run: | + OUT="${{ github.workspace }}/$UT_REPORT_DIR" + PAGES="$UT_PAGES_URL" + if [ -z "$PAGES" ]; then + owner="${GITHUB_REPOSITORY%%/*}"; repo="${GITHUB_REPOSITORY#*/}" + PAGES="https://${owner}.github.io/${repo}" + fi + python "${{ github.action_path }}/build-unittest-report.py" \ + --results "$OUT/results" \ + --out "$OUT" \ + --workspace "${{ github.workspace }}" \ + --sha "${{ github.sha }}" \ + --platform windows \ + --repo "${{ github.repository }}" \ + --pages-url "$PAGES" \ + --labview-version "$UT_LV_VERSION" \ + --commit-msg "$(git log -1 --pretty=%s)" \ + --author "$(git log -1 --pretty=%an)" \ + --date "$(git log -1 --pretty=%cI)" + + - name: Check report directory + id: check + shell: bash + env: + UT_REPORT_DIR: ${{ inputs.report-dir }} + run: | + OUT="${{ github.workspace }}/$UT_REPORT_DIR" + if [ -f "$OUT/index.html" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + TESTS=""; PASSED=""; FAILED="" + if [ -f "$OUT/results.json" ]; then + TESTS=$(jq -r '.summary.tests // 0' "$OUT/results.json") + PASSED=$(jq -r '.summary.passed // 0' "$OUT/results.json") + FAILED=$(jq -r '((.summary.failed // 0) + (.summary.errored // 0))' "$OUT/results.json") + fi + echo "tests=$TESTS" >> "$GITHUB_OUTPUT" + echo "passed=$PASSED" >> "$GITHUB_OUTPUT" + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + + - name: Fail on test failures + if: inputs.fail-on-test-failure == 'true' && steps.check.outputs.failed != '' && steps.check.outputs.failed != '0' + shell: bash + run: | + echo "::error::${{ steps.check.outputs.failed }} unit test(s) failed (${{ steps.check.outputs.passed }} passed). See the Unit Tests report artifact/deploy for details." + exit 1 diff --git a/actions/unit-tests/build-unittest-report.py b/actions/unit-tests/build-unittest-report.py new file mode 100644 index 00000000..f8c9fd7f --- /dev/null +++ b/actions/unit-tests/build-unittest-report.py @@ -0,0 +1,764 @@ +#!/usr/bin/env python3 +""" +build-unittest-report.py — Turn the JUnit XML produced by one or more LabVIEW +unit-test frameworks (JKI Caraya, JKI VI Tester, Astemes LUnit, and NI Unit Test +Framework) into ONE friendly, navigable report, exactly like the Mass Compile / +VI Analyzer reports: it groups results by tool → suite → test, surfaces failures +first, and lets you open the test VI's rendered snapshot (and, when derivable, +the VI *under test*) in the VI Browser for this revision. + +WHY A SHARED REPORT + Each framework reports differently (Caraya = assertion VIs, VI Tester = + xUnit TestCase classes, UTF = .lvtest), but all three can emit JUnit XML. + JUnit is therefore the common interchange: the runner script writes one XML + per enabled tool, and this script merges them into a single unified model so + the dashboard shows one "Unit Tests" result per revision with a pass-rate + badge, and the viewer frames it in the shared site chrome (lvci-header.js). + +INPUTS + --results directory of JUnit XML files. The tool for each file is + inferred from its name: caraya*.xml → Caraya, + vi-tester*/vitester* → VI Tester, lunit*.xml → LUnit, + utf*/unit-test* → UTF. + (Override per file with --junit TOOL:PATH, repeatable.) + --workspace repo checkout — used to resolve a test's VI name to its + repo-relative path (for snapshots) and to derive the VI + under test by naming convention. + +OUTPUTS + /index.html the friendly report (the deployed page) + /results.json the unified model (so the report can be rebuilt, and the + OTHER platform's tab can lazy-load it) + +The model + renderer intentionally mirror build-masscompile-report.py so the two +reports feel identical and share the snapshot drawer + VI-Browser deep links. +""" +from __future__ import annotations + +import argparse +import glob +import html +import json +import os +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + + +# ── tool identity ──────────────────────────────────────────────────────────── +TOOLS = { + "caraya": {"id": "caraya", "label": "Caraya"}, + "vi-tester": {"id": "vi-tester", "label": "VI Tester"}, + "lunit": {"id": "lunit", "label": "LUnit"}, + "utf": {"id": "utf", "label": "Unit Test Framework"}, +} +STATUS_ORDER = ["failed", "error", "skipped", "passed"] +STATUS_META = { + "failed": {"label": "Failed", "color": "#da3633", "blurb": "An assertion failed."}, + "error": {"label": "Errored", "color": "#bb8009", "blurb": "The test raised an unexpected error."}, + "skipped": {"label": "Skipped", "color": "#6e7681", "blurb": "The test was skipped."}, + "passed": {"label": "Passed", "color": "#2ea043", "blurb": "The test passed."}, +} + + +def tool_for_filename(name: str) -> str: + n = name.lower() + if "caraya" in n: + return "caraya" + if "vitester" in n or "vi-tester" in n or "vi_tester" in n: + return "vi-tester" + if n.startswith("lunit"): + return "lunit" + if n.startswith("utf") or "unit-test-framework" in n or "lvtest" in n: + return "utf" + return "caraya" # safe default; most hand-rolled JUnit looks like Caraya's + + +# ── VI path resolution (mirror of the mass-compile report's approach) ───────── +def index_workspace(workspace: Path) -> dict[str, list[str]]: + """Map each VI's lower-case base name → list of repo-relative paths. Lets us + resolve a test's bare VI name to a real path for the snapshot gallery.""" + by_base: dict[str, list[str]] = {} + if not workspace or not workspace.is_dir(): + return by_base + for root, _dirs, files in os.walk(workspace): + # Skip CI tooling + build output so we match the snapshot gallery. + rel_root = os.path.relpath(root, workspace) + parts = rel_root.replace("\\", "/").split("/") + if parts and parts[0] in (".git", ".github", "ci-out", "build"): + continue + for f in files: + if f.lower().endswith(".vi"): + rel = os.path.relpath(os.path.join(root, f), workspace).replace("\\", "/") + by_base.setdefault(f[:-3].lower(), []).append(rel) + return by_base + + +def to_rel(path: str) -> str: + return (path or "").replace("\\", "/").lstrip("./") + + +def base_name(name_or_path: str) -> str: + b = re.split(r"[\\/]", (name_or_path or "").strip())[-1] + if b.lower().endswith(".vi"): + b = b[:-3] + return b + + +def resolve_vi(name_or_path: str, by_base: dict[str, list[str]]) -> str: + """Best-effort: a JUnit testcase's name/classname/file → a unique repo path. + Returns '' when unknown or ambiguous (the card then shows name-only).""" + if not name_or_path: + return "" + p = to_rel(name_or_path) + # Already a path that looks real and ends in .vi + if "/" in p and p.lower().endswith(".vi"): + return p + hits = by_base.get(base_name(name_or_path).lower(), []) + return hits[0] if len(hits) == 1 else "" + + +# Strip common test-naming affixes to guess the VI *under test*: +# "Foo Tests", "Test Foo", "Foo_Test", "TestFoo", "Foo.Test", "Foo UnitTest" +_TEST_AFFIX = re.compile( + r"(^|[\s_\-.])(unit\s*)?tests?($|[\s_\-.])|" + r"^test[\s_\-.]+|[\s_\-.]+test$", + re.IGNORECASE, +) + + +def derive_target(test_name: str, by_base: dict[str, list[str]]) -> tuple[str, str]: + """From a test VI base name, guess the VI under test by stripping a 'test' + affix and resolving the remainder. Returns ('', '') when not derivable.""" + bn = base_name(test_name) + cand = _TEST_AFFIX.sub(" ", bn).strip(" _-.") + cand = re.sub(r"\s{2,}", " ", cand) + if not cand or cand.lower() == bn.lower(): + return "", "" + rel = resolve_vi(cand, by_base) + return (rel, base_name(rel) if rel else cand) if rel else ("", "") + + +# ── JUnit parsing ───────────────────────────────────────────────────────────── +def _text(el) -> str: + return (el.text or "").strip() if el is not None else "" + + +def parse_junit(xml_path: Path, tool: str, by_base: dict[str, list[str]]) -> list[dict]: + """Parse one JUnit XML file into a list of suite dicts for `tool`.""" + try: + root = ET.parse(str(xml_path)).getroot() + except Exception as e: # malformed XML shouldn't sink the whole report + return [{ + "tool": tool, "name": f"(could not parse {xml_path.name})", + "tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0, + "cases": [], "parse_error": str(e), + }] + + suites_el = root.iter("testsuite") if root.tag != "testsuite" else [root] + out: list[dict] = [] + for s in suites_el: + sname = s.get("name") or "(unnamed suite)" + cases = [] + for c in s.findall("testcase"): + name = c.get("name") or "(unnamed test)" + classname = c.get("classname") or "" + file_attr = c.get("file") or "" + time = _to_float(c.get("time")) + + fail = c.find("failure") + err = c.find("error") + skip = c.find("skipped") + if fail is not None: + status, node = "failed", fail + elif err is not None: + status, node = "error", err + elif skip is not None: + status, node = "skipped", skip + else: + status, node = "passed", None + message = (node.get("message") if node is not None else "") or "" + details = _text(node) if node is not None else "" + sysout = _text(c.find("system-out")) + if sysout and status in ("failed", "error") and sysout not in details: + details = (details + "\n\n" + sysout).strip() + + # Resolve the TEST VI (always shown when we can find it) from the + # most specific identifier available: file → classname → name. + test_rel = (resolve_vi(file_attr, by_base) or resolve_vi(classname, by_base) + or resolve_vi(name, by_base)) + test_name = base_name(test_rel) if test_rel else base_name(name) + # Best-effort VI under test (naming convention). + target_rel, target_name = derive_target( + base_name(test_rel) if test_rel else name, by_base) + + cases.append({ + "name": name, + "classname": classname, + "status": status, + "time": time, + "message": clean(message), + "details": clean(details), + "test_vi_rel": test_rel, + "test_vi_name": test_name, + "target_vi_rel": target_rel, + "target_vi_name": target_name, + }) + + out.append({ + "tool": tool, + "name": sname, + "tests": _to_int(s.get("tests"), len(cases)), + "failures": _to_int(s.get("failures"), sum(c["status"] == "failed" for c in cases)), + "errors": _to_int(s.get("errors"), sum(c["status"] == "error" for c in cases)), + "skipped": _to_int(s.get("skipped"), sum(c["status"] == "skipped" for c in cases)), + "time": _to_float(s.get("time")), + "cases": cases, + }) + return out + + +def _to_float(v) -> float: + try: + return round(float(v), 3) + except (TypeError, ValueError): + return 0.0 + + +def _to_int(v, fallback: int) -> int: + try: + return int(float(v)) + except (TypeError, ValueError): + return fallback + + +def clean(text: str) -> str: + text = re.sub(r"[ \t]+\n", "\n", (text or "").replace("\r\n", "\n")) + return text.strip() + + +# ── assemble unified model ──────────────────────────────────────────────────── +def collect_inputs(args) -> list[tuple[str, Path]]: + pairs: list[tuple[str, Path]] = [] + for spec in (args.junit or []): + tool, _, path = spec.partition(":") + if path and tool in TOOLS: + pairs.append((tool, Path(path))) + if args.results and Path(args.results).is_dir(): + for f in sorted(glob.glob(os.path.join(args.results, "*.xml"))): + pairs.append((tool_for_filename(os.path.basename(f)), Path(f))) + return pairs + + +def classify(passed: int, failed: int, errored: int, total: int) -> tuple[str, int]: + if total == 0: + return "empty", 0 + percent = round(100 * passed / total) + if failed == 0 and errored == 0: + return "passed", percent + return "failed", percent + + +def build_data(args) -> dict: + workspace = Path(args.workspace) if args.workspace else None + by_base = index_workspace(workspace) if workspace else {} + + suites: list[dict] = [] + tools_seen: list[str] = [] + for tool, path in collect_inputs(args): + if not path.exists(): + continue + if tool not in tools_seen: + tools_seen.append(tool) + suites.extend(parse_junit(path, tool, by_base)) + + passed = failed = errored = skipped = 0 + duration = 0.0 + for s in suites: + duration += s.get("time", 0.0) + for c in s["cases"]: + if c["status"] == "passed": + passed += 1 + elif c["status"] == "failed": + failed += 1 + elif c["status"] == "error": + errored += 1 + elif c["status"] == "skipped": + skipped += 1 + total = passed + failed + errored + skipped + status, percent = classify(passed, failed, errored, total) + + meta_extra = {} + if args.meta and Path(args.meta).exists(): + try: + meta_extra = json.loads(Path(args.meta).read_text(encoding="utf-8-sig")) + except Exception: + meta_extra = {} + + # Cross-platform tab (Windows report at unit-tests//, Linux one level + # deeper at unit-tests//linux/), mirroring the mass-compile report. + if args.platform == "windows": + platforms = [{"id": "windows", "url": None}, {"id": "linux", "url": "linux/results.json"}] + snap_depth = "../../" + else: + platforms = [{"id": "windows", "url": "../results.json"}, {"id": "linux", "url": None}] + snap_depth = "../../../" + + # Optional marker written by run-unit-tests.ps1 when a configured tool could + # not run because the selected container lacks the required tooling. Drives + # the shared "missing container tooling" banner (see tooling_banner_html). + tooling = {} + res_dir = Path(args.results) if args.results else None + if res_dir and (res_dir / "_tooling.json").exists(): + try: + tooling = json.loads((res_dir / "_tooling.json").read_text(encoding="utf-8-sig")) + except Exception: + tooling = {} + if not isinstance(tooling, dict): + tooling = {} + # PowerShell ConvertTo-Json may emit a single finding as an object rather than + # a 1-element array; normalize so the model always carries a list. + _miss = tooling.get("missing") + tooling["missing"] = [_miss] if isinstance(_miss, dict) else (_miss or []) + + pages_url = (args.pages_url or "").rstrip("/") + return { + "meta": { + "sha": args.sha, + "short": (args.sha or "")[:7], + "platform": args.platform, + "repo": args.repo, + "pages_url": pages_url, + "snap_base": (pages_url + "/vi-snapshots/") if pages_url else (snap_depth + "vi-snapshots/"), + "dash_url": (pages_url + "/") if pages_url else snap_depth, + "labview_version": args.labview_version or meta_extra.get("labview_version", ""), + "tools": [TOOLS[t] for t in tools_seen], + "commit": {"message": args.commit_msg, "author": args.author, "date": args.date}, + "generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + }, + "summary": { + "tests": total, "passed": passed, "failed": failed, "errored": errored, + "skipped": skipped, "percent": percent, "status": status, + "duration": round(meta_extra.get("duration", duration), 1), + }, + "status_order": STATUS_ORDER, + "status_meta": STATUS_META, + "platforms": platforms, + "suites": suites, + "tooling": tooling, + } + + +# ── renderer ────────────────────────────────────────────────────────────────── +def _esc(s) -> str: + return html.escape(str(s if s is not None else ""), quote=True) + + +def tooling_banner_html(missing: list, configure_url: str) -> str: + """Render the site-wide "the selected container is missing this dependency" + banner from a list of {tool,name,kind,detail} entries. + + A COMMON METAPHOR meant for every action's report: the runner writes + /_tooling.json with a `missing` list when a configured tool could not + run for lack of container tooling, and the report renders this banner (in the + body, so it also shows inside the dashboard's iframe where the shared site + header self-suppresses) linking to the Configure Workers dialog. + """ + if isinstance(missing, dict): + missing = [missing] + miss = [x for x in (missing or []) if isinstance(x, dict) and (x.get("kind") in (None, "", "missing-tooling"))] + if not miss: + return "" + names = ", ".join(_esc(x.get("name") or x.get("tool") or "") for x in miss if (x.get("name") or x.get("tool"))) + detail = next((x.get("detail") for x in miss if x.get("detail")), "") + detail_html = f'
{_esc(detail)}
' if detail else "" + cta = (f'' + 'Set up the container') if configure_url else "" + return ( + '' + ) + + +def render(data: dict) -> str: + blob = json.dumps(data, ensure_ascii=False) + blob = blob.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") + m = data.get("meta", {}) or {} + pages = (m.get("pages_url") or "").rstrip("/") + is_linux = m.get("platform") == "linux" + hdr_src = "../../../lvci-header.js" if is_linux else "../../lvci-header.js" + hdr_cfg = { + "context": "unit-tests-report", + "repo": m.get("repo", ""), + "pagesUrl": pages or ("../../.." if is_linux else "../.."), + "sha": m.get("sha", ""), + "short": m.get("short", ""), + "platform": m.get("platform", "windows"), + } + dash = m.get("dash_url") or "" + repo = m.get("repo") or "" + cfg_url = (dash or "") + "configure.html" + ("?repo=" + quote(repo, safe="") if repo else "") + banner = tooling_banner_html((data.get("tooling") or {}).get("missing") or [], cfg_url) + + out = _TEMPLATE.replace("__UT_DATA_JSON__", blob) + out = out.replace("__UT_HEADER_CFG__", json.dumps(hdr_cfg, ensure_ascii=False)) + out = out.replace("__LVCI_HEADER_SRC__", hdr_src) + out = out.replace("__UT_TOOLING_BANNER__", banner) + return out + + +def main() -> None: + ap = argparse.ArgumentParser(description="Build a unified unit-test report from JUnit XML.") + ap.add_argument("--results", default="", help="Directory of JUnit *.xml files (tool inferred from name)") + ap.add_argument("--junit", action="append", default=[], help="Explicit TOOL:PATH (repeatable)") + ap.add_argument("--out", required=True, help="Output directory") + ap.add_argument("--workspace", default="", help="Repo checkout (resolve VI paths)") + ap.add_argument("--platform", default="windows", choices=["windows", "linux"]) + ap.add_argument("--meta", default="", help="meta.json with duration/labview_version") + ap.add_argument("--sha", default="") + ap.add_argument("--repo", default="") + ap.add_argument("--pages-url", dest="pages_url", default="") + ap.add_argument("--commit-msg", dest="commit_msg", default="") + ap.add_argument("--author", default="") + ap.add_argument("--date", default="") + ap.add_argument("--labview-version", dest="labview_version", default="") + args = ap.parse_args() + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + data = build_data(args) + (out_dir / "results.json").write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") + (out_dir / "index.html").write_text(render(data), encoding="utf-8") + s = data["summary"] + print(f"unit-test report: {s['passed']}/{s['tests']} passed ({s['percent']}%) " + f"- {s['failed']} failed - {s['errored']} errored - {s['skipped']} skipped -> {out_dir/'index.html'}") + + +# ── HTML template (self-contained; chrome via lvci-header.js) ───────────────── +_TEMPLATE = r""" + + + + +Unit Tests — LabVIEW CI + + + + + +__UT_TOOLING_BANNER__ +
+

Unit Tests + +

+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+ +
+ + + + + + +""" + + +if __name__ == "__main__": + main() diff --git a/actions/unit-tests/run-unit-tests.ps1 b/actions/unit-tests/run-unit-tests.ps1 new file mode 100644 index 00000000..18a673f8 --- /dev/null +++ b/actions/unit-tests/run-unit-tests.ps1 @@ -0,0 +1,657 @@ +<# +.SYNOPSIS + Run the configured LabVIEW unit-test frameworks headlessly and emit one JUnit + XML file per tool into -ResultsDir. Runs INSIDE the LabVIEW CI container + (same environment as run-vi-analyzer.ps1). Its JUnit output is consumed by + build-unittest-report.py, which merges every *.xml into one friendly report. + +.DESCRIPTION + Reads `config.unitTests.tools[]` from .github/labview-ci.yml. For each enabled + tool it resolves the configured test locations (each a directory to recurse or + a glob / file-extension pattern) and invokes that tool's headless runner via + g-cli (already baked into the CI image), writing JUnit XML. + + Caraya is the reference implementation, driven via g-cli. NI UTF runs through the + built-in LabVIEWCLI RunUnitTests operation (see Invoke-UtfTests); LUnit (Astemes) + runs through the native LabVIEWCLI "LUnit" operation the same way (see + Invoke-LUnitTests); JKI VI Tester is scaffolded with the same contract. The exact + command for each tool is a per-tool + template that can be overridden from the config (`command:` key) so the precise + invocation can be corrected on a real worker without editing this script. + + HEADLESS: LabVIEW must run -Headless in LabVIEW 2026+ Windows containers (same + constraint run-vi-analyzer.ps1 documents) or VI Server fails with -350000. g-cli + launches LabVIEW the same way, so we pass the LabVIEW path/version through. + +.PARAMETER WorkspaceRoot + Absolute path to the checked-out project inside the container. Default C:\workspace. + +.PARAMETER ResultsDir + Directory to write the per-tool JUnit XML into. build-unittest-report.py reads + every *.xml here and infers the tool from the file name (caraya*.xml -> Caraya, + vitester*/vi-tester* -> VI Tester, utf*/unit-test* -> UTF). + +.PARAMETER ConfigPath + Path to labview-ci.yml. Default: \.github\labview-ci.yml. Ignored + when -Framework is set. + +.PARAMETER Framework + When set, bypasses ConfigPath/labview-ci.yml entirely and runs exactly ONE tool: + 'caraya', 'vi-tester', 'utf', 'lunit', or an arbitrary id paired with -Command. + Used by actions/unit-tests (the step-level composite action), which takes the + framework as an action input instead of a repo-committed config file. + +.PARAMETER TestDir + -Framework only: a single test location (directory, glob, or .lvproj), same + contract as a config tool's `locations[0]`. Empty = whole project/workspace. + +.PARAMETER Command + -Framework only: overrides that tool's default command template (tokens + {ver}{dir}{out}{lv}, or {cli}{proj} for utf/lunit). Empty = built-in default. +#> +param( + [string]$WorkspaceRoot = 'C:\workspace', + [string]$ResultsDir = 'C:\workspace\ci-out\unit-tests\results', + [string]$ConfigPath = '', + [string]$LabVIEWVersion = '2026', + [string]$LabVIEWPath = '', + [string]$Framework = '', + [string]$TestDir = '', + [string]$Command = '' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +if (-not $ConfigPath) { $ConfigPath = Join-Path $WorkspaceRoot '.github\labview-ci.yml' } +New-Item -ItemType Directory -Force -Path $ResultsDir | Out-Null + +# Tracks tools that were configured + attempted but could not run because the +# selected container lacks the required tooling (e.g. the NI Unit Test Framework +# toolkit is not installed). Serialized to \_tooling.json so +# build-unittest-report.py can render the shared "container is missing this +# dependency" banner on the report. +$Script:ToolingIssues = @() +function Add-ToolingIssue([string]$tool, [string]$name, [string]$kind, [string]$detail) { + $Script:ToolingIssues += [pscustomobject]@{ tool = $tool; name = $name; kind = $kind; detail = $detail } +} + +# Diagnostic: show where the NI Unit Test Framework toolkit landed and what this +# LabVIEW references. LabVIEW 2023+ loads toolkits from a VERSION-INDEPENDENT +# add-ons folder (C:\Program Files\NI\LVAddons); the UTF MSI deploys via NI's +# NIPaths resolver (logical path LVADDONSDIR64). This prints the add-ons folder +# state + LabVIEW registry so a -350053 'operation could not load' is traceable +# to whether the toolkit is actually visible to this LabVIEW. +function Show-UtfAddonsDiag([string]$LvPath) { + Write-Host '===== UTF / version-independent add-ons diagnostic =====' + $roots = @('C:\Program Files\NI\LVAddons', + 'C:\Program Files (x86)\NI\LVAddons', + 'C:\Program Files\National Instruments\Shared\LabVIEW Addons') + foreach ($r in $roots) { + if (Test-Path -LiteralPath $r) { + Write-Host "ADDONS ROOT: $r" + Get-ChildItem -LiteralPath $r -Recurse -Depth 2 -ErrorAction SilentlyContinue | + Select-Object -First 80 | ForEach-Object { Write-Host " $($_.FullName)" } + } else { + Write-Host "absent: $r" + } + } + # Dump the add-on manifests. LabVIEW 2023+ loads an LVAddon only if its + # lvaddoninfo.json declares compatibility with the running LabVIEW version. + # Compare UTF (RunUnitTests fails) against viawin (VI Analyzer, which works) + # to reveal the version gating that keeps UTF from loading on LabVIEW 2026. + foreach ($mf in @('C:\Program Files\NI\LVAddons\utf64\1\lvaddoninfo.json', + 'C:\Program Files\NI\LVAddons\utf32\1\lvaddoninfo.json', + 'C:\Program Files\NI\LVAddons\viawin\1\lvaddoninfo.json')) { + if (Test-Path -LiteralPath $mf) { + Write-Host "--- manifest: $mf ---" + Get-Content -LiteralPath $mf -Raw -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + } + $lvRoot = if ($LvPath) { Split-Path -Parent $LvPath } else { '' } + if ($lvRoot -and (Test-Path -LiteralPath $lvRoot)) { + Write-Host "LabVIEW root: $lvRoot" + foreach ($sub in @('vi.lib\addons','user.lib','resource\Framework\Providers','project','vi.lib\Unit Test Framework')) { + $p = Join-Path $lvRoot $sub + if (Test-Path -LiteralPath $p) { + $utf = @(Get-ChildItem -LiteralPath $p -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)utf|unit.?test' }) + Write-Host (" {0}: {1} UTF-ish entr(ies) {2}" -f $sub, $utf.Count, (($utf | ForEach-Object { $_.Name }) -join ', ')) + } + } + } + foreach ($rk in @('HKLM:\SOFTWARE\National Instruments\LabVIEW','HKLM:\SOFTWARE\WOW6432Node\National Instruments\LabVIEW')) { + if (Test-Path -LiteralPath $rk) { + Write-Host "REG $rk" + $props = Get-ItemProperty -LiteralPath $rk -ErrorAction SilentlyContinue + if ($props) { $props.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object { Write-Host " $($_.Name) = $($_.Value)" } } + Get-ChildItem -LiteralPath $rk -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " subkey: $($_.PSChildName)" } + } + } + # The -350053 error names the LabVIEW CLI "operation folder" as the place with + # "missing or bad files". Dump it so we can see whether the RunUnitTests operation + # is actually present for this LabVIEW (the UTF version-independent add-on is + # supposed to register it); a missing/broken RunUnitTests operation here is the + # real cause of -350053, independent of any VIPM package. + Write-Host '----- LabVIEW CLI operation folders -----' + foreach ($op in @('C:\Program Files (x86)\National Instruments\Shared\LabVIEW CLI\Operations', + 'C:\Program Files\National Instruments\Shared\LabVIEW CLI\Operations')) { + if (Test-Path -LiteralPath $op) { + Write-Host "OPERATIONS ROOT: $op" + Get-ChildItem -LiteralPath $op -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 200 | ForEach-Object { Write-Host " $($_.FullName)" } + } else { + Write-Host "absent: $op" + } + } + # Where does the UTF add-on keep its RunUnitTests CLI operation, if anywhere? + foreach ($addon in @('C:\Program Files\NI\LVAddons\utf64\1','C:\Program Files\NI\LVAddons\utf32\1')) { + if (Test-Path -LiteralPath $addon) { + $hits = @(Get-ChildItem -LiteralPath $addon -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '(?i)run.?unit.?test|unit.?test.*\.(vi|lvclass)$|cli' }) + Write-Host ("UTF add-on '{0}': {1} CLI/RunUnitTests-ish file(s)" -f $addon, $hits.Count) + $hits | Select-Object -First 50 | ForEach-Object { Write-Host " $($_.FullName)" } + } + } + Write-Host '===== end diagnostic =====' +} + +# -- Resolve LabVIEW / LabVIEWCLI / g-cli (mirror run-vi-analyzer.ps1) ---------- +function Resolve-LabVIEWPath([string]$PreferredPath) { + if ($PreferredPath -and (Test-Path $PreferredPath)) { return $PreferredPath } + $candidates = @(Get-ChildItem 'C:\Program Files\National Instruments' -Directory -Filter 'LabVIEW *' -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName 'LabVIEW.exe' } | + Where-Object { Test-Path $_ }) + if ($candidates.Count -gt 0) { return $candidates[0] } + throw "LabVIEW.exe not found (preferred '$PreferredPath')." +} + +function Resolve-Cmd([string[]]$names) { + foreach ($n in $names) { + $c = Get-Command $n -ErrorAction SilentlyContinue + if ($c -and $c.Source) { return $c.Source } + } + return $null +} + +function Sync-PathFromRegistry { + # VIPM-installed CLIs (e.g. g-cli) add their directory to the MACHINE PATH in the + # registry at install time, but a Windows container's process PATH is baked from + # the image ENV layer and does NOT pick that up (the g-cli docs note you must + # "restart any terminals or build agents after install"). Re-read the persisted + # PATH from the registry and merge in anything missing so Get-Command can see a + # freshly-baked g-cli without an image rebuild. Best-effort: never aborts the run. + try { + $machine = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name 'Path' -ErrorAction SilentlyContinue).Path + $user = (Get-ItemProperty -Path 'HKCU:\Environment' -Name 'Path' -ErrorAction SilentlyContinue).Path + $current = @($env:Path -split ';') + foreach ($raw in @($machine, $user)) { + if (-not $raw) { continue } + foreach ($entry in ([System.Environment]::ExpandEnvironmentVariables($raw) -split ';')) { + $e = $entry.Trim() + if ($e -and ($current -notcontains $e)) { $env:Path = $env:Path.TrimEnd(';') + ';' + $e; $current += $e } + } + } + } catch { Write-Host " (PATH refresh from registry skipped: $($_.Exception.Message))" } +} + +function Resolve-LabVIEWCLI([string]$LabVIEWExePath) { + $cli = Get-Command 'LabVIEWCLI.exe' -ErrorAction SilentlyContinue + if ($null -eq $cli) { $cli = Get-Command 'LabVIEWCLI' -ErrorAction SilentlyContinue } + if ($null -ne $cli -and $cli.Source) { return $cli.Source } + if ($LabVIEWExePath) { + $candidate = Join-Path (Split-Path $LabVIEWExePath) 'LabVIEWCLI.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + return $null +} + +function Resolve-LabVIEWPort([string]$LabVIEWExePath) { + $ini = Join-Path (Split-Path -Parent $LabVIEWExePath) 'LabVIEW.ini' + if (Test-Path -LiteralPath $ini) { + $m = Select-String -LiteralPath $ini -Pattern '^server\.tcp\.port\s*=\s*(\d+)\s*$' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($m -and $m.Matches.Count -gt 0) { return [int]$m.Matches[0].Groups[1].Value } + } + return 3363 +} + +$LabVIEWPath = Resolve-LabVIEWPath $LabVIEWPath +Sync-PathFromRegistry +$GCli = Resolve-Cmd @('g-cli', 'g-cli.exe') +$CliExe = Resolve-LabVIEWCLI $LabVIEWPath +$LabVIEWPort = Resolve-LabVIEWPort $LabVIEWPath + +Write-Host "=== Unit Tests (Windows) ===" +Write-Host " Workspace : $WorkspaceRoot" +Write-Host " Results : $ResultsDir" +Write-Host " LabVIEW : $LabVIEWPath (v$LabVIEWVersion)" +Write-Host " VI Server : $LabVIEWPort" +Write-Host " LabVIEWCLI: $(if ($CliExe) { $CliExe } else { '' })" +Write-Host " g-cli : $(if ($GCli) { $GCli } else { '' })" +Write-Host " Config : $ConfigPath" +Write-Host "" + +# -- Minimal reader for config.unitTests.tools[] ------------------------------- +# Repo convention is regex/state-machine YAML parsing (see the awk parsers in the +# container workflows). We only need this fixed, shallow shape: +# config: +# unitTests: +# tools: +# caraya: +# enabled: true +# command: "" +# locations: +# - "tests/" +# - "**/*.vi" +# vi-tester: +# enabled: false +# locations: [] +# Only tools with `enabled: true` are returned. Blank/empty locations = whole project. +function Read-UnitTestTools([string]$path) { + $tools = @() + if (-not (Test-Path $path)) { return ,$tools } + $inUT = $false; $inTools = $false; $inLoc = $false + $cur = $null + foreach ($raw in (Get-Content -LiteralPath $path)) { + $line = ($raw -replace '\t', ' ') + if ($line -match '^\s*unitTests:\s*$') { $inUT = $true; $inTools = $false; $inLoc = $false; $cur = $null; continue } + if (-not $inUT) { continue } + if ($line -match '^\s{2}tools:\s*$') { $inTools = $true; continue } + + # A tool entry is a map key under tools:, e.g. " caraya:" (4-space indent). + $m = [regex]::Match($line, '^\s{4}([A-Za-z0-9_.-]+):\s*$') + if ($inTools -and $m.Success) { + if ($cur -and $cur.enabled) { $tools += $cur } + $cur = [ordered]@{ tool = $m.Groups[1].Value.ToLower(); enabled = $false; command = ''; locations = @() } + $inLoc = $false + continue + } + if ($null -ne $cur) { + if ($line -match '^\s{6}enabled:\s*(true|false)\s*$') { $cur.enabled = ($Matches[1] -eq 'true'); $inLoc = $false; continue } + $cm = [regex]::Match($line, '^\s{6}command:\s*"?(.+?)"?\s*$') + if ($cm.Success) { $cur.command = $cm.Groups[1].Value; $inLoc = $false; continue } + if ($line -match '^\s{6}locations:\s*\[\s*\]\s*$') { $cur.locations = @(); $inLoc = $false; continue } + if ($line -match '^\s{6}locations:\s*$') { $inLoc = $true; continue } + if ($inLoc) { + $lm = [regex]::Match($line, '^\s{8}-\s*"?([^"]+?)"?\s*$') + if ($lm.Success) { $cur.locations += $lm.Groups[1].Value; continue } + } + } + # A top-level key (column 0) ends the unitTests block. + if ($line -match '^\S') { + if ($cur -and $cur.enabled) { $tools += $cur } + $cur = $null; $inUT = $false; $inTools = $false; $inLoc = $false + } + } + if ($cur -and $cur.enabled) { $tools += $cur } + return ,$tools +} + +# -- Resolve a tool's locations (dir OR glob/extension) to scan-root directories - +function Resolve-TestRoots([string[]]$locations) { + $roots = New-Object System.Collections.Generic.List[string] + foreach ($loc in $locations) { + if (-not $loc) { continue } + $rel = ($loc -replace '/', '\') + $full = Join-Path $WorkspaceRoot $rel + if (Test-Path -LiteralPath $full -PathType Container) { + $roots.Add((Resolve-Path -LiteralPath $full).Path); continue + } + # Glob / extension: expand and take each match's parent directory. + try { + $matches = Get-ChildItem -Path (Join-Path $WorkspaceRoot $rel) -Recurse -File -ErrorAction SilentlyContinue + foreach ($f in $matches) { $roots.Add($f.DirectoryName) } + } catch { } + } + return ($roots | Sort-Object -Unique) +} + +# -- Per-tool default g-cli command templates --------------------------------- +# Tokens: {ver}=LabVIEW year, {dir}=a resolved test-root directory, {out}=JUnit +# output path, {lv}=LabVIEW.exe path. A `command:` in the tool's config overrides +# the default. THESE DEFAULTS ARE BEST-EFFORT and must be confirmed on a real +# worker (the exact g-cli plugin name + flags per tool); the override key exists +# so that confirmation needs no script change. +$DEFAULT_CMD = @{ + # Caraya CLI g-cli extension (lvos_lib_caraya_cli_extension): run every Caraya + # test under a directory and export JUnit. Reference implementation. + 'caraya' = 'g-cli --lv-ver {ver} -- caraya -- --directory "{dir}" --junit "{out}"' + # JKI VI Tester via sas_workshops_lib_vitester_for_g_cli (scaffold). + 'vi-tester' = 'g-cli --lv-ver {ver} -- vitester -- --directory "{dir}" --junit "{out}"' +} + +# NI Unit Test Framework runs the .lvtest files of a PROJECT (not a flat directory of +# test VIs), so UTF has its own runner (Invoke-UtfTests) rather than the generic +# {dir} template. The default uses the FIRST-PARTY LabVIEWCLI RunUnitTests operation +# (built into the LabVIEW CLI in the container): it runs every unit test in the +# project and writes a JUnit report to -JUnitReportPath. -Headless is required for +# LabVIEW 2026 Windows containers (mirrors run-vi-analyzer / RunVIAnalyzer). +# Tokens: {cli}=LabVIEWCLI, {lv}=LabVIEW.exe, {proj}=.lvproj path, {out}=JUnit output +# path, {ver}=LabVIEW year. Override per tool with the config `command:` key. +$UTF_DEFAULT_CMD = '"{cli}" -LogToConsole TRUE -OperationName RunUnitTests -ProjectPath "{proj}" -JUnitReportPath "{out}" -LabVIEWPath "{lv}" -Headless' + +# LUnit (Astemes' xUnit-style framework) is driven the SAME WAY as NI UTF: through +# the native LabVIEW CLI, not g-cli. Its `astemes_lib_lunit_cli` package registers +# the "LUnit" operation, which discovers Test Case classes under -Path and writes a +# JUnit report when -ReportPath ends in .xml. Unlike UTF, -Path accepts a directory +# (or project/class/library), so LUnit resolves test-root DIRECTORIES like the +# g-cli tools rather than .lvproj files. -Headless is required on LabVIEW 2026 +# Windows containers (same constraint as UTF / VI Analyzer). Tokens: {cli}=LabVIEWCLI, +# {lv}=LabVIEW.exe, {dir}=a resolved test-root directory, {out}=JUnit output path, +# {ver}=LabVIEW year. Override per tool with the config `command:` key. +$LUNIT_DEFAULT_CMD = '"{cli}" -LogToConsole TRUE -OperationName LUnit -Path "{dir}" -ReportPath "{out}" -LabVIEWPath "{lv}" -Headless' + +function Invoke-Tool($tool, [int]$index) { + $id = $tool.tool + $tmpl = if ($tool.command) { $tool.command } elseif ($DEFAULT_CMD.ContainsKey($id)) { $DEFAULT_CMD[$id] } else { '' } + $locs = @($tool.locations | Where-Object { $_ -and $_.Trim() }) + # Empty locations means "the whole project" (the config page documents this). + $roots = if ($locs.Count -gt 0) { Resolve-TestRoots $locs } else { @($WorkspaceRoot) } + + Write-Host "--- tool: $id ---" + if ($roots.Count -eq 0) { Write-Warning " no test locations resolved for '$id' (locations: $($tool.locations -join ', ')) - skipping."; return } + if (-not $tmpl) { Write-Warning " no headless command known for '$id' yet (set config.unitTests.tools[].command to enable) - skipping."; return } + if (-not $GCli -and $tmpl -match '(^|\s)g-cli(\s|$)') { Write-Warning " g-cli not found on PATH; cannot run '$id'."; return } + + $i = 0 + foreach ($dir in $roots) { + $out = Join-Path $ResultsDir ("{0}-{1}.xml" -f $id, ($index * 100 + $i)) + $cmd = $tmpl.Replace('{ver}', $LabVIEWVersion).Replace('{dir}', $dir).Replace('{out}', $out).Replace('{lv}', $LabVIEWPath) + Write-Host " [$id] $cmd" + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { + & cmd.exe /c $cmd 2>&1 | Out-Host + Write-Host (" [$id] exit={0}" -f $LASTEXITCODE) + } catch { + Write-Warning " [$id] runner error: $($_.Exception.Message)" + } + $ErrorActionPreference = $prevEAP + if (Test-Path -LiteralPath $out) { Write-Host " [$id] wrote $out" } + else { Write-Warning " [$id] produced no JUnit at $out (check the command/plugin for this tool)." } + $i++ + } +} + +# -- NI Unit Test Framework (UTF) --------------------------------------------- +# UTF tests live as .lvtest files inside a .lvproj, and RunUnitTests runs a whole +# PROJECT, so we must resolve the tool's locations to the owning .lvproj(s). This +# is deliberately robust to however a repo is laid out - a location may be: +# * a .lvproj path -> run that project; +# * a directory that CONTAINS one or more test-bearing .lvproj -> run each; +# * a directory of .lvtest files whose .lvproj lives ABOVE it -> walk parents +# up to the workspace root and run the nearest owning project(s); +# * empty -> discover EVERY test-bearing .lvproj anywhere in +# the repo and run them all. +# Only projects that actually reference UTF tests are kept, so we never launch +# LabVIEW for nothing, and CI tooling folders (.github, ci-out, build, .git) are +# always excluded. +function Test-ProjHasUtfTests([string]$projPath) { + $txt = Get-Content -LiteralPath $projPath -Raw -ErrorAction SilentlyContinue + return [bool]($txt -and ($txt -match 'Type="TestItem"' -or $txt -match '\.lvtest')) +} + +function Resolve-UtfProjects([string[]]$locations) { + $found = New-Object System.Collections.Generic.List[string] + $exclRe = '(?i)[\\/](\.github|ci-out|build|\.git)[\\/]' + $wsFull = (Resolve-Path -LiteralPath $WorkspaceRoot).Path + + # No explicit locations => discover every test-bearing project in the repo. + $locList = @($locations | Where-Object { $_ -and $_.Trim() }) + if ($locList.Count -eq 0) { + Get-ChildItem -LiteralPath $wsFull -Recurse -File -Filter '*.lvproj' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch $exclRe -and (Test-ProjHasUtfTests $_.FullName) } | + ForEach-Object { $found.Add($_.FullName) } + return ($found | Sort-Object -Unique) + } + + foreach ($loc in $locList) { + $full = Join-Path $WorkspaceRoot ($loc -replace '/', '\') + + # (a) the location is itself a .lvproj -> run it directly. + if ((Test-Path -LiteralPath $full -PathType Leaf) -and ($full -match '\.lvproj$')) { + $found.Add((Resolve-Path -LiteralPath $full).Path) + continue + } + + # (b) the location is a directory or glob -> resolve to concrete roots. + foreach ($root in @(Resolve-TestRoots @($loc))) { + if (-not (Test-Path -LiteralPath $root)) { continue } + + # (b1) downward: test-bearing .lvproj at or under the location. + $downward = @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.lvproj' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch $exclRe -and (Test-ProjHasUtfTests $_.FullName) }) + if ($downward.Count -gt 0) { + $downward | ForEach-Object { $found.Add($_.FullName) } + continue + } + + # (b2) upward: the location holds .lvtest files but the owning .lvproj + # lives above it. Walk parents up to the workspace root and take the + # nearest ancestor that has a test-bearing project. + $hasTests = @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.lvtest' -ErrorAction SilentlyContinue).Count -gt 0 + if (-not $hasTests) { continue } + $dir = (Resolve-Path -LiteralPath $root).Path + while ($dir) { + $up = @(Get-ChildItem -LiteralPath $dir -File -Filter '*.lvproj' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch $exclRe -and (Test-ProjHasUtfTests $_.FullName) }) + if ($up.Count -gt 0) { $up | ForEach-Object { $found.Add($_.FullName) }; break } + if ($dir -eq $wsFull) { break } + $parent = Split-Path -Parent $dir + if (-not $parent -or $parent -eq $dir -or $parent.Length -lt $wsFull.Length) { break } + $dir = $parent + } + } + } + + return ($found | Sort-Object -Unique) +} + +function Invoke-UtfTests($tool, [int]$index) { + $id = $tool.tool + Write-Host "--- tool: $id (NI Unit Test Framework) ---" + $projects = @(Resolve-UtfProjects $tool.locations) + if ($projects.Count -eq 0) { + Write-Warning " no UTF project (.lvproj containing .lvtest) found for locations: $($tool.locations -join ', ') - skipping." + return + } + if (-not $CliExe) { Write-Warning " LabVIEWCLI not found; cannot run UTF."; return } + + Show-UtfAddonsDiag $LabVIEWPath + + $tmpl = if ($tool.command) { $tool.command } else { $UTF_DEFAULT_CMD } + + $i = 0 + foreach ($proj in $projects) { + $out = Join-Path $ResultsDir ("utf-{0}.xml" -f ($index * 100 + $i)) + Write-Host " [utf] project: $proj" + + # RunUnitTests writes the JUnit report directly to -JUnitReportPath ({out}). + $cmd = $tmpl.Replace('{cli}', $CliExe).Replace('{lv}', $LabVIEWPath).Replace('{proj}', $proj).Replace('{out}', $out).Replace('{ver}', $LabVIEWVersion) + Write-Host " [utf] $cmd" + + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + $cliOut = '' + try { + $cliOut = (& cmd.exe /c $cmd 2>&1 | Out-String) + Write-Host $cliOut + Write-Host (" [utf] exit={0}" -f $LASTEXITCODE) + } catch { + Write-Warning " [utf] runner error: $($_.Exception.Message)" + } + $ErrorActionPreference = $prevEAP + + if (Test-Path -LiteralPath $out) { Write-Host " [utf] wrote $out" } + else { + Write-Warning " [utf] produced no JUnit at $out (check the RunUnitTests output above; override with the tool's command: key)." + # The LabVIEWCLI console error (e.g. -350053) is generic; the actual + # detail (which VI is broken / which module is missing) is written to + # the CLI's own session log. Echo it so failures are diagnosable. + $m = [regex]::Match($cliOut, '(?i)started logging in file:\s*(.+\.log)') + if ($m.Success) { + $logPath = $m.Groups[1].Value.Trim() + Write-Host " [utf] --- LabVIEW CLI session log ($logPath) ---" + if (Test-Path -LiteralPath $logPath) { + Get-Content -LiteralPath $logPath | ForEach-Object { Write-Host " [utf-log] $_" } + } else { + Write-Host " [utf] (session log not found on disk)" + } + Write-Host " [utf] --- end LabVIEW CLI session log ---" + } else { + Write-Host " [utf] (no CLI session-log path found in output)" + } + # DIAGNOSTIC PROBE: re-run the SAME operation WITHOUT -JUnitReportPath. If it + # then loads/succeeds, the -350053 is specific to the JUnit-report step (its + # VIs), not the operation; if it still fails, the RunUnitTests operation cannot + # load at all in this LabVIEW. Output-only; does not affect the report. + $cmdNoJUnit = $tmpl.Replace('{cli}', $CliExe).Replace('{lv}', $LabVIEWPath).Replace('{proj}', $proj).Replace('{out}', $out).Replace('{ver}', $LabVIEWVersion) + $cmdNoJUnit = $cmdNoJUnit -replace '\s*-JUnitReportPath\s+"[^"]*"', '' + Write-Host " [utf][diag] retry WITHOUT -JUnitReportPath:" + Write-Host " [utf][diag] $cmdNoJUnit" + $prevEAP2 = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { + $diagOut = (& cmd.exe /c $cmdNoJUnit 2>&1 | Out-String) + Write-Host $diagOut + Write-Host (" [utf][diag] exit={0}" -f $LASTEXITCODE) + } catch { Write-Warning " [utf][diag] runner error: $($_.Exception.Message)" } + $ErrorActionPreference = $prevEAP2 + # Record that UTF could not run, so the report shows the shared + # "missing container tooling" banner. -350053 / "missing or bad files" + # / "required modules or toolkits" => the UTF toolkit is absent. + if (-not ($Script:ToolingIssues | Where-Object { $_.tool -eq 'utf' })) { + $missingTooling = ($cliOut -match '350053' -or $cliOut -match 'missing or bad files' -or $cliOut -match 'required modules or toolkits') + if ($missingTooling) { + Add-ToolingIssue 'utf' 'NI Unit Test Framework' 'missing-tooling' 'The NI Unit Test Framework toolkit is not installed in this container, so the LabVIEW CLI RunUnitTests operation could not load (error -350053).' + } else { + Add-ToolingIssue 'utf' 'NI Unit Test Framework' 'error' 'The RunUnitTests operation produced no JUnit output.' + } + } + } + $i++ + } +} + +# -- LUnit (Astemes) ---------------------------------------------------------- +# LUnit tests are Test Case classes (.lvclass) discovered under a directory or +# project. We resolve the tool's locations to test-root DIRECTORIES (empty = +# whole project) and run the native LabVIEWCLI "LUnit" operation against each, +# writing one JUnit XML per root. Mirrors Invoke-UtfTests' diagnostics: it echoes +# the LabVIEW CLI session log on failure and records a missing-tooling finding so +# a worker without the astemes_lib_lunit_cli package surfaces the shared +# "missing container tooling" banner (instead of a bare "no tests found"). +function Invoke-LUnitTests($tool, [int]$index) { + $id = $tool.tool + Write-Host "--- tool: $id (LUnit) ---" + if (-not $CliExe) { Write-Warning " LabVIEWCLI not found; cannot run LUnit."; return } + + $locs = @($tool.locations | Where-Object { $_ -and $_.Trim() }) + $roots = if ($locs.Count -gt 0) { Resolve-TestRoots $locs } else { @($WorkspaceRoot) } + if ($roots.Count -eq 0) { + Write-Warning " no test locations resolved for '$id' (locations: $($tool.locations -join ', ')) - skipping." + return + } + + $tmpl = if ($tool.command) { $tool.command } else { $LUNIT_DEFAULT_CMD } + + $i = 0 + foreach ($dir in $roots) { + $out = Join-Path $ResultsDir ("lunit-{0}.xml" -f ($index * 100 + $i)) + Write-Host " [lunit] path: $dir" + + $cmd = $tmpl.Replace('{cli}', $CliExe).Replace('{lv}', $LabVIEWPath).Replace('{dir}', $dir).Replace('{out}', $out).Replace('{ver}', $LabVIEWVersion) + Write-Host " [lunit] $cmd" + + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + $cliOut = '' + try { + $cliOut = (& cmd.exe /c $cmd 2>&1 | Out-String) + Write-Host $cliOut + Write-Host (" [lunit] exit={0}" -f $LASTEXITCODE) + } catch { + Write-Warning " [lunit] runner error: $($_.Exception.Message)" + } + $ErrorActionPreference = $prevEAP + + if (Test-Path -LiteralPath $out) { Write-Host " [lunit] wrote $out" } + else { + Write-Warning " [lunit] produced no JUnit at $out (check the LUnit output above; override with the tool's command: key)." + # Echo the LabVIEW CLI session log (the console error is generic; the + # real detail lives in the CLI's own log), same as Invoke-UtfTests. + $m = [regex]::Match($cliOut, '(?i)started logging in file:\s*(.+\.log)') + if ($m.Success) { + $logPath = $m.Groups[1].Value.Trim() + Write-Host " [lunit] --- LabVIEW CLI session log ($logPath) ---" + if (Test-Path -LiteralPath $logPath) { + Get-Content -LiteralPath $logPath | ForEach-Object { Write-Host " [lunit-log] $_" } + } else { + Write-Host " [lunit] (session log not found on disk)" + } + Write-Host " [lunit] --- end LabVIEW CLI session log ---" + } else { + Write-Host " [lunit] (no CLI session-log path found in output)" + } + # -350053 / "missing or bad files" / "required modules or toolkits" => + # the LUnit CLI add-on (astemes_lib_lunit_cli) is not installed in this + # container, so the "LUnit" operation could not load. + if (-not ($Script:ToolingIssues | Where-Object { $_.tool -eq 'lunit' })) { + $missingTooling = ($cliOut -match '350053' -or $cliOut -match 'missing or bad files' -or $cliOut -match 'required modules or toolkits') + if ($missingTooling) { + Add-ToolingIssue 'lunit' 'LUnit' 'missing-tooling' 'The LUnit CLI toolkit (astemes_lib_lunit_cli) is not installed in this container, so the LabVIEW CLI LUnit operation could not load (error -350053).' + } else { + Add-ToolingIssue 'lunit' 'LUnit' 'error' 'The LUnit operation produced no JUnit output.' + } + } + } + $i++ + } +} + +# -- Main --------------------------------------------------------------------- +# actions/unit-tests passes the framework selection as ENVIRONMENT VARIABLES +# (docker --env-file) rather than as -Framework/-TestDir/-Command arguments, +# because Windows PowerShell 5.1 mangles such values on a native command line: an +# empty one is dropped (breaking parameter binding) and embedded double quotes - +# which every default command template contains, e.g. --junit "{out}" - are +# stripped. An explicit parameter still wins, so the script stays directly +# invokable with -Framework for local runs and other callers. +if (-not $Framework -and $env:LVCI_FRAMEWORK) { $Framework = $env:LVCI_FRAMEWORK } +if (-not $TestDir -and $env:LVCI_TEST_DIR) { $TestDir = $env:LVCI_TEST_DIR } +if (-not $Command -and $env:LVCI_COMMAND) { $Command = $env:LVCI_COMMAND } + +# -Framework runs a single tool straight from parameters (no config file / YAML +# parsing involved), which is what actions/unit-tests uses. Otherwise fall back +# to the repo's own config.unitTests.tools[] as before. +if ($Framework) { + $tools = @([ordered]@{ tool = $Framework.ToLower(); enabled = $true; command = $Command; locations = @($TestDir) }) +} else { + $tools = Read-UnitTestTools $ConfigPath +} +if (-not $tools -or $tools.Count -eq 0) { + Write-Warning "No config.unitTests.tools configured in $ConfigPath - nothing to run." + Write-Host "Wrote 0 JUnit file(s) to $ResultsDir." + exit 0 +} + +Write-Host ("Configured tools: {0}" -f (($tools | ForEach-Object { $_.tool }) -join ', ')) +Write-Host "" + +$idx = 0 +foreach ($t in $tools) { + if ($t.tool -eq 'utf') { Invoke-UtfTests $t $idx } + elseif ($t.tool -eq 'lunit') { Invoke-LUnitTests $t $idx } + else { Invoke-Tool $t $idx } + $idx++; Write-Host "" +} + +$xml = @(Get-ChildItem -Path $ResultsDir -Filter '*.xml' -File -ErrorAction SilentlyContinue) +Write-Host "=== Unit Tests finished: wrote $($xml.Count) JUnit file(s) to $ResultsDir ===" +# Persist any "container is missing this tooling" findings for the report builder. +$toolingPath = Join-Path $ResultsDir '_tooling.json' +if ($Script:ToolingIssues.Count -gt 0) { + (@{ missing = @($Script:ToolingIssues) } | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $toolingPath -Encoding ascii + Write-Host "Recorded $($Script:ToolingIssues.Count) missing-tooling finding(s) -> $toolingPath" +} +# Always exit 0: pass/fail is derived from the JUnit content by +# build-unittest-report.py (its summary.json drives the commit status), exactly +# like the Mass Compile report. A runner-level error surfaces as a missing/empty +# report, not a hard CI failure here. +exit 0 diff --git a/actions/worker-image/Dockerfile b/actions/worker-image/Dockerfile new file mode 100644 index 00000000..e203df38 --- /dev/null +++ b/actions/worker-image/Dockerfile @@ -0,0 +1,68 @@ +# escape=` +# ============================================================================= +# LabVIEW CI final worker image +# ============================================================================= +# Starts from the public LCWC base image, then applies only this repository's +# optional VIPC layer and worker-version labels. Repositories with no VIPC files +# can skip this build and simply tag/push the base image as their own worker. +# ============================================================================= +ARG LCWC_BASE_IMAGE=ghcr.io/elijah286/labview-ci-with-containers-labview-base:2026 +FROM ${LCWC_BASE_IMAGE} + +SHELL ["powershell", "-NoLogo", "-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] + +# Worker version: a short content hash of the build inputs (this Dockerfile + +# install-vipc.ps1 + any applied *.vipc), computed by the build workflow and +# passed in here. It is stamped into the image (env + label) so any CI job can +# read back exactly which worker it pulled and link to that worker's manifest on +# the dashboard. Defaults to 'dev' for local/ad-hoc builds. +ARG CI_WORKER_VERSION=dev + +# VIPC automation assets. install-vipc.ps1 plus any *.vipc are staged here; the +# build workflow also copies repo-root *.vipc (e.g. "COTC Dependencies.vipc") +# into .github/labview/vipm/ before the build, so "a repo that features a .vipc" +# gets that configuration baked into the Windows worker automatically. With no +# .vipc staged the VIPM hook below is a no-op. +COPY .github/labview/vipm/ C:/vipm/ + +# Optional VIPC support hook. If .vipc files exist, an installer script must be +# present so dependencies are handled explicitly. +RUN $vipcFiles = Get-ChildItem -Path 'C:\vipm' -Filter '*.vipc' -Recurse -ErrorAction SilentlyContinue; ` + if ($vipcFiles -and $vipcFiles.Count -gt 0) { ` + if (Test-Path 'C:\vipm\install-vipc.ps1') { ` + Write-Host 'VIPC files detected. Running C:\vipm\install-vipc.ps1 ...'; ` + powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File 'C:\vipm\install-vipc.ps1' ` + } else { ` + throw 'VIPC files were detected in C:\vipm but install-vipc.ps1 was not provided.' ` + } ` + } else { ` + Write-Host 'No VIPC dependencies were provided. Skipping VIPM install hook.' ` + } + +# Optional Dragon support hook. Project *.dragon (JKI Dragon / NIPM) files are +# applied with the Dragon CLI that the base image already provides. This is +# BEST-EFFORT: a failure logs a warning but never fails the worker build, because +# the exact headless Dragon apply invocation can vary by environment. A repo can +# stage its own install-dragon.ps1 to override the default `dragon apply` per file. +RUN $dragonFiles = Get-ChildItem -Path 'C:\vipm' -Filter '*.dragon' -Recurse -ErrorAction SilentlyContinue; ` + if ($dragonFiles -and $dragonFiles.Count -gt 0) { ` + if (Test-Path 'C:\vipm\install-dragon.ps1') { ` + Write-Host 'Dragon files detected. Running C:\vipm\install-dragon.ps1 ...'; ` + powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File 'C:\vipm\install-dragon.ps1' ` + } else { ` + foreach ($d in $dragonFiles) { ` + Write-Host ('Applying Dragon dependency file (best-effort): ' + $d.FullName); ` + try { dragon apply $d.FullName } catch { Write-Host ('::warning::Dragon apply failed for ' + $d.Name + ': ' + $_) } ` + } ` + } ` + } else { ` + Write-Host 'No Dragon dependencies were provided. Skipping Dragon install hook.' ` + } + +# Stamp the worker version so any consuming CI job can read it back from the +# pulled image (docker inspect / env) and link the dashboard to this worker's +# published manifest. ENV survives into `docker run`; LABEL is queryable without +# starting a container. +ENV CI_WORKER_VERSION=${CI_WORKER_VERSION} +LABEL com.cotc.ci-worker.version=${CI_WORKER_VERSION} ` + com.cotc.ci-worker.platform=windows diff --git a/actions/worker-image/action.yml b/actions/worker-image/action.yml new file mode 100644 index 00000000..7999f22a --- /dev/null +++ b/actions/worker-image/action.yml @@ -0,0 +1,302 @@ +# Composite action: LabVIEW Worker Image (create / update / no-op) +# +# Builds (or reuses) the LabVIEW CI worker image a repo's other actions +# (unit-tests, vi-analyzer, ...) pull and run against. Unlike those actions, +# which only ever `docker pull`, this one actually produces the image. +# +# Create / update / no-op, not "always rebuild": every input that affects the +# image's content (the base image's digest, this action's own Dockerfile + +# install-vipc.ps1, and whatever .vipc/.dragon/.vip files get staged) is hashed +# into a deterministic content tag (`win-<12 hex>`; same inputs -> same tag, +# always). Before building, the action checks via `crane digest` whether THAT +# EXACT tag already exists in the registry: +# - not found & the image has never been published -> CREATE +# - not found because content changed since last run -> UPDATE (rebuild) +# - found -> NO-OP (just retag +# the floating tags onto the existing digest; no docker build/push) +# This existence check is the one genuinely new mechanism here — the content +# hash scheme itself mirrors build-labview-image.yml's "Compute worker +# version" step, which computes the same fingerprint but (today) only ever +# uses it to NAME an unconditional rebuild, never to skip one. +# +# This action always acts as a CLIENT of the shared LCWC base image (reads its +# digest via crane; never builds/publishes the base itself via +# labview-ci-base.Dockerfile). Publishing that shared base remains the source +# tooling repo's own internal concern. +# +# Windows-only for now (mirrors masscompile/vi-analyzer/unit-tests/build). +# Linux support (a genuinely different Dockerfile/base-layering shape, not +# just a shell swap) is left for a later action if a real second consumer +# shows up. +# +# No .github/labview-ci.yml config-file reading (no monitor/monitorOn/ +# rebuildPolicy parsing). The caller passes an explicit `deps-dir` input +# instead — a directory it has already staged .vipc/.dragon/.vip files into +# (via actions/checkout + its own step, same as any other composite action +# here). Whether/when to even call this action (on push, on a schedule, gated +# by a diff) is entirely the caller workflow's decision. +# +# Bundled files must be kept in sync with their vendored originals: +# cp /.github/docker/labview-ci.Dockerfile actions/worker-image/Dockerfile +# cp /.github/labview/vipm/install-vipc.ps1 actions/worker-image/install-vipc.ps1 +# cp /.github/labview/vipm/ci-tooling.vipc actions/worker-image/ci-tooling.vipc +# cp /.github/labview/ensure-docker.ps1 actions/worker-image/ensure-docker.ps1 +# +# The caller's workflow must grant `permissions: packages: write` (GHCR push) +# and, if using `deps-dir`, must have already checked out the repo. +name: 'LabVIEW Worker Image' +description: 'Create, update, or no-op the shared LabVIEW CI worker image in GHCR.' +author: 'labview-ci' + +inputs: + image: + description: > + Target image name, WITHOUT registry/owner prefix or tag (e.g. 'myrepo-labview'). + Empty = auto-derive '-labview' from the calling repository. + required: false + default: '' + labview-version: + description: 'LabVIEW year. Used as a floating tag and passed through to install-vipc.ps1.' + required: false + default: '2026' + base-image: + description: 'The shared LCWC base image to build the VIPC layer on top of.' + required: false + default: 'ghcr.io/elijah286/labview-ci-with-containers-labview-base:2026' + deps-dir: + description: > + Directory (relative to the workspace) already staged with .vipc/.dragon/.vip + files to bake in. Empty = no project-specific dependencies (ci-tooling only). + required: false + default: '' + include-ci-tooling: + description: > + Bake this action's bundled ci-tooling.vipc (Caraya / VI Tester / LUnit CLI / + UTF essentials) in alongside anything in deps-dir. Set 'false' only if the + caller wants a worker with no unit-test framework tooling at all. + required: false + default: 'true' + tag-latest: + description: 'Also move a floating :latest tag onto the current content.' + required: false + default: 'true' + +outputs: + image: + description: 'Resolved image name (registry + owner + name, no tag).' + value: ${{ steps.resolve.outputs.image }} + tag: + description: 'The deterministic content-hash tag for this build (win-<12hex>).' + value: ${{ steps.ver.outputs.version }} + digest: + description: 'Manifest digest of the published (or already-existing) image.' + value: ${{ steps.build.outputs.digest || steps.check.outputs.digest }} + built: + description: "'true' if a build+push actually ran, 'false' if the no-op path was taken." + value: ${{ steps.check.outputs.outdated }} + +runs: + using: 'composite' + steps: + - name: Resolve image name + id: resolve + shell: bash + env: + IMAGE_OVERRIDE: ${{ inputs.image }} + run: | + owner=$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]') + name="${IMAGE_OVERRIDE:-${GITHUB_REPOSITORY##*/}-labview}" + name=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]') + echo "image=ghcr.io/${owner}/${name}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + # windows-2022 runners intermittently start a job before the Docker engine + # is up (actions/runner-images#14252, #13888). Wait for/start it before the + # first real docker-daemon interaction (the build below); crane and docker + # login don't necessarily touch the local engine, so this is the first step + # that would actually surface the problem. + - name: Ensure Docker daemon is running + shell: powershell + run: '& "${{ github.action_path }}\ensure-docker.ps1"' + + # The bundled Dockerfile is byte-identical to .github/docker/labview-ci.Dockerfile, + # including its `COPY .github/labview/vipm/ C:/vipm/` — so the scratch build + # context must reproduce that EXACT relative path (not just a top-level + # 'vipm' folder), or Docker's COPY source never exists in the context. + - name: Stage build context + id: stage + shell: pwsh + run: | + $ctx = Join-Path $env:RUNNER_TEMP 'worker-image-ctx' + $vipmDir = Join-Path $ctx '.github\labview\vipm' + New-Item -ItemType Directory -Force -Path $vipmDir | Out-Null + + # Copy the Dockerfile INTO the context (not just reference it from the + # action path) so `docker build -f` always points at a file within the + # context being built, matching the vendored workflow's proven layout. + Copy-Item -Path (Join-Path '${{ github.action_path }}' 'Dockerfile') -Destination $ctx -Force + + Copy-Item -Path (Join-Path '${{ github.action_path }}' 'install-vipc.ps1') -Destination $vipmDir -Force + + if ('${{ inputs.include-ci-tooling }}' -eq 'true') { + Copy-Item -Path (Join-Path '${{ github.action_path }}' 'ci-tooling.vipc') -Destination $vipmDir -Force + } + + $depsDir = '${{ inputs.deps-dir }}' + if ($depsDir) { + $depsPath = Join-Path '${{ github.workspace }}' $depsDir + if (Test-Path $depsPath) { + Get-ChildItem -Path $depsPath -File | Where-Object { $_.Extension -in '.vipc', '.dragon', '.vip' } | + ForEach-Object { + Copy-Item -Path $_.FullName -Destination $vipmDir -Force + Write-Host "Staged: $($_.Name)" + } + } else { + Write-Warning "deps-dir '$depsDir' not found at $depsPath; skipping." + } + } + + "ctx=$ctx" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "vipm_dir=$vipmDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # crane (go-containerregistry) reads/copies/tags registry content without + # pulling multi-GB images to the runner. Same tool/version/source as + # build-labview-image.yml's "Set up crane" step. + - name: Set up crane + shell: pwsh + run: | + $ver = 'v0.20.2' + $url = "https://github.com/google/go-containerregistry/releases/download/$ver/go-containerregistry_Windows_x86_64.tar.gz" + $tgz = Join-Path $env:RUNNER_TEMP 'crane.tar.gz' + $dir = Join-Path $env:RUNNER_TEMP 'crane' + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Invoke-WebRequest -Uri $url -OutFile $tgz -UseBasicParsing + tar -xzf $tgz -C $dir + "$dir" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + & (Join-Path $dir 'crane.exe') version + + - name: Resolve base image digest + id: base + shell: pwsh + run: | + $baseImage = '${{ inputs.base-image }}' + $digest = (crane digest $baseImage 2>$null) + if (-not $digest) { + throw "Could not read base image '$baseImage' from the registry. Confirm it is public/readable or set 'base-image' to a readable base image." + } + "digest=$digest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Base image: $baseImage digest: $digest" + + # Worker version = short SHA-256 over the base image ref+digest, this + # action's own Dockerfile, and every file staged into vipm/ (which + # already includes install-vipc.ps1 and, by default, ci-tooling.vipc). + # Same inputs -> same tag, deterministically -- this IS the fingerprint + # the existence check below queries the registry for. + - name: Compute worker version + id: ver + shell: pwsh + run: | + $vipmDir = '${{ steps.stage.outputs.vipm_dir }}' + $files = @(Get-ChildItem -Path $vipmDir -File | Sort-Object Name) + + $dockerfileHash = (Get-FileHash -LiteralPath (Join-Path '${{ github.action_path }}' 'Dockerfile') -Algorithm SHA256).Hash.ToLower() + $parts = @( + "base-image=${{ inputs.base-image }}", + "base-digest=${{ steps.base.outputs.digest }}", + "Dockerfile=$dockerfileHash" + ) + foreach ($f in $files) { + $h = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash.ToLower() + $parts += ('{0}={1}' -f $f.Name, $h) + } + + $bytes = [System.Text.Encoding]::UTF8.GetBytes(($parts -join "`n")) + $digestBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) + $hex = -join ($digestBytes | ForEach-Object { $_.ToString('x2') }) + $version = 'win-' + $hex.Substring(0, 12) + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Computed worker version: $version" + + # THE existence/staleness check. Success = this exact content was already + # published under this tag (no-op: just move the floating tags onto it). + # Failure = never published before, either because the image is new or + # because the content changed since the last build (create/update). + - name: Check if already up to date + id: check + shell: pwsh + run: | + $image = '${{ steps.resolve.outputs.image }}' + $version = '${{ steps.ver.outputs.version }}' + $existingDigest = (crane digest "${image}:${version}" 2>$null) + # A "not found" here is an EXPECTED, non-error outcome (it's how we detect + # create/update), but crane still exits non-zero for it, and PowerShell + # cmdlets below (Write-Host/Out-File) never touch $LASTEXITCODE - so + # without this reset, GitHub Actions' pwsh wrapper would fail this step + # every time the answer is "needs building" (i.e. every create/update). + $LASTEXITCODE = 0 + if ($existingDigest) { + Write-Host "::notice::'${image}:${version}' already exists (digest $existingDigest). Nothing changed since the last build; skipping." + "outdated=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "digest=$existingDigest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + $moveTags = @('${{ inputs.labview-version }}') + if ('${{ inputs.tag-latest }}' -eq 'true') { $moveTags += 'latest' } + foreach ($t in $moveTags) { + crane tag "${image}:${version}" $t + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Retagged ${image}:${t} -> ${version}" + } + } else { + Write-Host "::notice::'${image}:${version}' not found; building." + "outdated=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - name: Build and push + id: build + if: steps.check.outputs.outdated == 'true' + shell: pwsh + run: | + $image = '${{ steps.resolve.outputs.image }}' + $version = '${{ steps.ver.outputs.version }}' + $baseImage = '${{ inputs.base-image }}' + $vipmDir = '${{ steps.stage.outputs.vipm_dir }}' + $ctx = '${{ steps.stage.outputs.ctx }}' + + $tags = @("${image}:${version}", "${image}:${{ inputs.labview-version }}") + if ('${{ inputs.tag-latest }}' -eq 'true') { $tags += "${image}:latest" } + + $depCount = @(Get-ChildItem -Path $vipmDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in '.vipc', '.dragon' }).Count + + if ($depCount -eq 0) { + Write-Host "::notice::No .vipc/.dragon dependencies staged; publishing as a registry copy of the base image." + crane copy $baseImage "${image}:${version}" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($t in $tags[1..($tags.Count - 1)]) { + $tagOnly = ($t -split ':')[-1] + crane tag "${image}:${version}" $tagOnly + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + } else { + $tagFlags = ($tags | ForEach-Object { '-t', $_ }) + docker build -f (Join-Path $ctx 'Dockerfile') ` + --build-arg LCWC_BASE_IMAGE=$baseImage ` + --build-arg CI_WORKER_VERSION=$version @tagFlags $ctx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($t in $tags) { + Write-Host "Pushing $t" + docker push $t + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + } + + $finalDigest = (crane digest "${image}:${version}" 2>$null) + $LASTEXITCODE = 0 + "digest=$finalDigest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Published ${image}:${version} (digest $finalDigest)" diff --git a/actions/worker-image/ci-tooling.vipc b/actions/worker-image/ci-tooling.vipc new file mode 100644 index 00000000..23311672 Binary files /dev/null and b/actions/worker-image/ci-tooling.vipc differ diff --git a/actions/worker-image/ensure-docker.ps1 b/actions/worker-image/ensure-docker.ps1 new file mode 100644 index 00000000..da68f770 --- /dev/null +++ b/actions/worker-image/ensure-docker.ps1 @@ -0,0 +1,45 @@ +# Ensure the Docker engine is up before a workflow uses it. +# +# The GitHub-hosted windows-2022 runner pool is currently intermittent: some VMs +# start a job before the Docker engine is running, or with the "docker" service +# not yet registered (see actions/runner-images#14252 and #13888). Failing on the +# first `docker` call turns that transient runner hiccup into a red build, so this +# script waits for the engine to respond -- starting, or if necessary registering, +# the service first -- and only fails (with an actionable message) if Docker never +# becomes available. On a healthy runner it returns within a second. +$ErrorActionPreference = 'Continue' + +function Test-DockerUp { + docker version --format '{{.Server.Version}}' 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) +} + +$deadlineSeconds = 180 +$deadline = (Get-Date).AddSeconds($deadlineSeconds) +$up = $false + +while ((Get-Date) -lt $deadline) { + if (Test-DockerUp) { $up = $true; break } + + $svc = Get-Service -Name docker -ErrorAction SilentlyContinue + if ($null -eq $svc) { + # The service is not registered on this VM yet; register dockerd if present. + if (Get-Command dockerd -ErrorAction SilentlyContinue) { + Write-Host 'Docker service not registered on this runner; registering dockerd...' + & dockerd --register-service 2>$null + } + $svc = Get-Service -Name docker -ErrorAction SilentlyContinue + } + if ($svc -and $svc.Status -ne 'Running') { + Write-Host "Starting the docker service (current status: $($svc.Status))..." + try { Start-Service docker -ErrorAction Stop } catch { Write-Host "Start-Service docker failed: $($_.Exception.Message)" } + } + Start-Sleep -Seconds 5 +} + +if (-not $up) { + throw "Docker engine did not become available on this windows-2022 runner within $deadlineSeconds seconds. GitHub's hosted Windows runner pool is currently, intermittently, starting VMs without a running Docker daemon (actions/runner-images#14252, #13888). Re-run this job to land on a healthy runner, or use a self-hosted Windows runner with Docker (Windows containers)." +} + +docker version +Write-Host 'Docker engine is ready.' diff --git a/actions/worker-image/install-vipc.ps1 b/actions/worker-image/install-vipc.ps1 new file mode 100644 index 00000000..beb13c67 --- /dev/null +++ b/actions/worker-image/install-vipc.ps1 @@ -0,0 +1,1050 @@ +<# +.SYNOPSIS + Installs VIPM and applies all .vipc dependency files found in C:\vipm. + This script runs INSIDE the Docker build container (Windows Server Core). + + Used to bake third-party VIPM add-ons into the CI image -- e.g. Antidoc + (wovalab_lib_antidoc_cli), Wovalab's LabVIEW code-documentation generator, + which is distributed only through VIPM and is the supported way to produce + project documentation headlessly in CI/CD. + +.NOTES + These values can be overridden at image-build time via environment variables + so the script does not need editing for each LabVIEW major version: + LABVIEW_VERSION LabVIEW year passed to `vipm install`; MUST match the + LabVIEW in the NI base image. Default: 2026. + LABVIEW_BITNESS LabVIEW bitness passed to `vipm install`. Default: 64. + VIPM_INSTALLER_URL VIPM community installer (https://vipm.jki.net) for a + VIPM build that supports LABVIEW_VERSION. + + Headless install model: the vipm CLI installs packages in Community Edition + (no VIPM Pro license needed) -- the script sets VIPM_COMMUNITY_EDITION and + NO_COLOR for unattended runs (the CLI is non-interactive by default). It also + launches LabVIEW headless before installing, because vipm requires a running + LabVIEW or it fails with "IO error: Failed to load". VIPM Pro activation is + still honored if VIPM_SERIAL_NUMBER / VIPM_FULL_NAME / VIPM_EMAIL are supplied. +#> + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$VipmDir = 'C:\Program Files\JKI\VI Package Manager' +$VipmExe = $null +$VipcDir = 'C:\vipm' +$LabVIEWVersion = if ($Env:LABVIEW_VERSION) { $Env:LABVIEW_VERSION } else { '2026' } # match the LabVIEW version in the NI base image +$LabVIEWBitness = if ($Env:LABVIEW_BITNESS) { $Env:LABVIEW_BITNESS } else { '64' } # NI base image ships 64-bit LabVIEW +$VipmInstallerUrl = if ($Env:VIPM_INSTALLER_URL) { $Env:VIPM_INSTALLER_URL } else { 'https://traffic.libsyn.com/secure/jkinc/vipm-26.3.3954-windows-setup.exe' } +# VIPM 26.3 Community Edition only installs packages when the working directory is +# inside a PUBLIC Git repository (otherwise it exits 6 with "VIPM Community Edition +# requires a public Git repository"). The worker image is built from this public +# repo, so we run the installs from a tiny working dir whose origin remote points +# at it. Override with VIPM_PUBLIC_REPO_URL (the build workflow passes the actual +# building repo's clone URL so forks use their own public repo). +$PublicRepoUrl = if ($Env:VIPM_PUBLIC_REPO_URL) { $Env:VIPM_PUBLIC_REPO_URL } else { 'https://github.com/elijah286/LabVIEW-CI-with-Containers.git' } + +# Run VIPM non-interactively so headless installs need no prompts. We deliberately +# do NOT set VIPM_COMMUNITY_EDITION here: forcing Community Edition mode turns ON +# VIPM's public-Git-repository entitlement gate (exit 6, "VIPM Community Edition +# requires a public Git repository"), which blocks installs inside the sealed +# `docker build` layer. The CLI already runs as Community Edition by default WITHOUT +# enforcing that gate, so installs proceed and no VIPM Pro license is needed. +# (If VIPM_COMMUNITY_EDITION=1 is supplied externally we honor it, and the MinGit + +# public-repo .git context below then satisfies the gate.) These env vars are read +# by the modern vipm CLI; older CLIs ignore them harmlessly. +$Env:VIPM_NONINTERACTIVE = '1' +$Env:VIPM_ASSUME_YES = '1' +$Env:NO_COLOR = '1' +# Turn on VIPM's verbose debug log so a failing build records WHY an install +# fails - e.g. why `vipm refresh` reports success yet `vipm install ` +# returns exit 3 "package not found" (an empty resolver index), and why applying +# the .vipc file returns Code 42. Overridable: set VIPM_DEBUG=0 to quiet it once +# the install path is proven. See docs.vipm.io/latest/cli/environment-variables. +$Env:VIPM_DEBUG = if ($null -ne $Env:VIPM_DEBUG -and $Env:VIPM_DEBUG -ne '') { $Env:VIPM_DEBUG } else { '1' } +# Make VIPM treat this `docker build` step as a CI environment. The official VIPM +# docs note the CLI auto-detects CI from these env vars and then uses its longer, +# CI-tuned default timeouts and non-interactive behavior; during `docker build` +# none of them are set, so VIPM falls back to short desktop defaults that can +# abort a cold headless LabVIEW. (VIPM_TIMEOUT still overrides the actual value.) +if (-not $Env:CI) { $Env:CI = 'true' } +if (-not $Env:GITHUB_ACTIONS) { $Env:GITHUB_ACTIONS = 'true' } +# VIPM has "several ways" to decide a repo is public, INCLUDING the environment +# (per JKI). In GitHub Actions these are set automatically, but inside `docker +# build` they are absent, so derive them from the public repo URL and export them +# for VIPM's environment-based public-repo detection. (Owner/name parsed from +# https://github.com//.git.) Do not clobber values already present. +if ($PublicRepoUrl -match 'github\.com[:/]+(?[^/]+)/(?[^/]+?)(?:\.git)?/?$') { + if (-not $Env:GITHUB_SERVER_URL) { $Env:GITHUB_SERVER_URL = 'https://github.com' } + if (-not $Env:GITHUB_REPOSITORY) { $Env:GITHUB_REPOSITORY = "$($Matches.owner)/$($Matches.name)" } + if (-not $Env:GITHUB_REPOSITORY_OWNER) { $Env:GITHUB_REPOSITORY_OWNER = $Matches.owner } +} +# Bound the per-operation timeout. During `docker build` the GITHUB_ACTIONS / CI +# env vars are NOT present, so VIPM does not apply its longer "CI" default timeouts +# and its short defaults (check_for_updates ~270s, library_list ~330s) can abort a +# cold, first-run headless LabVIEW before it finishes responding. VIPM_TIMEOUT +# overrides the default/CI-adjusted timeout, in seconds. +# See docs.vipm.io/latest/cli/environment-variables. +$Env:VIPM_TIMEOUT = if ($Env:VIPM_TIMEOUT) { $Env:VIPM_TIMEOUT } else { '900' } + +# VIPM 26.3 Community Edition shells out to a real `git` binary to verify that the +# working directory is a PUBLIC Git repository (see New-PublicRepoWorkdir below). The +# Windows base image has no git on PATH; labview-ci.Dockerfile bakes portable MinGit +# into C:\git, so make sure git is discoverable by vipm's child process. Without this +# vipm fails with "Cannot determine repository visibility: ... git: program not found". +foreach ($gitDir in @('C:\git\cmd', 'C:\Program Files\Git\cmd')) { + if ((Test-Path (Join-Path $gitDir 'git.exe')) -and ($Env:Path -notlike "*$gitDir*")) { + $Env:Path = "$gitDir;$Env:Path" + } +} + +# -- 1. Install VIPM if not already present ----------------------------------- +# VIPM is normally pre-installed into the image by labview-ci.Dockerfile, which +# downloads the official VIPM 2026 Q3 (26.3.3954) Windows installer from the JKI +# CDN and runs it silently, so this script just finds vipm.exe and applies the +# .vipc. If it is NOT already present we fall back to downloading the same +# installer here ($VipmInstallerUrl, overridable via VIPM_INSTALLER_URL). That +# fallback is OPTIONAL and fetched from a vendor-controlled URL that can move or +# 404 at any time, so a download/install failure must NOT brick the core CI image +# (LabVIEW + VI Analyzer were installed above). Treat the fallback as best-effort: +# on failure, warn and skip the add-ons (exit 0) instead of failing the build. +# Prefer the MODERN vipm CLI (C:\Program Files\JKI\VIPM) over the legacy +# LabVIEW-based CLI (C:\Program Files\JKI\VI Package Manager\...). The modern CLI +# has first-class headless/container support (--refresh, Community Edition mode) +# and installs packages without a VIPM Pro license. +$vipmCandidates = @( + 'C:\Program Files\JKI\VIPM\vipm.exe', + 'C:\Program Files (x86)\JKI\VIPM\vipm.exe', + "$VipmDir\vipm.exe", + "$VipmDir\support\vipm.exe" +) +$VipmExe = $vipmCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $VipmExe) { + # Fall back to a recursive search of the JKI install roots, preferring any + # path under a '\VIPM\' folder (the modern CLI) over the legacy product folder. + $found = Get-ChildItem -Path 'C:\Program Files\JKI', 'C:\Program Files (x86)\JKI' ` + -Filter 'vipm.exe' -Recurse -ErrorAction SilentlyContinue | + Sort-Object @{ Expression = { $_.FullName -notmatch '\\VIPM\\' } }, FullName | + Select-Object -First 1 + if ($found) { $VipmExe = $found.FullName } +} +if ($VipmExe) { Write-Host "Using VIPM CLI: $VipmExe" } +if (-not $VipmExe -or -not (Test-Path $VipmExe)) { + Write-Host 'VIPM not found - downloading installer...' + $InstallerFile = Join-Path $Env:TEMP 'vipm-installer.exe' + try { + Invoke-WebRequest -Uri $VipmInstallerUrl -OutFile $InstallerFile -UseBasicParsing + + Write-Host 'Running VIPM installer silently...' + $p = Start-Process -FilePath $InstallerFile ` + -ArgumentList '/exenoui', '/qn' ` + -Wait -PassThru + if ($p.ExitCode -ne 0) { + throw "VIPM installer exited with code $($p.ExitCode)" + } + Write-Host "VIPM installed to: $VipmDir" + $VipmExe = $vipmCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $VipmExe) { $VipmExe = "$VipmDir\vipm.exe" } + } + catch { + Write-Warning ("VIPM add-on install SKIPPED: could not install VIPM from '" + $VipmInstallerUrl + "' (" + $_.Exception.Message + "). " + + "Core image (LabVIEW + VI Analyzer) is unaffected; VIPM-only add-ons such as Antidoc are NOT baked in. " + + "Provide a reachable VIPM_INSTALLER_URL to enable them.") + exit 1 + } +} + +# -- 2. Apply each .vipc file ------------------------------------------------- +$vipcFiles = @(Get-ChildItem $VipcDir -Filter '*.vipc') +if ($vipcFiles.Count -eq 0) { + Write-Host 'No .vipc files found - nothing to apply.' + exit 0 +} + +# Native VIPM commands below emit to stderr on normal progress; do not let that +# abort the script - we drive control flow off $LASTEXITCODE instead. +$ErrorActionPreference = 'Continue' + +# Diagnostics: record which VIPM CLI we have. The 'ni-vipm' build baked into this +# image is the modern VIPM CLI (2024+), which no longer has the legacy 'apply_vipc' +# verb, and whose 'install' verb is unreliable at applying a .vipc FILE headlessly +# (Pro-activation / interactive prompts). So instead of applying the .vipc file, we +# read the package list out of its config.xml and install each package BY NAME +# using the documented 'vipm install @' form - the reliable path +# that needs no VIPM Pro activation (verified against VIPM 2026 Free Edition). The +# .vipc itself is a real, VIPM-openable VIPC (build-tooling-vipc.py harvests each +# package's real spec+icon from the public indexes), so a human can still open and +# edit it in VIPM; CI just doesn't depend on VIPM to parse it. +& $VipmExe --version 2>&1 | Out-Host +& $VipmExe about 2>&1 | Out-Host + +# Optional VIPM Pro activation. With VIPM_COMMUNITY_EDITION=1 set above, headless +# installs work WITHOUT a Pro license, so activation is optional. If the +# VIPM_SERIAL_NUMBER / VIPM_FULL_NAME / VIPM_EMAIL build secrets are supplied we +# still activate Pro (best-effort: a failure here does not stop the build). +if ($Env:VIPM_SERIAL_NUMBER) { + Write-Host 'Activating VIPM Pro from VIPM_SERIAL_NUMBER ...' + & $VipmExe activate ` + --serial-number $Env:VIPM_SERIAL_NUMBER ` + --name $Env:VIPM_FULL_NAME ` + --email $Env:VIPM_EMAIL 2>&1 | Out-Host +} else { + Write-Host 'VIPM_SERIAL_NUMBER not set; using VIPM Community Edition (no Pro license required).' +} + +# The modern vipm CLI requires LabVIEW to be RUNNING (headless) before it can +# install/build packages -- otherwise it fails to load with "IO error: Failed to +# load". The Docker build step that calls this script does NOT have LabVIEW +# running, so launch it headless in the background now and wait for the VI Server +# port (default 3363) to come up. Best-effort: if LabVIEW can't be found/started +# we still attempt the install (it may already be running). +$LabVIEWProc = $null +$lvExe = @( + 'C:\Program Files\National Instruments', + 'C:\Program Files (x86)\National Instruments' +) | Where-Object { Test-Path $_ } | + ForEach-Object { Get-ChildItem -Path $_ -Directory -Filter 'LabVIEW*' -ErrorAction SilentlyContinue } | + ForEach-Object { Join-Path $_.FullName 'LabVIEW.exe' } | + Where-Object { Test-Path $_ } | Select-Object -First 1 + +# The vipm CLI reads C:\ProgramData\JKI\VIPM\Settings.ini for its target LabVIEW +# configuration and ABORTS with "IO error: Failed to load ...Settings.ini ... +# (os error 2)" if that file is missing. In a fresh image VIPM was never launched +# interactively, so the file does not exist. Seed a minimal Settings.ini that +# points the CLI at the image's LabVIEW (so `--labview-version ` resolves) +# before any install. Only create it if absent so a real VIPM never gets clobbered. +$VipmSettingsDir = 'C:\ProgramData\JKI\VIPM' +$VipmSettings = Join-Path $VipmSettingsDir 'Settings.ini' +if ($lvExe -and -not (Test-Path $VipmSettings)) { + try { + $fi = (Get-Item $lvExe).VersionInfo + $ver = '{0}.{1} ({2}-bit)' -f $fi.ProductMajorPart, $fi.ProductMinorPart, $LabVIEWBitness + # INI wants the exe path in "/C/Program Files/.../LabVIEW.exe" form. + $lvIni = '/' + (($lvExe -replace ':', '') -replace '\\', '/') + $settingsText = @" +[General] +IsFirstLaunch="FALSE" + +[Targets] +Names.="1" +Names 0="LabVIEW" +Versions.="1" +Versions 0="$ver" +Locations.="1" +Locations 0="$lvIni" +Ports=" 3363" +Tested.="1" +Tested 0="TRUE" +Disabled.="1" +Disabled 0="FALSE" +Connection Timeout="120" +Active Target.Name="LabVIEW" +Active Target.Version="$ver" +CommunityEdition.="1" +CommunityEdition 0="TRUE" +"@ + New-Item -ItemType Directory -Path $VipmSettingsDir -Force | Out-Null + Set-Content -Path $VipmSettings -Value $settingsText -Encoding ASCII + Write-Host "Seeded VIPM Settings.ini for target: LabVIEW $ver" + } catch { + Write-Warning ("Could not seed VIPM Settings.ini (" + $_.Exception.Message + "); vipm install may fail to load.") + } +} +# Launch headless LabVIEW for VIPM. Factored into a function so a wedged VIPM +# stack can be torn down and relaunched mid-build (see Restart-VipmStack) instead +# of failing the whole image on a transient cold-start race. +function Start-HeadlessLabVIEW { + if (-not $lvExe) { + Write-Warning 'LabVIEW.exe not found; attempting VIPM install without pre-launching LabVIEW.' + return + } + Write-Host "Launching headless LabVIEW for VIPM: $lvExe" + try { + $script:LabVIEWProc = Start-Process -FilePath $lvExe -ArgumentList '--headless' -PassThru + $deadline = (Get-Date).AddSeconds(180) + $ready = $false + while ((Get-Date) -lt $deadline) { + try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect('127.0.0.1', 3363) + if ($client.Connected) { $client.Close(); $ready = $true; break } + } catch { Start-Sleep -Seconds 3 } + } + if ($ready) { Write-Host 'Headless LabVIEW VI Server is ready (port 3363).' } + else { Write-Warning 'Timed out waiting for LabVIEW VI Server (port 3363); attempting VIPM install anyway.' } + } catch { + Write-Warning ("Could not launch headless LabVIEW (" + $_.Exception.Message + "); attempting VIPM install anyway.") + } +} + +# The vipm CLI does not install packages itself -- it delegates to the VIPM "engine" +# application (VI Package Manager.exe, the LabVIEW-runtime VIPM app). When that engine +# is not already running the CLI tries to start it and BLOCKS on "wait for VIPM +# startup"; in a fresh headless container that startup never completed, so +# `vipm install` aborted after the full VIPM_TIMEOUT ("Operation 'wait for VIPM +# startup' timed out after 900s"). Locally the install works only because the VIPM +# engine is already running. Pre-launch the engine here (best-effort) and give it +# time to come up so the install can attach to an already-running engine. +$VipmEngineProc = $null +$script:VipmEngineExe = @( + (Join-Path $VipmDir 'VI Package Manager.exe'), + 'C:\Program Files (x86)\JKI\VI Package Manager\VI Package Manager.exe' +) | Where-Object { Test-Path $_ } | Select-Object -First 1 +function Start-VipmEngineProcess { + if (-not $script:VipmEngineExe) { return } + if (Get-Process -Name 'VI Package Manager' -ErrorAction SilentlyContinue) { return } + Write-Host "Pre-launching VIPM engine so the CLI can attach: $script:VipmEngineExe" + try { + $script:VipmEngineProc = Start-Process -FilePath $script:VipmEngineExe -PassThru -ErrorAction Stop + # Give the LabVIEW-runtime engine time to initialize before the first install. + Start-Sleep -Seconds 45 + Write-Host 'VIPM engine launch requested (allowed 45s to initialize).' + } catch { + Write-Warning ("Could not pre-launch the VIPM engine (" + $_.Exception.Message + "); the CLI will try to start it itself.") + } +} + +# -- VIPM engine crash-recovery budget ---------------------------------------- +# A cold headless VIPM engine occasionally never finishes its startup handshake, +# so the FIRST 'vipm install' burns the full VIPM_TIMEOUT and the engine stays +# wedged for the rest of the build (build 28951187926 failed exactly this way, yet +# the identical code succeeded on the very next run -- a transient cold-start race). +# Rather than fail the whole image on that transient, tear the stack down and +# relaunch it, then retry the install. Bounded by a build-wide budget so a +# genuinely-broken engine still fails fast instead of hanging for hours. Override +# with VIPM_MAX_ENGINE_RESTARTS (0 disables recovery, restoring the old +# fail-fast-on-first-wedge behavior). +$script:VipmMaxEngineRestarts = if ($Env:VIPM_MAX_ENGINE_RESTARTS -match '^\d+$') { [int]$Env:VIPM_MAX_ENGINE_RESTARTS } else { 2 } +$script:VipmEngineRestartsUsed = 0 + +# Kill the whole VIPM stack (CLI, engine, headless LabVIEW) and relaunch it, then +# clear the wedged flag so the caller can retry. If the relaunched engine wedges +# again the next 'vipm install' re-sets the flag and the budget check stops the loop. +function Restart-VipmStack { + param([int] $Attempt) + Write-Warning (" VIPM engine wedged; restarting the VIPM stack (attempt " + $Attempt + "/" + $script:VipmMaxEngineRestarts + ") and retrying ...") + foreach ($procName in @('vipm', 'VI Package Manager', 'LabVIEW', 'LabVIEWCLI')) { + Get-Process -Name $procName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + $script:LabVIEWProc = $null + $script:VipmEngineProc = $null + # Let the killed processes release the VI Server port (3363) and file locks. + Start-Sleep -Seconds 10 + Start-HeadlessLabVIEW + Start-VipmEngineProcess + $script:VipmEngineDead = $false +} + +# Launch the VIPM stack for the first time. +Start-HeadlessLabVIEW +Start-VipmEngineProcess + +# NOTE: this vipm CLI (2026.1.0) has NO standalone 'refresh' command; the package +# list is refreshed via the global '--refresh' option passed to 'install' below. + +# Read the package list out of a .vipc's config.xml and return install specs. +# The config.xml lists each package as 'pkg_name-1.2.3.4...'; +# the modern 'vipm install' wants 'pkg_name@1.2.3.4' (the hyphen form is misread as +# a file path). VIPM IDs may carry a trailing '-' build suffix (e.g. +# 'jki_labs_tool_vi_tester-1.1.2.164-1', 'jki_rsc_toolkits_palette-1.1-1'); that +# suffix is dropped here (install resolves the dotted version). Names without a +# trailing dotted version (e.g. 'jki_vi_tester') install the latest available. +function Get-VipcPackageSpecs([string]$VipcPath) { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + $zip = [System.IO.Compression.ZipFile]::OpenRead($VipcPath) + try { + $entry = $zip.Entries | Where-Object { $_.Name -eq 'config.xml' } | Select-Object -First 1 + if (-not $entry) { return @() } + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { [xml]$cfg = $reader.ReadToEnd() } finally { $reader.Close() } + } finally { $zip.Dispose() } + $names = @($cfg.VI_Package_Configuration.Target.Package | ForEach-Object { $_.Name }) + $specs = foreach ($n in $names) { + if ([string]::IsNullOrWhiteSpace($n)) { continue } + if ($n -match '^(?.+)-(?\d+(?:\.\d+)+)(?:-\d+)?$') { '{0}@{1}' -f $Matches.n, $Matches.v } else { $n.Trim() } + } + return @($specs) +} + +function Split-VipmPackageSpec([string] $Spec) { + $s = ([string]$Spec).Trim() + $aliases = @{ + # Older VIPC files can use the short/legacy VI Tester name, while the + # public VIPM repository indexes expose the package under this ID. + 'jki_vi_tester' = 'jki_labs_tool_vi_tester' + } + if ($s -match '^(?[^@]+)@(?.+)$') { + $name = $Matches.name.Trim() + if ($aliases.ContainsKey($name)) { $name = $aliases[$name] } + return [pscustomobject]@{ Name = $name; Version = $Matches.version.Trim(); Minimum = $false } + } + if ($s -match '^(?[A-Za-z0-9_\.\-]+)\s*>\=\s*(?.+)$') { + $name = $Matches.name.Trim() + if ($aliases.ContainsKey($name)) { $name = $aliases[$name] } + return [pscustomobject]@{ Name = $name; Version = $Matches.version.Trim(); Minimum = $true } + } + if ($aliases.ContainsKey($s)) { $s = $aliases[$s] } + return [pscustomobject]@{ Name = $s; Version = ''; Minimum = $false } +} + +function Get-NumericVersionKey([string] $Version) { + $nums = @([regex]::Matches(([string]$Version), '\d+') | ForEach-Object { [int]$_.Value }) + while ($nums.Count -lt 6) { $nums += 0 } + return ($nums[0..5] | ForEach-Object { '{0:D8}' -f $_ }) -join '.' +} + +function ConvertFrom-VipmRepositoryIndex([string] $IndexPath, [string] $BaseUrl, [string] $Name) { + $packages = New-Object System.Collections.Generic.List[object] + $current = $null + foreach ($line in Get-Content -LiteralPath $IndexPath -ErrorAction Stop) { + if ($line -match '^\[Package\s+(?.+)\]\s*$') { + if ($current) { $packages.Add($current) } + $id = $Matches.id.Trim() + $pkgName = $id + $pkgVersion = '' + if ($id -match '^(?.+)-(?\d+(?:\.\d+)+(?:[A-Za-z0-9_.-]*)?)$') { + $pkgName = $Matches.name + $pkgVersion = $Matches.version + } + $current = [ordered]@{ + Id = $id + Name = $pkgName + Version = $pkgVersion + VersionKey = Get-NumericVersionKey $pkgVersion + Repository = $Name + BaseUrl = $BaseUrl + PackageUrl = '' + PackageMD5 = '' + Dependencies = '' + } + continue + } + if (-not $current) { continue } + if ($line -match '^(?[^=]+)=(?.*)$') { + $key = $Matches.key.Trim() + $value = $Matches.value.Trim() + switch ($key) { + 'Package.URL' { $current.PackageUrl = $value } + 'Package.MD5' { $current.PackageMD5 = $value.ToLowerInvariant() } + 'Dependencies.Requires' { $current.Dependencies = $value } + } + } + } + if ($current) { $packages.Add($current) } + return @($packages | ForEach-Object { [pscustomobject]$_ }) +} + +function Get-PublicVipmRepositoryPackages { + $repoDir = Join-Path $env:TEMP 'vipm-public-indexes' + New-Item -ItemType Directory -Force -Path $repoDir | Out-Null + $repos = @( + [pscustomobject]@{ + Name = 'NI LabVIEW Tools Network' + Url = 'http://download.ni.com/evaluation/labview/lvtn/vipm/index.vipr' + BaseUrl = 'http://download.ni.com/evaluation/labview/lvtn/vipm/' + FileName = 'ni-lvtn.vipr' + }, + [pscustomobject]@{ + Name = 'VIPM Community' + Url = 'http://www.jkisoft.com/packages/jkisoft.ogpd' + BaseUrl = 'http://www.jkisoft.com/packages/' + FileName = 'vipm-community.ogpd' + } + ) + $all = New-Object System.Collections.Generic.List[object] + foreach ($repo in $repos) { + $indexFile = Join-Path $repoDir $repo.FileName + Write-Host "Downloading public VIPM repository index: $($repo.Url)" + Invoke-WebRequest -Uri $repo.Url -OutFile $indexFile -UseBasicParsing -TimeoutSec 120 | Out-Null + foreach ($pkg in (ConvertFrom-VipmRepositoryIndex $indexFile $repo.BaseUrl $repo.Name)) { $all.Add($pkg) } + } + Write-Host "Loaded $($all.Count) package versions from public VIPM indexes." + return @($all.ToArray()) +} + +function Resolve-PublicVipmPackageUrl($Package) { + $url = [string]$Package.PackageUrl + if ($url -match '^https?://') { return $url } + if ($url -match '^packages/') { return ([string]$Package.BaseUrl).TrimEnd('/') + '/' + $url } + if ($url -match '^sf://opengtoolkit/(?[^/]+)$') { + $file = $Matches.file + if ($Package.Name -match '^oglib_(?.+)$') { + $lib = $Matches.lib + $major = if ($Package.Version -match '^(?\d+)\.') { $Matches.major } else { '4' } + return "https://downloads.sourceforge.net/project/opengtoolkit/lib_$lib/$major.x/$file`?download" + } + } + if ($url -match '^sf://(?[^/]+)/(?[^/]+)$') { + return "https://downloads.sourceforge.net/project/$($Matches.project)/$($Matches.file)`?download" + } + if ($url) { return ([string]$Package.BaseUrl).TrimEnd('/') + '/' + $url.TrimStart('/') } + return '' +} + +function Select-PublicVipmPackage($Request, [object[]] $Packages) { + $matches = @($Packages | Where-Object { $_.Name -eq $Request.Name }) + if ($matches.Count -eq 0) { return $null } + if ($Request.Version) { + if ($Request.Minimum) { + $minKey = Get-NumericVersionKey $Request.Version + $matches = @($matches | Where-Object { $_.VersionKey -ge $minKey }) + } else { + $matches = @($matches | Where-Object { $_.Version -eq $Request.Version }) + } + } + return @($matches | Sort-Object VersionKey -Descending | Select-Object -First 1)[0] +} + +function Get-PublicVipmDependencyRequests($Package) { + $deps = @() + $text = [string]$Package.Dependencies + if ([string]::IsNullOrWhiteSpace($text)) { return @() } + foreach ($part in ($text -split ',')) { + $p = $part.Trim() + if ($p -match '^(?[A-Za-z0-9_\.\-]+)\s*(?>=|=|==)?\s*(?[A-Za-z0-9_.\-]+)?') { + $op = [string]$Matches.op + $deps += [pscustomobject]@{ + Name = $Matches.name.Trim() + Version = if ($Matches.version) { $Matches.version.Trim() } else { '' } + Minimum = ($op -eq '>=' -or -not $op) + } + } + } + return @($deps) +} + +function Save-PublicVipmPackage($Package, [string] $OutDir) { + New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + $ext = '.vip' + if ([string]$Package.PackageUrl -match '\.(?vip|ogp)(?:$|\?)') { $ext = '.' + $Matches.ext } + $fileName = '{0}-{1}{2}' -f $Package.Name, $Package.Version, $ext + $outFile = Join-Path $OutDir $fileName + if (Test-Path $outFile) { return $outFile } + $url = Resolve-PublicVipmPackageUrl $Package + if (-not $url) { throw "No downloadable package URL found for $($Package.Id) from $($Package.Repository)." } + Write-Host " Downloading $($Package.Id) from $url" + Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300 -Headers @{ 'User-Agent' = 'LabVIEW-CI-with-Containers VIPM downloader' } | Out-Null + $bytes = Get-Content -LiteralPath $outFile -Encoding Byte -TotalCount 4 + if ($bytes.Count -lt 4 -or $bytes[0] -ne 0x50 -or $bytes[1] -ne 0x4b) { + throw "Downloaded file for $($Package.Id) is not a VIP/ZIP archive: $outFile" + } + if ($Package.PackageMD5) { + $md5 = (Get-FileHash -LiteralPath $outFile -Algorithm MD5).Hash.ToLowerInvariant() + if ($md5 -ne $Package.PackageMD5) { throw "MD5 mismatch for $($Package.Id): expected $($Package.PackageMD5), got $md5" } + } + return $outFile +} + +# Index the local .vip package files staged into the worker (C:\vipm) so a +# VIPC-referenced package can be installed FROM THE COMMITTED .vip instead of being +# downloaded from the public mirrors. This is the supported way to bake a package +# that is published on NO VIPM repository (e.g. an in-house framework) without a +# private mirror: commit its .vip and reference the package in a .vipc. A loose .vip +# is only ever used when an applied .vipc references it - it is never installed on +# its own. +# +# NOTE: -Filter '*.vip' ALSO matches '*.vipc' (Windows 8.3 wildcard), so enumerate +# and filter on the exact extension instead. Package id + version come from the file +# name (VIPM's canonical export form '-.vip'); the package's +# internal 'spec' is read as a best-effort override when the file was renamed. +function Get-LocalVipFileIndex([string] $Dir) { + $index = New-Object System.Collections.Generic.List[object] + if (-not $Dir -or -not (Test-Path -LiteralPath $Dir)) { return @($index.ToArray()) } + $vipFiles = @(Get-ChildItem -LiteralPath $Dir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -eq '.vip' }) + foreach ($f in $vipFiles) { + $name = '' + $version = '' + $base = [System.IO.Path]::GetFileNameWithoutExtension($f.Name) + if ($base -match '^(?.+)-(?\d+(?:\.\d+)+)(?:-\d+)?$') { + $name = $Matches.n + $version = $Matches.v + } + # Best-effort override from the package's internal 'spec' (a .vip is a zip). + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + $zip = [System.IO.Compression.ZipFile]::OpenRead($f.FullName) + try { + $entry = $zip.Entries | Where-Object { $_.Name -eq 'spec' } | Select-Object -First 1 + if ($entry) { + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { $specText = $reader.ReadToEnd() } finally { $reader.Close() } + $mN = [regex]::Match($specText, '(?im)^\s*Package(?:\s*Name)?\s*=\s*"?(?[A-Za-z0-9_.\-]+)"?\s*$') + if ($mN.Success) { $name = $mN.Groups['v'].Value.Trim() } + $mV = [regex]::Match($specText, '(?im)^\s*Version\s*=\s*"?(?\d+(?:\.\d+)+)"?') + if ($mV.Success) { $version = $mV.Groups['v'].Value.Trim() } + } + } finally { $zip.Dispose() } + } catch { } + if (-not $name) { $name = $base } + $index.Add([pscustomobject]@{ + Name = $name + Version = $version + VersionKey = Get-NumericVersionKey $version + File = $f.FullName + }) + } + return @($index.ToArray()) +} + +# Pick the best local .vip for a request, mirroring Select-PublicVipmPackage's +# name + (exact | minimum) version matching. +function Select-LocalVipPackage($Request, [object[]] $LocalVips) { + $cands = @($LocalVips | Where-Object { $_.Name -ieq $Request.Name }) + if ($cands.Count -eq 0) { return $null } + if ($Request.Version) { + if ($Request.Minimum) { + $minKey = Get-NumericVersionKey $Request.Version + $cands = @($cands | Where-Object { $_.VersionKey -ge $minKey }) + } else { + $cands = @($cands | Where-Object { $_.Version -eq $Request.Version }) + } + } + if ($cands.Count -eq 0) { return $null } + return @($cands | Sort-Object VersionKey -Descending | Select-Object -First 1)[0] +} + +function Get-LocalVipFilesForSpecs([string[]] $Specs) { + $repoPackages = @(Get-PublicVipmRepositoryPackages) + # Committed .vip files take priority over the public mirrors when applying. + $localVips = @(Get-LocalVipFileIndex $VipcDir) + $rootRequests = @($Specs | ForEach-Object { Split-VipmPackageSpec $_ }) + $exactByName = @{} + foreach ($root in $rootRequests) { + if ($root.Version -and -not $root.Minimum) { $exactByName[$root.Name] = $root } + } + $resolved = New-Object System.Collections.Generic.List[object] + $visiting = @{} + $visited = @{} + $visitedPackageIds = @{} + + function Resolve-One($Request, [bool]$IsDependency = $false) { + if ($Request.Minimum -and $exactByName.ContainsKey($Request.Name)) { + $Request = $exactByName[$Request.Name] + } + $key = '{0}@{1}:{2}' -f $Request.Name, $Request.Version, $Request.Minimum + if ($visited.ContainsKey($key)) { return } + if ($visiting.ContainsKey($key)) { return } + $visiting[$key] = $true + # Prefer a committed local .vip over the public mirrors. This also resolves a + # package that is in NO public index (e.g. an in-house framework): its + # dependencies are not parsed from the .vip - they come from the .vipc, which + # enumerates the full closure as separate specs that resolve on their own. + $local = Select-LocalVipPackage $Request $localVips + if ($local) { + if (-not $visitedPackageIds.ContainsKey($local.File)) { + $resolved.Add([pscustomobject]@{ + Id = ('{0}-{1}' -f $local.Name, $local.Version) + Name = $local.Name + Version = $local.Version + LocalFile = $local.File + }) + $visitedPackageIds[$local.File] = $true + } + $visited[$key] = $true + $visiting.Remove($key) + return + } + $pkg = Select-PublicVipmPackage $Request $repoPackages + if (-not $pkg) { + $vtext = if ($Request.Version) { " version '$($Request.Version)'" } else { '' } + # A ROOT request that can't be resolved is fatal. A transitive + # DEPENDENCY that isn't in any reachable index is skipped with a warning: + # some packages declare a dependency whose content is bundled inside the + # parent .vip and is never published standalone (e.g. the LUnit CLI lists + # astemes_lib_lunit_cli_system, which ships inside the CLI package). VIPM + # installs the parent fine without a separate file for it. + if ($IsDependency) { + Write-Warning (" Skipping dependency '$($Request.Name)'$vtext" + ": not in the reachable public VIPM indexes (assumed bundled in its parent package).") + $visited[$key] = $true + $visiting.Remove($key) + return + } + throw "Package '$($Request.Name)'$vtext was not found in the public VIPM indexes." + } + if ($visitedPackageIds.ContainsKey($pkg.Id)) { + $visited[$key] = $true + $visiting.Remove($key) + return + } + foreach ($dep in (Get-PublicVipmDependencyRequests $pkg)) { Resolve-One $dep $true } + $resolved.Add($pkg) + $visitedPackageIds[$pkg.Id] = $true + $visited[$key] = $true + $visiting.Remove($key) + } + + foreach ($root in $rootRequests) { Resolve-One $root $false } + $downloadDir = Join-Path $env:TEMP 'vipm-package-files' + $files = New-Object System.Collections.Generic.List[string] + foreach ($pkg in $resolved) { + if ($pkg.PSObject.Properties.Name -contains 'LocalFile' -and $pkg.LocalFile) { + Write-Host " Using committed local .vip for $($pkg.Id): $(Split-Path $pkg.LocalFile -Leaf)" + $files.Add($pkg.LocalFile) + } else { + $files.Add((Save-PublicVipmPackage $pkg $downloadDir)) + } + } + return @($files.ToArray() | Select-Object -Unique) +} + +$applyFailed = $false +# Set when a best-effort tooling VIPC (ci-tooling*) does not fully install. This is +# surfaced as a warning but does NOT fail the image build (the required essentials +# and project dependencies are what gate CI correctness). +$script:bestEffortFailed = $false +# Set once a VIPM call reports the engine-startup timeout. After that the headless +# VIPM engine is wedged and will NOT recover within this build, so every subsequent +# 'vipm install' would burn another full VIPM_TIMEOUT (900s) before failing. We use +# this flag to abort the remaining install attempts immediately rather than stacking +# 15-minute timeouts into a multi-hour hang (build 27910621710 ran 90+ min that way). +$script:VipmEngineDead = $false +# * --labview-version / --labview-bitness are GLOBAL options and must PRECEDE the +# 'install' subcommand; they target the LabVIEW baked into the image. +# * There is NO '--refresh' option on 'install' anymore - the package list is +# updated by the SEPARATE 'vipm refresh' command (run once below). (In the older +# 2026.1.0 CLI '--refresh' was a global option; 26.3 removed it - passing it now +# fails with exit 2 COMMAND_SYNTAX_ERROR: "unexpected argument '--refresh'".) +# * The CLI is non-interactive via the VIPM_NONINTERACTIVE / VIPM_ASSUME_YES env +# vars set above, so no '-y' is required. +$GlobalFlags = @('--labview-version', $LabVIEWVersion, '--labview-bitness', $LabVIEWBitness) + +# Run 'vipm install' with the global LabVIEW target flags in front of the subcommand. +# Exit 2 (COMMAND_SYNTAX_ERROR) means this CLI build rejected the flag position; fall +# back to the bare form, which targets the active LabVIEW from the seeded Settings.ini. +# This is the single-attempt worker; callers go through Invoke-VipmInstall, which adds +# the wedged-engine restart-and-retry recovery on top of it. +function Invoke-VipmInstallOnce { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Targets) + $out = & $VipmExe @GlobalFlags install @Targets 2>&1 + $out | Out-Host + if ($LASTEXITCODE -eq 2) { + Write-Host ' (install rejected global LabVIEW flags; retrying bare form against active target)' + $out = & $VipmExe install @Targets 2>&1 + $out | Out-Host + } + # Capture the vipm exit code before any further work: 124 is the CLI's timeout + # exit (the operation ran the full VIPM_TIMEOUT and was killed), which is the + # by-name install's symptom of an unresponsive engine. + $vipmExit = $LASTEXITCODE + # Stash the CLI text so callers can distinguish failure causes that share exit + # code 8 (IO_ERROR) - e.g. the engine-startup timeout vs. the engine rejecting + # the .vipc file itself. Capture WIDE: Out-String wraps at the host buffer width + # (default 120) and can split a message mid-phrase, which previously made the + # detector below MISS a wrapped 'wait for VIPM startup' line - so the build kept + # stacking 900s timeouts for every remaining package instead of bailing once. + $script:LastVipmOutput = ($out | Out-String -Width 8192) + # Match against a whitespace-flattened copy so a wrap can never hide the phrase. + $flatVipmOutput = ($script:LastVipmOutput -replace '\s+', ' ') + # A wedged VIPM engine means every subsequent 'vipm install' burns another full + # VIPM_TIMEOUT (~900s) before failing, so record it once and let callers bail + # instead of stacking 15-minute timeouts into a multi-hour hang (build + # 28951187926 ran ~2h that way). The engine is wedged when EITHER: + # * the CLI reports the startup handshake timed out ('wait for VIPM startup'), + # which the local-file fallback path surfaces; OR + # * a plain 'vipm install' hits the full VIPM_TIMEOUT -- the by-name path does + # NOT print 'wait for VIPM startup', it prints "operation 'install' timed out + # after s" and exits 124. That timeout was previously missed, + # so the four essentials retried one-by-one at 900s each before the fallback + # finally tripped the detector. Match the timeout message AND exit 124 too. + if ($vipmExit -eq 124 -or + $flatVipmOutput -match 'wait for VIPM startup' -or + $flatVipmOutput -match "operation '[^']*' timed out after") { + $script:VipmEngineDead = $true + } + return $vipmExit +} + +# Wedged-engine recovery wrapper around Invoke-VipmInstallOnce. If a 'vipm install' +# wedges the headless VIPM engine (the transient cold-start race that fails ~1 build +# in N), tear the whole VIPM stack down and relaunch it, then retry the SAME install. +# Restart-VipmStack clears $script:VipmEngineDead so the loop can retry; if the +# relaunched engine wedges again the next attempt re-sets the flag and the build-wide +# restart budget stops the loop, falling through to the existing fast-abort path so a +# genuinely-broken engine still fails fast instead of hanging for hours. +function Invoke-VipmInstall { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Targets) + $vipmExit = Invoke-VipmInstallOnce @Targets + while ($script:VipmEngineDead -and $script:VipmEngineRestartsUsed -lt $script:VipmMaxEngineRestarts) { + $script:VipmEngineRestartsUsed++ + Restart-VipmStack $script:VipmEngineRestartsUsed + Write-Host (" Retrying 'vipm install' after VIPM engine restart " + $script:VipmEngineRestartsUsed + "/" + $script:VipmMaxEngineRestarts + " ...") + $vipmExit = Invoke-VipmInstallOnce @Targets + } + return $vipmExit +} + +# Install a set of package SPECS (name@version) using the by-name path first and, +# when the container resolver index is empty, the public-index local-file fallback. +# Returns $true only if every spec installed. Stops early (returns $false) the +# moment the VIPM engine wedges so we never stack 900s timeouts. +function Install-VipmSpecs { + param([string[]] $Specs) + if (-not $Specs -or $Specs.Count -eq 0) { return $true } + Write-Host (" Installing by name: " + ($Specs -join ', ')) + $rc = Invoke-VipmInstall @Specs + $failed = $false + if ($rc -ne 0) { + Write-Host " batch install failed (exit $rc); retrying each package individually ..." + foreach ($spec in $Specs) { + if ($script:VipmEngineDead) { Write-Warning ' VIPM engine wedged; stopping per-package retries.'; return $false } + $rc = Invoke-VipmInstall $spec + if ($rc -ne 0) { Write-Warning " package '$spec' failed (exit $rc)."; $failed = $true } + if ($script:VipmEngineDead) { Write-Warning ' VIPM engine wedged; stopping per-package retries.'; return $false } + } + } + if ($rc -eq 0 -and -not $failed) { return $true } + if ($script:VipmEngineDead) { Write-Warning ' VIPM engine wedged; skipping the local-file fallback.'; return $false } + Write-Host ' VIPM name-based resolution failed; downloading public .vip files and installing from local files ...' + try { + $vipFiles = @(Get-LocalVipFilesForSpecs $Specs) + Write-Host (" Installing local VIP files: " + (($vipFiles | ForEach-Object { Split-Path $_ -Leaf }) -join ', ')) + $rc = Invoke-VipmInstall @vipFiles + if ($rc -eq 0) { return $true } + Write-Host " local VIP file batch install failed (exit $rc); retrying each file individually ..." + $localFailed = $false + foreach ($vipFile in $vipFiles) { + if ($script:VipmEngineDead) { Write-Warning ' VIPM engine wedged; stopping per-file retries.'; return $false } + $rc = Invoke-VipmInstall $vipFile + if ($rc -ne 0) { Write-Warning " local package file '$vipFile' failed (exit $rc)."; $localFailed = $true } + if ($script:VipmEngineDead) { Write-Warning ' VIPM engine wedged; stopping per-file retries.'; return $false } + } + return (-not $localFailed) + } catch { + Write-Warning (" local VIP file fallback failed: " + $_.Exception.Message) + return $false + } +} + +# Refresh all package sources once (best-effort - a refresh failure is only a warning +# because version-pinned installs can still resolve from the local cache). +# +# VIPM 26.3 Community Edition refuses to install ("exit 6: VIPM Community Edition +# requires a public Git repository") unless the current working directory is inside +# a PUBLIC Git repository. It only reads .git/config's origin URL (and verifies the +# repo is public). When Community Edition enforcement is active it shells out to a +# real `git` binary (MinGit, baked into C:\git by labview-ci.Dockerfile) to read +# .git/config's origin URL and verify the repo is public - so a minimal fabricated +# .git (no clone or commits required) plus git on PATH is enough. We default to NOT +# forcing CE (see above), but keep this public-repo context as a safety net so the +# install still works if CE enforcement is enabled. Verified locally against VIPM +# 26.3: with git present this clears the exit-6 gate and the install proceeds. +function New-PublicRepoWorkdir { + param([string] $RepoUrl) + $work = Join-Path $env:TEMP ('vipm-install-' + [Guid]::NewGuid().ToString('N')) + + # Preferred: actually CLONE the public repo so the working directory is a REAL + # git checkout - a genuine remote, real HEAD/commits, and the project's own + # .vipc present on disk - rather than a fabricated stub. If VIPM Community + # Edition verifies repository visibility by shelling out to git (git rev-parse + # HEAD / git ls-remote origin reaching GitHub), only a real clone satisfies it. + # Shallow + single-branch + no-tags keeps it fast. Best-effort: any failure + # (no git, no network in the build layer) falls back to the fabricated .git + # context below, which is enough to read .git/config's origin URL. + if (Get-Command git -ErrorAction SilentlyContinue) { + try { + Write-Host "Cloning public repo for VIPM CE context: $RepoUrl" + & git clone --depth 1 --single-branch --no-tags --quiet $RepoUrl $work 2>&1 | Out-Host + if (($LASTEXITCODE -eq 0) -and (Test-Path (Join-Path $work '.git'))) { + Write-Host " Cloned public repo into $work" + return $work + } + Write-Warning " git clone failed (exit $LASTEXITCODE); falling back to a fabricated .git context." + } catch { + Write-Warning (" git clone threw (" + $_.Exception.Message + "); falling back to a fabricated .git context.") + } + if (Test-Path $work) { Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue } + } + + # Fallback: minimal fabricated .git (origin URL only). Enough for VIPM to read + # .git/config's origin remote, but with no commits a deeper `git rev-parse HEAD` + # / `git ls-remote` check would not pass - hence the real clone is preferred. + New-Item -ItemType Directory -Path (Join-Path $work '.git\objects') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $work '.git\refs\heads') -Force | Out-Null + Set-Content -Path (Join-Path $work '.git\HEAD') -Value 'ref: refs/heads/main' -NoNewline -Encoding ascii + $cfg = "[core]`n`trepositoryformatversion = 0`n`tbare = false`n" + + "[remote `"origin`"]`n`turl = $RepoUrl`n`tfetch = +refs/heads/*:refs/remotes/origin/*`n" + Set-Content -Path (Join-Path $work '.git\config') -Value $cfg -Encoding ascii + return $work +} + +$prevLocation = Get-Location +$installWorkdir = $null +try { + $installWorkdir = New-PublicRepoWorkdir $PublicRepoUrl + Write-Host "Running VIPM installs from a public-repo context (origin=$PublicRepoUrl) to satisfy Community Edition." + Set-Location $installWorkdir + + # Diagnostic (per JKI): show what `git` reports from the EXACT directory vipm + # runs in - this is one of the signals VIPM uses to decide the repo is public. + # If these don't show a clean working tree with a public origin remote, VIPM's + # public-repo detection can't succeed regardless of anything else. + if (Get-Command git -ErrorAction SilentlyContinue) { + Write-Host "--- git context for VIPM (cwd=$installWorkdir) ---" + Write-Host '$ git status' + & git status 2>&1 | Out-Host + Write-Host '$ git remote -v' + & git remote -v 2>&1 | Out-Host + Write-Host '$ git rev-parse HEAD' + & git rev-parse HEAD 2>&1 | Out-Host + Write-Host '--- end git context ---' + } + + # Force a full re-download of the package spec index. A fresh headless VIPM in a + # container starts with an empty CLI spec cache (C:\ProgramData\JKI\VIPM\cache); + # a plain `vipm refresh` reported "complete" but downloaded no specs, so every + # package resolved as "not found" (exit 3). --force re-fetches the index. + Write-Host 'Refreshing VIPM package sources (vipm refresh --force) ...' + & $VipmExe refresh --force 2>&1 | Out-Host + + # Phase A (REQUIRED, installed FIRST): the UTF JUnit essentials the built-in + # 'LabVIEWCLI -OperationName RunUnitTests' operation links against. Install them + # before any heavy tooling VIPC so they land while the engine is fresh - even if + # a later add-on (e.g. Antidoc) wedges the engine, headless UTF still works. + # Without them RunUnitTests fails with LabVIEW CLI error -350053. Override the + # list with VIPM_REQUIRED_PACKAGES (comma/semicolon separated name@version); set + # it to a single '-' to disable the required pre-install entirely. + $requiredRaw = if ($null -ne $Env:VIPM_REQUIRED_PACKAGES) { $Env:VIPM_REQUIRED_PACKAGES } else { + 'ni_lib_utf_junit_report@1.0.1.43,ni_lib_junit_results_api@1.0.1.6,ni_lib_simple_xml@1.0.0.4' + } + $requiredSpecs = @($requiredRaw -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -ne '-' }) + if ($requiredSpecs.Count -gt 0) { + Write-Host ("Installing REQUIRED UTF essentials first: " + ($requiredSpecs -join ', ')) + if (Install-VipmSpecs $requiredSpecs) { + Write-Host 'REQUIRED UTF essentials installed.' + } else { + Write-Warning 'One or more REQUIRED UTF essentials failed to install; headless UTF (RunUnitTests) will fail with -350053.' + $applyFailed = $true + } + } + + # Phase A2 (EARLY, BEST-EFFORT): unit-test framework packages that only resolve + # through the public-index local-file fallback (the container's by-name resolver + # is empty, so `vipm install ` returns exit 3). Astemes LUnit is the case: + # it IS on the JKI/NI public indexes (downloadable), but must be installed HERE, + # right after the UTF essentials while the headless VIPM engine is still fresh. + # The later heavy ci-tooling.vipc local-file install (Caraya + VI Tester + their + # OpenG dependency closures) can wedge the engine ('wait for VIPM startup'), and + # once wedged nothing else installs - so LUnit, if left to that phase, never gets + # a healthy engine. Installing it early mirrors how the (now-removed) local .vip + # bundle made LUnit survive. Best-effort: a failure warns but does not fail the + # build. Gated to unit-tests builds: defaults to empty when the required UTF + # essentials are disabled (VIPM_REQUIRED_PACKAGES='-', i.e. Unit Tests capability + # not installed). Override with VIPM_EARLY_PACKAGES ('-' disables). + $earlyRaw = if ($null -ne $Env:VIPM_EARLY_PACKAGES) { $Env:VIPM_EARLY_PACKAGES } + elseif ($requiredSpecs.Count -gt 0) { 'astemes_lib_lunit,astemes_lib_lunit_cli' } + else { '' } + $earlySpecs = @($earlyRaw -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -ne '-' }) + if ($earlySpecs.Count -gt 0 -and -not $script:VipmEngineDead) { + Write-Host ("Installing EARLY best-effort framework packages (engine still fresh): " + ($earlySpecs -join ', ')) + if (Install-VipmSpecs $earlySpecs) { + Write-Host 'Early framework packages installed.' + } else { + $script:bestEffortFailed = $true + Write-Warning 'One or more early framework packages (e.g. LUnit base/CLI) did not install; the LUnit CLI may be missing from the worker (run-unit-tests.ps1 will show the missing-tooling banner).' + } + } + + # Apply REQUIRED (project) VIPCs before BEST-EFFORT tooling VIPCs (ci-tooling*). + # A best-effort add-on (Antidoc) can wedge the headless VIPM engine, so it must + # run LAST - otherwise it would kill the engine before a required project VIPC + # (the OpenG / domain dependencies the project's VIs load against) gets to apply. + $vipcFiles = @($vipcFiles | Sort-Object @{ Expression = { if ($_.Name -like 'ci-tooling*') { 1 } else { 0 } } }, Name) + + foreach ($vipc in $vipcFiles) { + # Tooling VIPCs (ci-tooling*.vipc) carry opportunistic add-ons (Antidoc, + # Caraya, VI Tester). Antidoc's heavy dependency tree can wedge the headless + # VIPM engine, so a tooling VIPC failure is BEST-EFFORT: it warns but does not + # fail the image build (the required essentials above are already installed). + # Any other (project) VIPC is REQUIRED - its packages are what the project's + # VIs load against, so a failure must fail the build. + $bestEffort = ($vipc.Name -like 'ci-tooling*') + $label = if ($bestEffort) { 'best-effort tooling' } else { 'required project' } + $vipcFailed = $false + + if ($script:VipmEngineDead) { + Write-Warning (" Skipping '$($vipc.Name)' ($label): the VIPM engine wedged earlier in this build and will not recover.") + $vipcFailed = $true + } + else { + Write-Host "Applying VIPC: $($vipc.Name) [$label]" + # Preferred path: install the .vipc file directly (the form VIPM documents: + # `vipm install -y project.vipc`). + Write-Host " Installing from file: vipm install -y '$($vipc.Name)'" + $rc = Invoke-VipmInstall '-y' $vipc.FullName + if ($rc -eq 0 -and $script:LastVipmOutput -match 'No packages were installed') { + Write-Warning " VIPM accepted '$($vipc.Name)' but reported that no packages were installed; falling back to package-level install." + $rc = 42 + } + if ($rc -ne 0) { + if (($rc -eq 8 -or $rc -eq 124) -and ($script:LastVipmOutput -match 'wait for VIPM startup')) { + # Engine never came online; the by-name fallback would hit the same + # wall and burn another VIPM_TIMEOUT, so surface it immediately. + Write-Warning (" VIPM could not install '$($vipc.Name)' (exit $rc): the VIPM engine never came online ('wait for VIPM startup').") + $vipcFailed = $true + } + else { + # Code 42 etc. mean the engine is up but rejected the .vipc-FILE + # apply path; the by-name + local-file fallback can still succeed. + Write-Host " install from file failed (exit $rc); falling back to per-package names ..." + $specs = @(Get-VipcPackageSpecs $vipc.FullName) + if ($specs.Count -eq 0) { + Write-Warning "VIPM could not install from '$($vipc.Name)' (exit $rc) and no package names could be parsed." + $vipcFailed = $true + } + elseif (-not (Install-VipmSpecs $specs)) { + $vipcFailed = $true + } + } + } + } + + if ($vipcFailed) { + if ($bestEffort) { + $script:bestEffortFailed = $true + Write-Warning (" '$($vipc.Name)' did not fully install, but it is best-effort tooling - continuing the build.") + } else { + $applyFailed = $true + } + } + } +} +finally { + Set-Location $prevLocation + if ($installWorkdir -and (Test-Path $installWorkdir)) { + Remove-Item -Recurse -Force $installWorkdir -ErrorAction SilentlyContinue + } +} + +# Stop the headless LabVIEW we launched for the install (best-effort). +if ($LabVIEWProc -and -not $LabVIEWProc.HasExited) { + Write-Host 'Stopping headless LabVIEW...' + try { $LabVIEWProc | Stop-Process -Force -ErrorAction SilentlyContinue } catch { } +} + +# Stop the VIPM engine we pre-launched for the install (best-effort). +if ($VipmEngineProc -and -not $VipmEngineProc.HasExited) { + Write-Host 'Stopping VIPM engine...' + try { $VipmEngineProc | Stop-Process -Force -ErrorAction SilentlyContinue } catch { } +} + +if ($applyFailed) { + $message = ('One or more REQUIRED VIPM packages could not be installed (a project .vipc ' + + 'dependency or a UTF JUnit essential the RunUnitTests CLI links against). Headless UTF ' + + 'may fail with LabVIEW CLI error -350053, or project VIs may not load. Check the install ' + + 'log above for the failing package(s) and confirm they exist on the configured VIPM repository.') + if ($Env:VIPM_ALLOW_MISSING_PACKAGES -eq '1') { + Write-Warning ($message + ' VIPM_ALLOW_MISSING_PACKAGES=1 is set, so the image build will continue without those packages.') + exit 0 + } + Write-Error ($message + ' Failing the image build so CI cannot publish or run against a worker image with stale/missing required dependencies. Set VIPM_ALLOW_MISSING_PACKAGES=1 only for emergency best-effort builds.') + exit 1 +} + +if ($script:bestEffortFailed) { + Write-Warning ('Some best-effort tooling add-ons (e.g. Antidoc / Caraya / VI Tester from ci-tooling.vipc) ' + + 'did not fully install - typically because a heavy dependency tree wedged the headless VIPM engine. ' + + 'The image is still valid: the required project dependencies and UTF JUnit essentials are present. ' + + 'Bake the missing add-on separately (e.g. a dedicated VIPC) if you need it in the worker.') +} + +Write-Host 'Required VIPM packages installed successfully.'