From 8aae88f4f7ddc7739701a053d1c836fe23878bb2 Mon Sep 17 00:00:00 2001 From: Anton Sundqvist Date: Mon, 27 Jul 2026 12:55:09 +0300 Subject: [PATCH 1/5] Add unit.-tests as a composite action Add a new, distributable composite GitHub Action that lets any consumer repo run a LabVIEW unit-test framework (Caraya, VI Tester, NI UTF, or Astemes LUnit) inside a container in one step and publish an HTML report, without needing their own copy of the scripts or a labview-ci.yml config. --- .github/labview/run-unit-tests.ps1 | 122 +++- actions/unit-tests/action.yml | 199 +++++ actions/unit-tests/build-unittest-report.py | 764 ++++++++++++++++++++ actions/unit-tests/run-unit-tests.ps1 | 657 +++++++++++++++++ 4 files changed, 1722 insertions(+), 20 deletions(-) create mode 100644 actions/unit-tests/action.yml create mode 100644 actions/unit-tests/build-unittest-report.py create mode 100644 actions/unit-tests/run-unit-tests.ps1 diff --git a/.github/labview/run-unit-tests.ps1 b/.github/labview/run-unit-tests.ps1 index 8dd1de7a..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' @@ -355,30 +373,76 @@ function Invoke-Tool($tool, [int]$index) { } # -- NI Unit Test Framework (UTF) --------------------------------------------- -# UTF tests live as .lvtest files inside a .lvproj. Resolve the project(s) to run -# from the tool's locations (a .lvproj path, or a directory/glob to search), keeping -# only projects that actually reference UTF tests so we never launch LabVIEW for -# nothing. Empty locations means "search the whole project". +# 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] - # A location may itself be a .lvproj. - foreach ($loc in @($locations)) { - if (-not $loc) { continue } + $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 } - } - $roots = if (@($locations | Where-Object { $_ -and $_.Trim() }).Count -gt 0) { Resolve-TestRoots $locations } else { @($WorkspaceRoot) } - foreach ($root in $roots) { - if (-not (Test-Path -LiteralPath $root)) { continue } - $projs = @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.lvproj' -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '(?i)\\\.github\\' -and $_.FullName -notmatch '(?i)\\ci-out\\' }) - foreach ($p in $projs) { - $txt = Get-Content -LiteralPath $p.FullName -Raw -ErrorAction SilentlyContinue - if ($txt -and ($txt -match 'Type="TestItem"' -or $txt -match '\.lvtest')) { $found.Add($p.FullName) } + + # (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) } @@ -542,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 From 2ab7ea38467f7123733c120fdf5e11d79b2e044f Mon Sep 17 00:00:00 2001 From: Anton Sundqvist Date: Tue, 28 Jul 2026 05:46:55 -0700 Subject: [PATCH 2/5] Add actions/worker-image: create/update/no-op composite action for the shared LabVIEW worker image --- actions/worker-image/Dockerfile | 68 ++ actions/worker-image/action.yml | 281 +++++++ actions/worker-image/ci-tooling.vipc | Bin 0 -> 72159 bytes actions/worker-image/install-vipc.ps1 | 1050 +++++++++++++++++++++++++ 4 files changed, 1399 insertions(+) create mode 100644 actions/worker-image/Dockerfile create mode 100644 actions/worker-image/action.yml create mode 100644 actions/worker-image/ci-tooling.vipc create mode 100644 actions/worker-image/install-vipc.ps1 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..edbf7ebe --- /dev/null +++ b/actions/worker-image/action.yml @@ -0,0 +1,281 @@ +# 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 +# +# 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 }} + + # The bundled Dockerfile's `COPY .github/labview/vipm/ C:/vipm/` becomes + # `COPY vipm/ C:/vipm/` against this scratch context — same relative + # layout (a single 'vipm' folder holding install-vipc.ps1 + every + # .vipc/.dragon/.vip to bake), just rooted outside the caller's checkout + # instead of inside it. + - name: Stage build context + id: stage + shell: pwsh + run: | + $ctx = Join-Path $env:RUNNER_TEMP 'worker-image-ctx' + $vipmDir = Join-Path $ctx 'vipm' + New-Item -ItemType Directory -Force -Path $vipmDir | Out-Null + + 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) + 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 '${{ github.action_path }}' '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) + "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 0000000000000000000000000000000000000000..2331167270bdede60f65ed4d8fb95bccff255f99 GIT binary patch literal 72159 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 KEGWtd_WuCqPv&v} literal 0 HcmV?d00001 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.' From a228e1022b1cd780c84fc75fd441270f91765575 Mon Sep 17 00:00:00 2001 From: Anton Sundqvist Date: Tue, 28 Jul 2026 06:06:23 -0700 Subject: [PATCH 3/5] worker-image: fix build-context staging to match Dockerfile's COPY .github/labview/vipm/ path --- actions/worker-image/action.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/actions/worker-image/action.yml b/actions/worker-image/action.yml index edbf7ebe..bc7b2736 100644 --- a/actions/worker-image/action.yml +++ b/actions/worker-image/action.yml @@ -115,17 +115,16 @@ runs: username: ${{ github.actor }} password: ${{ github.token }} - # The bundled Dockerfile's `COPY .github/labview/vipm/ C:/vipm/` becomes - # `COPY vipm/ C:/vipm/` against this scratch context — same relative - # layout (a single 'vipm' folder holding install-vipc.ps1 + every - # .vipc/.dragon/.vip to bake), just rooted outside the caller's checkout - # instead of inside it. + # 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 'vipm' + $vipmDir = Join-Path $ctx '.github\labview\vipm' New-Item -ItemType Directory -Force -Path $vipmDir | Out-Null Copy-Item -Path (Join-Path '${{ github.action_path }}' 'install-vipc.ps1') -Destination $vipmDir -Force From e3dee8caf704040c410a9fa1764eb0f8f8b02aa1 Mon Sep 17 00:00:00 2001 From: Anton Sundqvist Date: Tue, 28 Jul 2026 06:10:39 -0700 Subject: [PATCH 4/5] worker-image: bundle ensure-docker.ps1 wait/retry; build with Dockerfile inside context --- actions/worker-image/action.yml | 17 +++++++++- actions/worker-image/ensure-docker.ps1 | 45 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 actions/worker-image/ensure-docker.ps1 diff --git a/actions/worker-image/action.yml b/actions/worker-image/action.yml index bc7b2736..8c6ef2ee 100644 --- a/actions/worker-image/action.yml +++ b/actions/worker-image/action.yml @@ -40,6 +40,7 @@ # 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. @@ -115,6 +116,15 @@ runs: 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 @@ -127,6 +137,11 @@ runs: $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') { @@ -264,7 +279,7 @@ runs: } } else { $tagFlags = ($tags | ForEach-Object { '-t', $_ }) - docker build -f (Join-Path '${{ github.action_path }}' 'Dockerfile') ` + 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 } 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.' From 1a7a2d3d9902edfc96084263bd0164dc63ef5a34 Mon Sep 17 00:00:00 2001 From: Anton Sundqvist Date: Tue, 28 Jul 2026 07:18:36 -0700 Subject: [PATCH 5/5] worker-image: reset $LASTEXITCODE after expected-nonzero crane digest calls The 'not found' case from crane digest is how create/update is detected, but it left $LASTEXITCODE non-zero through Write-Host/Out-File (which don't touch it), so GitHub Actions' pwsh wrapper failed the step on every single create/update run before Build and push ever executed. --- actions/worker-image/action.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/actions/worker-image/action.yml b/actions/worker-image/action.yml index 8c6ef2ee..7999f22a 100644 --- a/actions/worker-image/action.yml +++ b/actions/worker-image/action.yml @@ -234,6 +234,12 @@ runs: $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 @@ -291,5 +297,6 @@ runs: } $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)"