This documentation describes the LabVIEW-CI-with-Containers tooling, maintained by elijah286. Looking for short answers instead? See the FAQ.

How LabVIEW CI works

A complete guide to continuous integration for LabVIEW — written for working LabVIEW developers, not software engineers. The early sections explain what CI is and the handful of concepts it rests on, in plain language and with diagrams. Later sections go all the way down to the implementation, so you can evaluate, operate, customise, or extend the stack. You do not need prior experience with Git, Docker, or GitHub Actions to read this — everything is introduced from first principles.

Living document — kept in lock-step with the tooling. When a capability is added or a behaviour changes, this page changes with it.

Start here

1. Why continuous integration? #

Picture the way most LabVIEW code is checked today. You finish a feature, run it on your own machine, click through a few cases, and commit. A week later a teammate opens the project and a VI is broken — a subVI moved, an add-on was missing, or a change two branches over quietly broke something you never thought to re-test. Nobody did anything wrong; there was simply no automatic, repeatable check that the whole project still loads, compiles, passes its tests, and meets your standards every time the code changes.

Continuous integration (CI) is that automatic check. Think of it as a tireless robot teammate. Every time anyone saves code to the shared repository, the robot quietly opens the entire project on a clean, identical machine and runs the work you would otherwise do by hand:

It does this without you remembering to, without tying up your machine, and in exactly the same way every time — so a result you can trust replaces "well, it worked when I tried it."

In plain terms CI catches mistakes minutes after they are introduced, on a clean machine, before they reach a colleague or a release — instead of days later when they are expensive and confusing to track down.

Why this has always been hard for LabVIEW

CI is routine for text-based languages, but LabVIEW resists the usual recipes. LabVIEW needs a licensed graphical IDE, usually a specific year version, and often a set of add-on packages (VIPM) just to open a project — and it traditionally wants a desktop with a display. A typical cloud build machine has none of that. LabVIEW CI removes every one of those obstacles by running LabVIEW headless (no visible UI) inside a container (a pre-built, self-contained machine image with LabVIEW and your add-ons already installed). Sections 2 and 13–15 explain exactly how.

Is it for you?

CI pays off as soon as more than one person, more than one branch, or more than one release is involved — and it is genuinely useful even for a solo developer who wants a dependable record of project health over time. The setup is largely automated (see §6), and on public repositories the compute is free. If your project lives on GitHub (or you are willing to put it there), you can be running in a few minutes.

2. Key concepts in plain language #

LabVIEW CI is built on a small number of GitHub building blocks. You can use the tooling without mastering any of them, but a one-paragraph mental model of each makes everything that follows click into place. Skip ahead if these are already familiar.

Repository (“repo”)
The home of one project — your VIs, controls, .lvproj, and its complete history. On GitHub it lives at github.com/<you>/<project>. Analogy: a project folder that also remembers every version of itself.
Git
The version-control system underneath GitHub. It records each change so you can review history, work in parallel, and never lose anything. Analogy: an infinite, reliable Undo history shared with your whole team.
Commit
One saved snapshot of the project with a short message. Committing is the LabVIEW-equivalent of “save a labelled checkpoint of the whole project.”
Branch · push · pull request (PR)
A branch is a parallel line of work; main is the trunk everyone shares. You push to upload your commits to GitHub. A pull request proposes merging your branch into main; it is where CI checks run and teammates review before the change lands.
GitHub Actions · workflow · job
GitHub's built-in automation. A workflow is a recipe (a .yml file) that runs one or more jobs when something happens — a push, a pull request, a schedule, or a button press. LabVIEW CI is, at its core, a set of these recipes.
Runner
The computer that actually executes a job. GitHub lends you clean, disposable Windows and Linux machines (“GitHub-hosted runners”); you can also register your own (“self-hosted”) — see §7. Analogy: a fresh rental laptop that is wiped after every job.
Container · image · Docker
A container is a lightweight, throwaway virtual computer. Its blueprint is an image. We bake LabVIEW and your add-ons into an image once; each CI job boots a fresh container from it, does its work, and is deleted. Docker is the technology that runs them. Analogy: the image is a saved disk; a container is booting a brand-new copy of that disk that you discard when done.
GHCR (GitHub Container Registry)
Where your built images are stored, attached to your own repository as packages.
GitHub Pages
Free static-website hosting served straight from a repository. LabVIEW CI publishes your dashboard and every report here, at https://<you>.github.io/<project>/.
Commit status
A small pass / fail / pending marker GitHub attaches to a commit. Each CI activity posts one; the dashboard reads them to fill in its grid — this is how a result travels from a workflow to a cell.
Headless LabVIEW
LabVIEW running with no visible front panel, driven from the command line / VI Server, so it can run unattended on a server inside a container.
CI / CD
Continuous Integration = automatically build and test every change. Continuous Delivery = automatically publish the results and artefacts. This stack does both: it tests every commit and delivers a live web report.

Here is how those pieces connect for a single change:

flowchart LR DEV["You, in LabVIEW<br/>edit + commit"] --> PUSH["git push<br/>(upload to GitHub)"] PUSH --> REPO["GitHub repository<br/>stores code + history"] REPO --> ACT["GitHub Actions<br/>starts the workflow"] ACT --> RUN["Runner (clean PC)<br/>pulls your image"] RUN --> CON["Container<br/>headless LabVIEW runs the checks"] CON --> REP["Reports + commit status"] REP --> PG["GitHub Pages<br/>dashboard + reports"] PG --> YOU["You read the result<br/>in a browser"]

Everything in this stack is just an elaboration of that one line. The rest of the document fills in what runs, when, and why each choice was made.

3. What LabVIEW CI is #

LabVIEW CI is a portable, repository-agnostic CI/CD pipeline for LabVIEW projects. It runs LabVIEW headless inside Docker containers on GitHub Actions runners, performs a set of code-quality activities on every commit, and publishes the results as a static GitHub Pages site with a unified dashboard. Nothing runs on the tooling author's infrastructure or on NI's — every container executes inside your GitHub Actions environment, under your account's minutes and limits.

Four design principles shape almost every decision in the codebase. Knowing them up front explains a lot of the “why” in later sections:

4. The big picture #

A push (or pull request) triggers the reusable workflow, which gates a set of per-capability container jobs on your configuration. Each job pulls a worker image, runs LabVIEW headless inside a throwaway container, writes a report, deploys it to the gh-pages branch, and posts a commit status. A separate dashboard workflow reacts to those statuses and rebuilds the Pages dashboard. Read the diagram top to bottom — it is the same journey as §2, with the real file and job names.

flowchart TD CP["commit / PR"] --> RW["labview-ci.reusable.yml<br/>config job reads .github/labview-ci.yml"] RW -->|"has-masscompile and os-windows"| MC["masscompile<br/>windows-2022"] RW -->|"has-vi-analyzer and os-linux"| VA["vi-analyzer<br/>ubuntu-latest"] RW -->|has-vidiff| VD["vidiff"] RW -->|has-snapshots| SN["vi-snapshots"] MC --> JOB["each job<br/>1. pull image (GHCR / NI)<br/>2. docker run --rm<br/>3. LabVIEW headless<br/>4. build report on host<br/>5. deploy to gh-pages<br/>6. post commit status"] VA --> JOB VD --> JOB SN --> JOB IMG["images<br/>ghcr.io/owner/repo-labview — build-labview-image (Windows) / build-labview-linux-image (Linux, incl. VI Browser 2.0 engine)<br/>nationalinstruments/labview — base layer (public)"] -.->|"pulled by"| JOB JOB -->|"commit status"| ST["status event"] ST --> DP["dashboard-pages.yml<br/>actions/dashboard"] DP --> GHP["gh-pages root<br/>index.html, reports, snapshots"] GHP --> PAGES["GitHub Pages<br/>your dashboard + reports"]

Three deploy destinations live side by side on the gh-pages branch (all written with keep_files: true so they never clobber each other):

Where things actually run Nothing in this picture runs on the tooling author's servers or NI's. The runner is GitHub's (or yours, if self-hosted); the images live in your registry; the website is your GitHub Pages. You own and can audit every part.

Using it

5. The activities & when to use each #

LabVIEW CI is a menu of independent activities (also called capabilities). You enable the ones that fit your project; each runs on its own and writes its own report. Here is the menu, what each one is for, and when it earns its keep. The deep mechanics of each are in §16.

CapabilityWhat it doesPlatforms
Mass CompileCompiles every VI/CTL and flags broken VIs and missing dependencies.Win Linux
VI AnalyzerRuns NI's static-analysis test suite for correctness, performance, style and documentation.Win Linux
VIDiffGenerates visual, side-by-side front-panel + block-diagram diffs of changed VIs between commits.Win Linux
Unit TestsRuns Caraya / LUnit / VI Tester / NI Unit Test Framework headlessly and merges JUnit output into one report.Win
AntidocGenerates project documentation from the VI hierarchy using Wovalab's Antidoc CLI.Win
VI Snapshots / BrowserRenders every VI to a content-addressed HTML snapshot gallery (the classic VI Browser).Win
VI Snapshots 2.0Position-aware, in-place VI Browser frames (JSON) rendered by the cross-platform toimages engine.Win Linux
DashboardAggregates everything above into a single status page on GitHub Pages.runner host

Which to turn on, and when

You can change your mind any time Every activity is a checkbox in the Configure Pipeline dialog (and Windows/Linux is a per-activity toggle). Turning one off simply skips its job; nothing is uninstalled and no history is lost.

6. Installing LabVIEW CI #

There are two ways in, and you do not need a command line for either. The fastest is the browser-based Apply to New Repo wizard (a button on the live site); the scriptable alternative is the install.py command-line installer. Both read the same catalog and produce the same result.

What you need first

The installer in three layers

LayerFile(s)Role
Entry pointinstall.sh / install.ps1Fetch the tooling from GitHub, locate Python, forward to install.py.
Braininstall.py (stdlib only)Resolve the file set from the catalog, apply text substitutions, write the manifest.
Resultthe target repo.github/workflows/*.yml, .github/labview/*, and the .github/labview-ci.yml manifest.

The browser-based Apply to New Repo flow also records each started install in this browser so you can leave and return. On return it reconciles the saved record against GitHub and clears anything that is no longer in flight: an open install pull request stays as “pull request open”, a merged one that is still building shows “publishing dashboard”, and a record clears the moment its dashboard is live. A record with a saved tooling version waits for the target dashboard's published catalog.json to reach that version; because the dashboard a repo runs can publish a different version line than the installer page is on, a record whose dashboard has simply been live for a while past the install clears anyway, so a finished install never lingers as “publishing dashboard”. Publishing records are rechecked from the public dashboard URL even if the install token has since been forgotten.

Before starting a new install, the flow also checks whether one is genuinely already in flight, and treats live GitHub state as authoritative: it warns only when there is an open install pull request, or the ci/install-labview-ci branch exists and its pull request has not yet merged or closed. GitHub does not delete a head branch on merge by default, so a finished, merged install leaves that branch behind — it is treated as harmless debris (the next install fast-forwards it), not as an install still in progress. A local record only blocks a re-install while its dashboard is genuinely still publishing (verified against the live catalog.json); any other stale record that GitHub contradicts is forgotten so it stops nagging on future installs.

What gets written into your repo

The per-repo manifest — .github/labview-ci.yml

This one file is the control panel for your pipeline. Everything the Configure and Dependencies dialogs change ends up here, and every workflow reads it at run time.

schemaVersion: 1
installedVersion: 3.24.5
source:
  repo: elijah286/LabVIEW-CI-with-Containers
  ref:  v3.24.5            # pinned to an IMMUTABLE tag, never "main"
config:
  labviewVersion: "2026"
  os: [windows, linux]
  container:
    use: latest           # tag every action uses by default
    vipc:
      - { path: Dependencies.vipc }
    actions:              # per-action image overrides
      masscompile: base   # bare NI image
      vidiff: "2026"      # pinned tag
activities: [dashboard, masscompile, vi-analyzer, vidiff]

Because source.ref is pinned to an immutable version tag, your pipeline never changes unless you explicitly update (see §18). That is a deliberate safety property: a new release of the tooling cannot silently alter how your project is checked.

Thin vs. fat installs

A thin install references the reusable workflow at a moving major tag (uses: …/labview-ci.reusable.yml@v3) and inherits improvements automatically. A fat install copies all workflow files locally and updates them deliberately via install.py --update. Both are valid; thin is the lowest-maintenance path and the right default for most teams.

After it installs

The wizard enables GitHub Pages, opens (and can auto-merge) the install pull request, and dispatches the first dashboard build so your site goes live within a minute of the merge — no CI run is required to see the dashboard. A fresh repo with existing history shows a Populate the dashboard with your history card so you can backfill results for past revisions on demand.

7. Private, self-hosted & custom setups #

The defaults target public repositories on GitHub-hosted runners because that path is free and zero-maintenance. But the architecture was designed to be adapted. This section covers the common “my situation is different” cases.

Private repositories

Everything works on a private repo with one caveat: GitHub Pages on a private repository requires a paid GitHub plan (Pro, Team, or Enterprise). Without one, every CI activity still runs and every report is still produced and uploaded as a workflow artifact — you simply cannot serve the live dashboard website. Your options are to make the repo public, upgrade the plan, or read reports from the Actions artifacts. The web installer detects a private repo and tells you which applies; if Pages cannot be enabled the install finishes with an honest amber warning rather than a false “done.” Use a fine-grained token scoped to only the target repository (the token-creation link cannot pre-select a single repo, so set Repository access → Only select repositories yourself).

Self-hosted & on-prem runners

The worker container runs on the runner, so the runner is where you decide CI executes. Both runner labels default to GitHub-hosted but can be pointed at self-hosted machines you register (your own Windows and Linux boxes, on-prem or in your cloud) via the reusable workflow's windows-runner input or repository Actions variables. Reasons to self-host:

Self-hosted runner security A self-hosted runner executes whatever a workflow tells it to. On a public repository, treat self-hosted runners with care (a fork's pull request can propose workflow changes); GitHub recommends self-hosted runners primarily for private repositories, ephemeral runners, and required approvals for outside contributors.

Custom container registries & base images

By default your worker images are published to your GHCR (ghcr.io/<owner>/<repo>-labview) and seeded from the shared LCWC base. If your organisation standardises on a different registry or a hardened LabVIEW base image, the image name and tag every action uses are derived at runtime from the manifest and Actions variables, not hard-coded — so you can point actions at a pre-existing internal image by setting config.container.use (or a per-action override) to a literal image tag, or by overriding the image variable. The dependency-baking Dockerfiles (§15) are ordinary multi-stage Dockerfiles you can fork to start FROM your own approved base.

Offline / restricted-network VIPM dependencies

Worker builds install add-on packages from public VIPM/NI indexes by default. In a locked-down network the build cannot reach those. Three supported escape hatches, in order of preference:

  1. A committed offline VIPC that embeds the package files themselves, so no download is needed at build time.
  2. Direct package-file URLs to an internal mirror (the resolver-bypass fallback can install local .vip/.ogp files).
  3. A pre-populated VIPM resolver cache baked into your custom base image.

Private or custom VIPM repositories are not auto-resolved from the public indexes; one of the three above is required. The mechanics are in §15.

Custom LabVIEW versions & add-ons

The LabVIEW year is set once in the manifest (config.labviewVersion) and threaded through the whole pipeline; the worker base image is built for that year. Your project's add-on packages are declared in .vipc (and optionally JKI .dragon) files and baked into the worker on demand from the Dependencies page — that is the supported way to add anything LabVIEW needs that is not in the base image.

Migrating or rehoming the project

A small relocation pointer (.github/labview-ci/source.json) lets the tooling's source move — for example to an official NI repository — without breaking existing installs. If it names a different repo than the current one, the bootstrappers and the update workflow follow it automatically and re-pin a consumer's manifest to the new home on the next update. See §18.

8. Everyday use & best practices #

Once installed, CI mostly takes care of itself. This section is the operating manual: when it runs, how to work with it day to day, and the handful of habits that get the most value for the least cost.

When does CI run?

The activities trigger automatically on three kinds of event. Knowing them avoids surprise (and surprise runner-minute bills):

flowchart TD E{"What happened?"} -->|"push that touches<br/>a VI / CTL / project file"| R1["The matching activities run<br/>on that commit"] E -->|"pull request<br/>with VI changes"| R2["Activities run on the PR<br/>(results shown as checks)"] E -->|"you press Run<br/>on the dashboard"| R3["That activity is dispatched<br/>for the chosen revision"] E -->|"only .github/ tooling<br/>changed"| R4["Skipped — no LabVIEW source changed"]

Path filters keep CI honest: a commit that only touches documentation or the tooling under .github/ does not fan out the whole LabVIEW matrix. A dependency rebuild is separate and opt-in — changing a .vipc flags that dependencies need installing but does not rebuild the heavy worker image until you ask it to (see §13).

A recommended workflow

The pattern that gets the most out of CI mirrors normal GitHub flow:

  1. Create a branch for your change.
  2. Commit and push. CI runs on the branch.
  3. Open a pull request into main. The activities post pass/fail checks and VIDiff gives you a reviewable visual diff.
  4. Review the dashboard / checks, fix anything red, and merge once green.

This keeps main always-compiling and gives every change an automatic, reviewable safety net. Solo developers benefit too: even pushing straight to main, you get a continuous record of project health and instant notice when something breaks.

Reading the dashboard

Each row is a commit; each column an activity. A coloured cell is a finished result you can click into; a spinning Queued / Running cell is live; a plain run arrow is an empty cell you can fill on demand. The version badge tells you whether newer tooling is available. §9 covers the dashboard in detail.

Managing dependencies

When you add or change the add-on packages your VIs need, declare them in a .vipc (or .dragon) file and commit it. The dashboard compares your declarations against what is baked into the current worker and shows a dependencies need to be installed banner if anything is missing. Applying it from the Dependencies page is a deliberate, one-click worker rebuild — so a dependency change never silently triggers a slow rebuild in the middle of your day.

Keeping costs & concurrency sane

Staying up to date

The version badge in the header tells you when newer tooling exists. Update now applies it in one click and preserves your configuration; it never rebuilds your containers. Because your manifest is pinned to an immutable tag, updates are always something you choose, never something that happens to you. See §18.

9. The dashboard & Pages site #

The dashboard is built by the actions/dashboard composite action (Python, dashboard.py) and deployed by dashboard-pages.yml. The action bundles all the tooling pages — the dashboard, VI Browser, report viewer, Configure, Apply, What's New, this Documentation page — so a consumer needs no local copy of any of them.

dashboard-pages.yml rebuilds on three kinds of trigger: a push that changes the page assets, the GitHub status event (every capability posts statuses, so the dashboard reacts to all of them with no per-workflow enumeration and no polling — and since it only reads statuses, it can never self-trigger), and workflow_run completions for workflows that publish page artifacts without a status.

dashboard.py reads each commit's statuses (keyed by the catalog's statusContext), classifies whether a commit touched real LabVIEW source vs. CI tooling, aggregates per-activity data from the deployed JSON (e.g. masscompile/<sha>/summary.json, the snapshot by-blob index), and renders one dashboard row per recent commit. To keep the project's own revisions visible even after many CI/tooling commits have piled up on main, it keeps paging back through history — past the recent window it retains only project revisions — until it has collected the project's revisions or reaches a large safety cap on how many commits it will classify. Reading that history is resilient to a transient GitHub API failure: rate-limited (HTTP 403/429), server, and network errors are retried with backoff (honoring Retry-After/X-RateLimit-Reset), and if a call still fails so the revision list would be incomplete, the build refuses to publish and exits — the previous good dashboard stays live and rebuilds automatically once the API recovers, so a burst of concurrent CI activity can never overwrite it with a blank or truncated table. It also stages the bundled tooling pages and writes the VI Browser's files.json index.

On a fresh install with project revisions but no CI results yet, dashboard.py emits a Populate the dashboard with your history card above the table. The card opens the Populate history chooser, can be dismissed per repository in local storage, and hides as soon as that dashboard has queued one of its own runnable cells or a later dashboard rebuild sees a running/finished activity from a revision or pull request. Queued-run state is matched against the current table's cells by capability and target revision (or globally for the all-history snapshot backfill) so another repository on the same Pages origin cannot suppress the fresh-install card.

Each activity in the chooser is set independently to Skip, Fill (queue only the revisions missing that result), or Re-run (queue every selected revision, replacing existing results). An activity is offered for Re-run when any selected revision has a result, an empty cell, or an in-flight queued/running cell — the latter are tagged cidash-active-cell so a re-run replaces the in-flight run via concurrency instead of greying out as “none in scope.” The optimistic “Queued” badge overlays that in-flight cell too, so re-running the latest revision's still-running activity shows its Queued spinner immediately rather than silently doing nothing. A cell whose only state is a browser-side optimistic overlay — a cidash-queued-cell that replaced an empty run glyph when you dispatched it, including the sticky red “Failed” badge for a dispatch that failed — is gathered the same way, so a failed run stays re-runnable here instead of disappearing as “none in scope.” Clicking that red “Failed” badge opens its manage dialog, which now also offers Re-run (dispatch it again for that commit) and, when the failed run still deployed a report, a View report link straight to it — a hard failure that produced no report keeps just the “View run” link to the Actions log. Queuing a large batch — more than ten workflow runs across the selected revisions and activities — first asks for confirmation, so an accidental “all revisions” selection can't fire dozens of runs without a prompt.

The chooser's revision picker (the All / range / specific selectors) mirrors the dashboard table: dependency-only revisions — those whose only change is an external dependency (.vipc/.vip) — are hidden by default and revealed with a “Show dependency-only revisions” toggle (shown only when such a revision exists), defaulting to whatever the dashboard's own dependency-only filter is currently set to. A search box narrows the picker by commit title or short SHA across all three scopes, so you can queue the latest change to LabVIEW code without scrolling past dependency-only commits. CI-only revisions never appear, because the history feeding the chooser already contains project revisions only.

The same searchable revision picker backs every other “select a revision” control (the Dependencies page, the Unit Testing project picker and the per-revision report viewer’s Revision selector). It is a shared widget in lvci-header.js (window.lvciRevPicker) that enhances a native <select> in place — the select stays the source of truth, so existing behaviour (deep links, steppers, change handlers) is unchanged — and adds a filter box that searches by commit title, build number or short SHA. By default it shows only revisions that change LabVIEW source, hiding CI/tooling and dependency-only (.vipc/.vip) revisions behind a “Show CI & dependency-only revisions” toggle, so picking “the latest revision” anywhere land On the Dependencies page this picker now lives in the shared header context bar — the same place the report viewer and VI Browser put their Revision selector (built by the header from window.LVCI.revPicker and exposed as window.lvciRevBar) — instead of a separate in-page dropdown.s on the latest LabVIEW-code change. The classification is published per revision in dependencies/index.json and vi-snapshots/files.json, so every picker filters identically to the dashboard.

The Populate-history chooser offers an opt-in "keep one container warm across revisions" checkbox, shown only when at least one selected activity has a warm-container backfill workflow installed (Windows Mass Compile, VI Analyzer, Unit Tests, Antidoc). It is always off by default and carries a red warning that container state carries over between revisions (see §14). When ticked, the chooser collapses every batch-capable (activity, platform) into a single workflow_dispatch carrying the whole oldest→newest SHA list instead of one dispatch per revision; non-batch platforms still queue per revision. The chooser also shows a live, history-driven time estimate comparing fresh vs warm-batch compute: the per-activity work time is the median of the durations the dashboard already loads from each summary.json/results.json, and the startup time is the median run total (from the Actions API) minus that work time, falling back to per-platform defaults until a repository has run history. Warm-batch is serial inside one container, so the estimate notes the wall-clock vs total-cost trade-off.

The shared site-wide header is lvci-header.js: every page declares a tiny window.LVCI config and loads the script, which injects a consistent navigation chrome (Dashboard / VI Browser nav, Settings & Help menus, version badge, live activity pill, theme toggle). Reports stay immutable; the header updates the moment the shared script is redeployed.

10. Common errors & troubleshooting #

Most problems fall into a few recognisable buckets. Each entry below gives the symptom, the cause, and the fix. When you need the raw detail, open the Actions tab of your repository and click into the failing run — the job log shows the exact step and error. (Note: GitHub returns no log for a step while it is still in progress; wait for the step to end or cancel it to read the log.)

The dashboard shows a placeholder or never goes live

Cause & fix. Usually GitHub Pages is not enabled, or the repo is private on a free plan (Pages needs a paid plan — see §7). Check Settings → Pages and set the source to the gh-pages branch. On a brand-new install, many workflows publish to gh-pages at once and one deploy can briefly lose a push race; the dashboard deploy retries automatically and self-heals on the next publish, so a momentary placeholder right after install is normal.

dashboard not live? └─ Settings → Pages source set to gh-pages? → no → set it └─ repo private on a free plan? → yes → make public or upgrade └─ just installed (< a minute ago)? → yes → wait; the deploy retries └─ still stuck? → → re-run the dashboard workflow

“Update now” seems stuck

Cause & fix. The update opens (or directly pushes) a change to your repo. If it opened a pull request that is just waiting, simply merge it — a tooling update PR changes only config/tooling files, so it has no checks to wait for. If no PR appeared, the built-in token either cannot push a workflow-file change or is not allowed to open PRs here; the What's New dialog offers a one-time token to finish the update. See §18.

A worker image build fails

Worker builds bake add-on packages with the VIPM CLI, which has several non-obvious failure modes (covered in depth in §15). The most common:

A LabVIEW activity errors with -350000

Cause & fix. LabVIEW 2026+ needs the -Headless flag for VI Server operations inside a Windows container; without it, VI Server raises -350000. The bundled drivers already pass it — if you forked a driver, restore the flag.

A dispatched run for an older commit fails instantly

Cause & fix. This was a historical bug (a missing origin/main tracking ref during a checkout of a past commit) that current tooling fixes. If you are on older tooling, the workaround is to re-dispatch with the commit box blank (which targets the latest commit), then update your tooling.

Relatedly, the Determine target SHA step no longer calls the GitHub API when the dispatched commit_sha is already a full 40-character SHA (the dashboard always passes one), and it falls back to the raw input instead of aborting if a short-SHA lookup is rate-limited — so a transient HTTP 403 API rate limit exceeded can no longer make a dispatched report run fail before it starts (which otherwise showed on the dashboard as an empty/failed activity, e.g. “no unit tests”).

The first run on a fresh runner is very slow

Cause & fix. The worker image is multi-gigabyte and a GitHub-hosted runner re-pulls it every job. This is expected. A self-hosted runner with a warm Docker layer cache removes the repeated pull (§7).

Windows jobs fail to start a container

Cause & fix. Every Windows job pins windows-2022 on purpose: windows-latest now resolves to Windows Server 2025, whose Docker daemon is broken for these LabVIEW containers. If you changed a runner label, set it back to windows-2022.

Unit Tests / Antidoc “missing” on Linux

Not a bug. Unit Tests and Antidoc are Windows-only today (they depend on baked VIPM tooling). They are not offered for Linux in Configure or Populate history. VI Analyzer, by contrast, now runs on Linux too — enable it per platform on the Configure page.

A new install does not appear on the Clients page

Not a bug. Discovery is a daily pull-based scan plus GitHub's own search-index lag, so a brand-new public install can take up to a day to appear (§19). Private repos are never listed.

Under the hood

11. The catalog — single source of truth #

Everything starts at .github/labview-ci/catalog.json. It is a versioned manifest that both the browser configurator and the Python installer consume. Its key sections:

{
  "schemaVersion": 1,
  "version": "3.24.5",            // must equal history.releases[0].version
  "history": { "releases": [ { "version": "...", "title": "...", "highlights": [...] } ] },
  "base":   { "files": [ ... ] }, // always installed (dashboard chrome, manifest, …)
  "capabilities": [
    {
      "id": "vi-analyzer",
      "name": "VI Analyzer",
      "status": "stable",          // planned | advanced | stable  → drives UI visibility
      "recommended": true,
      "supportsOs": ["windows"],
      "requires": ["dashboard"],   // hard dependencies
      "recommends": ["dashboard"],
      "statusContext": "CI / VI Analyzer",   // GitHub commit-status context
      "files": { "any": [...], "windows": [...] }
    }
  ],
  "substitutions": { "ordered": [ { "find": "...", "replaceWith": "{ownerRepo}" } ] },
  "userConfig": { "files": [ ".github/labview-ci.yml", ".github/labview/vipm/ci-tooling.packages.json" ] }
}

The status field controls how a capability surfaces: planned is shown but disabled, advanced is opt-in, stable is offered by default. The statusContext string is the GitHub commit-status context the capability's workflow posts and the dashboard reads — that one string is how a result flows from a workflow to a dashboard cell.

Why this matters for extension Because the configurator and installer iterate the catalog generically, you never touch UI code to add a capability. See §20 for the full recipe.

12. Workflow orchestration #

The heart is .github/workflows/labview-ci.reusable.yml. It implements config-driven job gating: a single config job reads the consumer's .github/labview-ci.yml once (via the actions/load-config composite action) and exposes the settings as job outputs. Every activity job then gates on both a capability flag and a platform flag.

config:
  runs-on: ubuntu-latest
  outputs:
    has-masscompile: ${{ steps.cfg.outputs.has-masscompile }}
    os-windows:      ${{ steps.cfg.outputs.os-windows }}
    tooling-ref:     ${{ steps.ref.outputs.ref }}
    # …

mass-compile:
  needs: config
  if: ${{ needs.config.outputs.has-masscompile == 'true'
          && needs.config.outputs.os-windows == 'true' }}
  runs-on: ${{ inputs.windows-runner }}   # default windows-2022

Disable a capability in Configure and its flag flips to false, skipping the job. Unselect a platform and the platform flag does the same. The tooling-ref output lets the consumer's manifest pin which version of the tooling scripts are checked out at run time (ref: in the config), decoupling capability versioning from GitHub's moving major-tag alias.

Runners

Windows container jobs run on windows-2022; Linux jobs on ubuntu-latest. Both default to GitHub-hosted runners but can be pointed at self-hosted labels via workflow inputs / Actions variables. The worker container runs on the runner, so choosing a runner is how you choose where CI executes — a self-hosted runner additionally gives you a persistent Docker layer cache so the multi-gigabyte image is not re-pulled every job.

Concurrency & parallel deploys

Each report-producing workflow runs in its own concurrency group keyed by activity and revision (${{ github.workflow }}-${{ inputs.commit_sha }}, cancel-in-progress: false), so different activities — and the same activity on different revisions — run in parallel and never cancel each other; re-dispatching the same activity for the same revision simply queues behind the in-flight run. Several jobs still push to the same gh-pages branch, so races are handled at the deploy step: the shared publish-gh-pages action is retry-safe — it re-fetches gh-pages and retries the push (up to six times with back-off) so every parallel publish lands instead of one losing a non-fast-forward cannot lock ref 'refs/heads/gh-pages' push. (Earlier versions serialised all report workflows through a single report-pages-deploy group; that made unrelated activities wait on — and, because GitHub keeps only one pending run per group, cancel — each other, so it was replaced by per-activity groups once the deploy became retry-safe.) The dashboard build uses a separate group (dashboard-pages, cancel-in-progress: true) so a burst of status events collapses into a single latest-wins rebuild.

The dashboard surfaces this line live. It reads GitHub's in-progress, pending, waiting/requested, and queued runs and shows each enqueued activity as a spinning Queued cell on the revision it targets. Because a manual re-run is dispatched on the default branch ref, the run's head_sha is the workflow-file commit, not necessarily the revision being analyzed. Per-revision dispatch workflows therefore include the target SHA (inputs.commit_sha, or inputs.head_sha for VIDiff) in their run name, and dashboard.py keys active runs on that target SHA rather than the dispatch ref. Snapshot backfills are one global run, so they are treated as active for every snapshot cell still awaiting coverage. When several runs are waiting for an available runner, each queued cell also reports how many jobs are ahead of it (for example Queued · 1 ahead).

A capability that runs on two platforms (Windows and Linux) writes a single shared result for the commit, so the faster platform finishes first. The dashboard checks for an active run on the commit + capability before rendering that result, so while the slower platform is still running the cell keeps its live Running badge instead of reverting to a finished-looking result — the indicator only clears once every platform's run for that capability has completed.

13. Worker container images #

The worker image is where LabVIEW and your baked-in dependencies live. The source/template repo (LCWC) owns and publishes one shared base image from .github/docker/labview-ci-base.Dockerfile — LabVIEW with VI Analyzer support, optional UTF support, VIPM, and Git already installed. Every client repo gets its worker from that base in one of two ways, and which one runs is the key distinction between installation and an upgrade.

Installation vs. upgrade — how containers are managed

flowchart TD BASE["Source repo (LCWC)<br/>labview-ci-base.Dockerfile<br/>publishes the shared -labview-base image"] INS["Installation<br/>a repo onboards LabVIEW CI"] --> COPY["copy-labview-image.yml<br/>crane copy + crane tag on ubuntu-latest<br/>copy the base into<br/>ghcr.io/you/repo-labview<br/>a minute or two, no rebuild"] BASE -.->|"copied from"| COPY COPY --> READY["Worker ready<br/>NI + LabVIEW, no project deps yet"] READY -.->|"later"| UPG UPG["Upgrade<br/>add or replace .vipc / .dragon files"] --> CHK{"Declared deps already<br/>baked into the current<br/>worker manifest?"} CHK -->|"yes"| OK["Nothing to do"] CHK -->|"no, some missing"| BAN["Dashboard banner<br/>dependencies need to be installed"] BAN --> APPLY["You open Review and update<br/>opt-in workflow dispatch"] APPLY --> BUILD["build-labview-image.yml<br/>FROM base + thin labview-ci.Dockerfile<br/>bakes your VIPC / Dragon packages"] BUILD --> MODE{"Update mode"} MODE -->|"replace (default)"| REP["Rebuild current tags in place<br/>actions pick it up automatically"] MODE -->|"new"| NEW["Publish dated tag year-deps-date<br/>select per action in Configure"]

Linux builds are handled by build-labview-linux-image.yml on the same model. The Linux worker and the standalone VI Browser 2.0 render image also bake a set of X11 core and TrueType fonts (DejaVu, Liberation, Noto, and the 75/100 dpi xfonts-* families) — NI’s minimal Linux base ships almost none, and without them LabVIEW’s UI controls and rendered VI text (VI Browser snapshots and the Debug Run desktop) come out blank. Images are pushed to your GitHub Container Registry (GHCR) as packages attached to your repo:

ghcr.io/<owner>/<repo>-labview      # your LabVIEW worker (copied from the base, or base + your deps; the Linux worker also bakes the VI Browser 2.0 render engine)
ghcr.io/<lcwc-owner>/<lcwc-repo>-labview-base   # the shared base, owned & published by the source repo

The image inherits your repository's visibility and is authenticated with the job's built-in GITHUB_TOKEN. GHCR rejects uppercase names, so the owner/repo are lowercased at build time.

Content-addressed tagging

Every build computes a worker version — a SHA-256 over the layered Dockerfiles, the VIPC install script, and every baked .vipc — and publishes four tags so consumers can choose their stability/freshness trade-off:

TagExampleStability
:latest:latestAlways the newest build.
:<year>:2026Moves as that LabVIEW year rebuilds.
:<year>-<date>:2026-20250620Dated snapshot, stable until the next rebuild.
:<hash>:win-a1b2c3d4e5f6Fully immutable — same inputs always reproduce the same tag.

Each build also publishes a human/machine-readable worker manifest to /workers/<platform>/<version>/manifest.html listing the exact nipkg and VIPM packages baked in, so a worker image is fully auditable.

The machine-readable manifest.json records installed VIPM packages in vipm_packages (name, version, and VIPC-style label) plus the raw vipm list --installed output in vipm_raw. The Dependencies page unions that installed-package inventory with the manifest’s own vipc[].packages list when deciding what is baked. Because VIPM records versions with a leading v (e.g. v0.5.0.1) while VIPC files declare the bare form (0.5.0.1), the comparison normalizes that v prefix — without it, correctly-baked packages would be reported as missing and the “dependencies need to be installed” banner would never clear.

Dragon dependencies are reconciled against the current Windows worker manifest (the worker rebuilt by build-labview-image.yml). If a manifest has not yet published a dedicated dragon.items status block, the dashboard falls back to the installed-package inventories (vipm_packages / nipkg_packages) so already-baked dependencies are marked installed instead of staying stuck at not_attempted.

When images rebuild

A worker rebuild is a heavy, opt-in operation. It runs when you apply a dependency update from the dashboard's Review & update dependencies dialog, on the monthly cron (to pick up base-image security updates), or by manual dispatch. Adding or changing a project .vipc never triggers a rebuild on its own — the dashboard simply flags that dependencies need installing until you choose to run the update.

Dependency gate (activities wait for the rebuild)

VIPC files are the source of truth for a repo's project dependencies, so the container-based activities must never run against a worker image that predates a dependency update. While a worker rebuild is in progress, the activity workflows — VI Analyzer, Mass Compile, Unit Tests, Antidoc, and VIDiff — call .github/labview/wait-for-worker-images.sh before they pull the image and block until the worker image build(s) for that revision finish. The wait outlasts a cold first build (pulling the multi-gigabyte NI base and baking the project VIPC runs ~80–100 minutes), so a fresh install's first dispatched activity waits rather than failing while the image is still building. In the reusable workflow a single gate-images job does this and the dependency-consuming jobs need it. If the rebuild fails the activities do not run; if nothing worker-affecting changed the gate returns immediately. The snapshot renderer is intentionally exempt because it renders on the bare NI base image.

Manual VIPC base-image bake

bake-vipc-windows-base.yml is a focused proof-and-promote workflow for a single VIPC. It runs on windows-2022, pulls :2026 of the shared -labview-vipm-base image (or builds and pushes it once when missing or explicitly requested), stages only the selected VIPC (default: example/Dependencies.vipc) into the thin Windows dependency layer, verifies that VIPM reports the VIPC packages as installed inside the resulting container, and only then pushes the verified image to GHCR. On success it promotes the normal project tag (for example :2026), an operator-selected moving tag (default :vipc-base), and an immutable VIPC-hash tag.

Validated Windows base split The successful 2026 proof path stores VIPM and Git in ghcr.io/<owner>/<repo>-labview-vipm-base:2026. VIPC rebuilds then pull that base and apply only the dependency layer. The verified project image for the example VIPC reported all seven expected OpenG packages from vipm list --installed before GHCR tags were pushed.

Per-action image routing

Image selection is a three-level lookup against .github/labview-ci.yml:

  1. Per-action overrideconfig.container.actions.<id> (e.g. pin VIDiff to a frozen build while Mass Compile tracks latest).
  2. Project-wide defaultconfig.container.use.
  3. Built-in default — the bare NI base image.

Special values: base/none force the NI base image (no baked dependencies), latest selects the custom image's :latest tag, and anything else is treated as a literal tag.

14. Container lifecycle & headless LabVIEW #

A container job follows the same shape on every platform. The Windows Mass Compile job is representative:

docker pull "$IMAGE"
docker run --rm `
  -e "GITHUB_REPOSITORY=$repo" -e "GITHUB_SHA=$sha" `
  -v "${{ github.workspace }}:C:\workspace" `   # your repo, bind-mounted in
  -v "${{ github.action_path }}:C:\ci-action" ` # the script ships with the action
  "$IMAGE" `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\ci-action\masscompile.ps1" `
    -WorkspaceRoot "C:\workspace" `
    -ReportDir     "C:\workspace\ci-out\masscompile" `
    -LabVIEWPath   "C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe"

Key properties:

Container lifecycle in one sentence A fresh container is created for each action × revision × platform, started when the action begins and destroyed when it ends; it is shared across all files in one action's run but never across actions or revisions. The snapshot renderer is the sole always-on exception — see §16.6 — and the opt-in warm-container history backfills below are the other.

Warm-container history backfills (opt-in)

Populating a long history one revision at a time pays the multi-GB image pull and the slow Windows LabVIEW cold-start once per revision. The per-capability backfill workflows (masscompile-backfill-windows.yml, vi-analyzer-backfill-windows.yml, unit-tests-backfill-windows.yml, antidoc-backfill-windows.yml, plus the existing VIDiff and snapshot backfills) instead start one long-lived container (docker run -d … sleep) and process every requested revision through it with docker exec, turning N × (startup + work) into startup + N × work. Each revision is checked out into a throwaway git worktree, compiled/analyzed/tested inside the container, its report docker cp'd back out to the host (Windows bind-mount output is unreliable), and the host report builder + shared Pages publisher run exactly as in the per-commit job. The runs are resumable: a skip-list built from the deployed gh-pages tree and a TimeBudgetMinutes cap let a batch stop before the 6-hour job limit and continue on the next dispatch.

This is strictly opt-in and never the default, because reusing one container means its mutable state — temp files, the LabVIEW object cache, the registry — carries over between revisions, so a revision whose code modifies the system can influence a later revision's result. The container image is never modified; only the short-lived instance accumulates state, and it is destroyed when the batch ends. The dashboard's Populate-history chooser exposes this as a clearly-warned checkbox — see §9.

Report generation runs on the host, not in the container

The container emits raw output (a log, a native HTML report, JUnit XML, …). After it exits, a small Python builder on the runner host turns that into the navigable report — grouping problems by VI, adding the shared navigation chrome, and writing the machine-readable summary.json/problems.json that the dashboard reads. The report is then uploaded as an artifact and deployed to gh-pages under its per-SHA path, and a commit status is posted with the report URL.

When a report workflow is manually dispatched for an older commit, it checks out that target revision but restores the current CI runtime from the repository default branch before running: .github/labview, .github/workflows, and the local .github/actions/publish-gh-pages action. That keeps history backfills compatible with commits that predate the current report builders or shared Pages publisher.

Debug Run — remote into a worker over VNC

Every activity above runs the container headless. When a run misbehaves in a way that is hard to diagnose from logs (a VI that loads broken only inside the image, a dependency that resolves differently than expected), Tools › Debug Run on the dashboard boots the same worker for a chosen revision with a full graphical desktop you can remote into, so you can open the project in the LabVIEW IDE and watch it happen.

The dialog is a single form over a live list. You pick a revision and a session length and press Start debug session; it boots the debug desktop for that revision with the checkout’s project (*.lvproj) already open in the LabVIEW IDE (it sets the open_source input, which the container script uses to open the first project it finds under the workspace) and dispatches debug-session.yml. You no longer pre-select activities in the dashboard — once you are inside the desktop you choose what to run from an on-screen menu (see below). The dialog makes it explicit that each debug session occupies one of the project’s Actions runners for its whole lifetime and will block other CI jobs if no runner slots are free, so end sessions as soon as you are done. The form stays put; each session you start appears in a scrollable Existing debug sessions list beneath it, so you can launch several (extra ones queue and start as earlier sessions finish). Every row shows a live progress bar and status — Queued, Booting with the current step, or Live — plus Open remote desktop and End, and the list is driven by the Actions API so it works from any device (not just the browser that started the session).

Mechanically, the job runs the Linux worker (.github/labview/debug/debug-session.sh) which starts a virtual display (Xvfb) and window manager, launches the LabVIEW UI (opening the project), and serves it as a browser noVNC session (x11vnc + websockify) on port 6080. The runner exposes that port through an ephemeral Cloudflare quick tunnel (no account or secret required) and publishes the one-time connect URL to gh-pages under debug/<run-id>.json; the dialog polls for it and auto-opens it. Inside the desktop an on-screen terminal shows an operations menu: the first option opens the project in LabVIEW, and the rest run CI operations (Mass Compile, Builds, VIDiff, …) via .github/labview/debug/run-debug-actions.sh, which invokes the same Linux entrypoints CI uses (e.g. masscompile.sh) and streams output to the window so you watch each step execute. It is a run-and-observe loop — run one operation, return to the menu, run another. The session self-tears-down after the chosen minutes, on End session, or when the run is cancelled — deleting the published connect file.

No license secret is needed: LabVIEW prompts for interactive login/activation in the session, so you activate it yourself; the activity runners then execute in that same activated session. Security note: the tunnel URL and its one-time password are unguessable and short-lived, but on a public repository anyone who can see the Actions run could read the connect file while the session is live. Use Debug Run on private repositories for sensitive code; it is off by default, dispatch-only, and never runs in normal CI. Interactive Debug Run is Linux only: Windows containers run in session 0 with no interactive desktop or DWM compositor, so a screen-scraping VNC view of a Windows container is blank — a Microsoft container limitation, not a setup issue. (Windows headless CI — VI Analyzer, snapshots, mass-compile, builds — is unaffected.)

15. VIPM dependency baking — lessons from the trenches #

This section is the single hardest part of the whole stack, and it is documented in detail precisely because it was painful to get right. If you ever fork the worker build or debug a baking failure, read it carefully — every numbered point below is a real problem that was hit and solved.

Third-party LabVIEW packages (Antidoc, Caraya, VI Tester, LUnit, the UTF JUnit reporter, and project packages declared in staged .vipc files) are baked into the Windows worker image at build time so they are available headless during CI. Getting the modern VIPM CLI to install packages non-interactively inside a docker build required solving several non-obvious problems, documented in .github/labview/vipm/README.md and implemented by install-vipc.ps1:

Linux bakes the same dependencies The Linux worker (labview-ci-linux.Dockerfile) installs VIPM from the native Debian package and applies the same staged repository .vipc files during its build via install-vipc-linux.sh (VIPM CLI under Xvfb + headless LabVIEW), publishing a workers/linux manifest just like Windows. So the Dependencies page shows real per-package Linux coverage and its columns are first-class, not “pending”.
  1. Use VIPM 2026 Q3 from the JKI CDN — the older NI-feed CLI times out on library_list with no diagnostic.
  2. Run without requiring VIPM Pro — the script does not force VIPM_COMMUNITY_EDITION=1, but it does satisfy the Free/Community public-repo checks by running from a real shallow clone (or a minimal fallback .git) of the public repository.
  3. Non-interactive env (VIPM_NONINTERACTIVE=1, VIPM_ASSUME_YES=1, NO_COLOR=1) so the CLI never blocks on a prompt.
  4. Seed Settings.ini before installing — a fresh image lacks C:\ProgramData\JKI\VIPM\Settings.ini, without which the CLI aborts with an IO error.
  5. Run LabVIEW headless first — the modern CLI installs into a running LabVIEW over VI Server, so the script launches LabVIEW.exe --headless and waits for port 3363.
  6. Extend timeouts (VIPM_TIMEOUT=900) — cold first-run headless LabVIEW exceeds the CLI's short defaults during a build, where CI/GITHUB_ACTIONS env hints are absent.
  7. Use the modern command shapevipm refresh --force is a standalone command; package installs use vipm --labview-version 2026 --labview-bitness 64 install <name>@<version> or vipm install -y project.vipc. The old install --refresh form is rejected by VIPM 26.3.
  8. Bypass the empty Free/Community resolver when needed — JKI's public Docker example notes that container use currently requires Pro activation and that Free/Community support is still being fixed. Our tests match that: desktop Free refresh downloads the NI Tool Network and VIPM Community indexes, but Windows Server Core refresh can report success without populating the install resolver. When by-name installs fail with package-not-found, the script downloads the public .vipr/.ogpd indexes directly, resolves the VIPC package set plus transitive dependencies, downloads the public .vip / .ogp files, validates them, and installs those local files with VIPM.
  9. Treat direct VIPC no-op applies as failures — in the container VIPM can return exit 0 from vipm install -y project.vipc while printing No packages were installed. The hook treats that text as a failed apply and continues to the package-spec and local-file fallback paths.
Discovery & the base/project stack Every .vipc in the repo is discovered recursively and listed on the Dependencies page. That page owns the monitored-file selection written back as config.container.vipc entries; Configure only owns the global dependency policy. The tooling also ships a base configuration — .github/labview/vipm/ci-tooling.vipc — that is always monitored and locked on, while a project's own discovered VIPC files can be checked or unchecked. Checked project files produce warnings when their packages are missing from the worker; unchecked project files are tracked in config with monitor: false and do not produce warnings or automatic dependency-update triggers. When this template repo updates the base, consumers receive the new base on their next tooling update and the next container rebuild re-applies base + project together. Because each repo publishes to its own GHCR namespace, those rebuilds produce that repo's own unique worker image.

After a worker image is built locally, the publish workflow captures both nipkg list and vipm list --installed from inside the image and writes them into the worker manifest. Fresh-install seed copies use a fast registry copy and do not pull the multi-GB image back just to inspect it; seed manifests without a captured VIPM list are treated conservatively by the dashboard, with only the known Windows core tooling VIPC inferred as installed.

Required essentials vs. best-effort tooling install-vipc.ps1 installs the UTF JUnit essentials (ni_lib_utf_junit_report + its ni_lib_junit_results_api / ni_lib_simple_xml deps) first as required, then applies project .vipc files (also required). Tooling VIPCs (ci-tooling*.vipc, e.g. Antidoc / Caraya / VI Tester / LUnit) are best-effort: if a heavy add-on wedges the headless VIPM engine it is logged as a warning and the build still publishes a working, UTF-capable image. Override the required set with VIPM_REQUIRED_PACKAGES.
Public-index fallback The resolver-bypass fallback covers packages in the built-in NI LabVIEW Tools Network and VIPM Community repositories, including old OpenG sf://opengtoolkit URLs and the legacy jki_vi_tester alias. Private/custom VIPM repositories still require either a populated VIPM resolver cache, a committed offline VIPC that embeds package files, or direct package-file URLs.
Baking a package on no VIPM repository A package published on no public index (e.g. an in-house framework) can be baked without a private mirror by committing its .vip anywhere outside .github/; the build stages every repo *.vip into .github/labview/vipm/ next to the VIPCs (the glob *.vip does not match *.vipc, and the worker-version hash matches the exact extension so VIPCs are not double-counted). Reference the package by id + version in an applied .vipc and keep VIPM's canonical export name <package-id>-<version>.vip so its id + version parse. When that .vipc is applied, install-vipc.ps1 uses the committed .vip in preference to the public mirror, and it is the only way a no-index package resolves at all. The .vipc must still enumerate the package's dependency closure (OpenG, etc.) — deps are not parsed from the committed .vip — and a committed .vip that no applied .vipc references is never installed on its own.
Why the UTF JUnit reporter is mandatory for unit tests The built-in LabVIEWCLI -OperationName RunUnitTests links against ni_lib_utf_junit_report. Without it baked in, headless UTF runs fail with error -350053 ("missing or bad files in the operation folder").

The committed tooling VIPC is the source of truth

The worker dependency intent is the committed ci-tooling.vipc itself. It must remain a real, VIPM-openable file and is the file applied during image builds. The Reconfigure workflow validates the committed VIPC but does not regenerate it, so a UI-authored VIPC is not overwritten by automation.

build-tooling-vipc.py remains available as an optional helper: it can assemble a genuine VIPM-openable VIPC from JSON/package-index inputs (real spec + icon assets from public indexes), but that generated output is only authoritative once committed as ci-tooling.vipc.

16. Capabilities in depth #

Every capability is built to the same template, so once you understand one you understand them all. §16.4 (Unit Tests) is documented end-to-end as the fully worked example you can copy from.

The shared anatomy of a capability:

16.1 Mass Compile #

At a glance

Driver.github/labview/masscompile.ps1 Windows / masscompile.sh Linux
Report builder.github/labview/build-masscompile-report.py
Workflowsmasscompile-windows-container.yml windows-2022, masscompile-linux-container.yml ubuntu-latest; backfill masscompile-backfill-windows.yml
Output & statusmasscompile/<sha>/ (Linux: …/<sha>/linux/); commit status CI / Mass Compile

What it runs (headless command)

A single LabVIEWCLI MassCompile pass recursively compiles every VI/CTL in one LabVIEW session:

LabVIEWCLI -LogToConsole TRUE -OperationName MassCompile `
  -DirectoryToCompile "C:\workspace" `
  -LabVIEWPath "C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe" `
  -Headless

-Headless is required on LabVIEW 2026+ in a Windows container (VI Server error -350000 otherwise). Output is tee’d to masscompile.log.

Runner composition

docker run --rm `
  -e "GITHUB_REPOSITORY=${{ github.repository }}" -e "GITHUB_SHA=${{ steps.sha.outputs.sha }}" `
  -v "${{ github.workspace }}:C:\workspace" `
  $Env:LABVIEW_CONTAINER_IMAGE `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\workspace\.github\labview\masscompile.ps1" `
    -WorkspaceRoot "C:\workspace" -ReportDir "C:\workspace\ci-out\masscompile" `
    -LabVIEWPath "C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe"

Output contract

Exit-code semantics 0 = all compiled; 3 = partial (some bad VIs, not a container failure); any other non-zero = a real container/configuration error. The reader also handles UTF-16 BOMs that Windows Tee-Object can emit.

Dashboard badge thresholds

The Mass Compile column colours the compiled-percentage badge: green at or above greenAtLeast (95%), yellow at or above yellowAtLeast (80%), red below redBelow (80%). A true compile failure (status: failed, percentage zeroed) is drawn as a solid, bright-red filled pill. Override in .github/labview-ci.yml:

massCompile:
  thresholds:
    compiledPercent:
      greenAtLeast: 95
      yellowAtLeast: 80
      redBelow: 80

16.2 VI Analyzer #

At a glance

Driverrun-vi-analyzer.ps1 Windows / run-vi-analyzer.sh Linux
Report builderbuild-analyzer-report.py
Workflowsrun-vi-analyzer-windows-container.yml; backfill vi-analyzer-backfill-windows.yml
Output & statusvi-analyzer/<sha>/ (CI / VI Analyzer); Linux …/<sha>/linux/ (CI / VI Analyzer (Linux))

What it runs (headless command)

Each analysis pass runs one configuration over one scope:

LabVIEWCLI -LogToConsole TRUE -OperationName RunVIAnalyzer `
  -ConfigPath "<config.viancfg>" -ReportPath "<report.html>" `
  -ReportSaveType HTML `
  -LabVIEWPath "...\LabVIEW.exe" -Headless

Before analysing, the driver runs a headless Mass Compile pre-pass (-OperationName MassCompile -DirectoryToCompile …) to upgrade the project’s VIs to the image’s LabVIEW year — VI Analyzer silently skips VIs saved in an older version (“0 VIs analyzed”). The pre-pass compiles each top-level project folder individually and excludes the CI’s own tooling under .github/ (plus .git/, actions/, ci-out/, build/) — e.g. the VI Browser render engine under .github/labview/toimages — so a broken tooling VI can’t fail the compile before project VIs are upgraded.

Runner composition

docker run --rm `
  -e "GITHUB_REPOSITORY=${{ github.repository }}" -e VIA_FILES -e VIA_CONFIG `
  -v "${{ github.workspace }}:C:\workspace" `
  $Env:LABVIEW_CONTAINER_IMAGE `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\workspace\.github\labview\run-vi-analyzer.ps1" `
    -WorkspaceRoot "C:\workspace" -ReportDir "C:\workspace\ci-out\vi-analyzer" `
    -LabVIEWPath "...\LabVIEW.exe"

VIA_FILES / VIA_CONFIG are passed by name (not spliced onto the command line), so a single-VI re-run’s paths and config never break argument parsing; empty means a normal whole-revision run.

Which config runs

Report & dashboard

16.3 VIDiff #

At a glance

Drivervidiff.ps1 / vidiff.sh; history walker vidiff-backfill.ps1 / .sh
Deploy + PR commentvidiff-deploy.yml
Workflowsvidiff-windows-container.yml windows-2022, vidiff-linux-container.yml ubuntu-latest; backfill vidiff-backfill-windows.yml
Output & statusvidiff/<pr-N|push-sha>/<platform>/vidiff/; status CI / VIDiff (windows|linux)

What it runs (headless commands)

Per changed VI, one of two operations runs (a magic-byte check at offset 8 confirms a real LabVIEW resource first):

# Modified — side-by-side comparison
LabVIEWCLI -LogToConsole TRUE -OperationName CreateComparisonReport `
  -VI1 "<base.vi>" -VI2 "<head.vi>" -ReportType html -ReportPath "<out>\index.html" `
  -LabVIEWPath "...\LabVIEW.exe" -Headless

# Added / deleted — single-VI snapshot via the bundled custom operation
LabVIEWCLI -OperationName PrintToSingleFileHtml `
  -AdditionalOperationDirectory "<PrintToSingleFileHtml op>" `
  -VI "<path.vi>" -OutputPath "<out>\index.html" -o -c `
  -LabVIEWPath "...\LabVIEW.exe" -Headless

Runner composition

The job mounts two checkouts — head at C:\workspace, base at C:\workspace-base — and passes the changed-file list by env var:

docker run --rm `
  -v "${workspace}\head:C:\workspace" -v "${workspace}\base:C:\workspace-base" `
  -e CHANGED_FILES=$Env:CHANGED_FILES -e "GITHUB_REPOSITORY=${{ github.repository }}" `
  $Env:LABVIEW_CONTAINER_IMAGE `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\workspace\.github\labview\vidiff.ps1" `
    -BaseDir "C:\workspace-base" -HeadDir "C:\workspace" `
    -ReportDir "C:\workspace\ci-out\vidiff" -LabVIEWPath "...\LabVIEW.exe"

Output & dashboard

PrintToSingleFileHtml The custom operation under .github/labview/PrintToSingleFileHtml/ is a binary .lvclass sourced from NI's labview-for-containers examples; it renders a single VI (front panel + block diagram) to a standalone HTML file and powers both added/deleted diffs and snapshots.

16.4 Unit Tests — a fully worked example #

Unit Tests is documented end-to-end here as the reference capability. Every other activity shares the anatomy above, so if you can reproduce Unit Tests from the detail below — the frameworks, the exact CLI commands, the container invocation, and the output contract — you can reproduce any of them.

At a glance

In-container driver.github/labview/run-unit-tests.ps1 — runs each enabled framework headless, writes one JUnit XML per tool.
Host report builder.github/labview/build-unittest-report.py — merges the JUnit XML into results.json + the navigable HTML report.
Per-commit workflowunit-tests-windows-container.yml on windows-2022.
History backfillunit-tests-backfill-windows.yml + unit-tests-backfill.ps1 — one warm container across many revisions.
Output & statusReport deploys to unit-tests/<sha>/ on gh-pages; commit-status context is CI / Unit Tests.
PlatformWindows only — every framework depends on baked VIPM/UTF tooling (§15).

The four frameworks & the exact headless command each runs

Each enabled tool is driven by a per-tool command template that run-unit-tests.ps1 applies verbatim after token substitution. Any template can be overridden per tool with a command: key in the config, so a precise invocation can be corrected on a real worker with no script change.

FrameworkTest unit it discoversRuns viaDefault command template
Caraya (JKI)VIs containing Caraya assertions, under a directoryg-clig-cli --lv-ver {ver} -- caraya -- --directory "{dir}" --junit "{out}"
LUnit (Astemes)Test Case classes (.lvclass), under a directorynative LabVIEWCLI"{cli}" -LogToConsole TRUE -OperationName LUnit -Path "{dir}" -ReportPath "{out}" -LabVIEWPath "{lv}" -Headless
VI Tester (JKI)xUnit TestCase classes, under a directory (scaffold)g-clig-cli --lv-ver {ver} -- vitester -- --directory "{dir}" --junit "{out}"
NI UTF.lvtest items inside a .lvprojnative LabVIEWCLI"{cli}" -LogToConsole TRUE -OperationName RunUnitTests -ProjectPath "{proj}" -JUnitReportPath "{out}" -LabVIEWPath "{lv}" -Headless

Tokens the driver substitutes: {ver} = LabVIEW year, {dir} = a resolved test-root directory, {proj} = a resolved .lvproj, {out} = the JUnit output path, {lv} = LabVIEW.exe, {cli} = LabVIEWCLI.exe.

Directory-based tools (Caraya, LUnit, VI Tester) run once per resolved test-root. UTF runs a whole .lvproj, so each location resolves to its owning project and is robust to however a repo is laid out: a location may be a .lvproj path, a folder that contains one or more test-bearing projects, or a folder of .lvtest files whose .lvproj lives in a parent directory (the driver walks up to the nearest owning project). An empty locations list discovers and runs every test-bearing .lvproj anywhere in the repo, so a fresh install reports results for whatever projects exist.

Configuring which frameworks run

The configurator writes a unitTests block into .github/labview-ci.yml; the driver reads it directly. Enable a tool, point it at one or more locations, and optionally override its command or the dashboard pass-rate thresholds:

unitTests:
  thresholds:
    passedPercent:            # colours the dashboard Unit Tests badge
      greenAtLeast: 100
      yellowAtLeast: 80
      redBelow: 80
  tools:
    utf:
      enabled: true
      locations:
        - "example/User-Defined Test/"   # a dir, a glob, or a .lvproj (empty = whole repo)
    caraya:
      enabled: false
      locations: []
    lunit:
      enabled: false
      locations: []
      # command: '...'                    # optional per-tool override
    vi-tester:
      enabled: false
      locations: []

Only tools with enabled: true run. A location may be a directory (recursed), a glob / extension pattern (each match’s parent directory becomes a test root), or — for UTF — a .lvproj path.

How the workflow composes the runner (per commit)

The whole job runs on one windows-2022 runner and follows a fixed step order (it mirrors VI Analyzer so the two behave identically):

  1. Determine the target SHA — from the workflow_dispatch input, the PR head, or github.sha.
  2. Post a pending status (CI / Unit Tests) so the dashboard cell spins immediately.
  3. Check out the target SHA (fetch-depth: 0); on a dispatched historical run, restore the current CI runtime (.github/labview, .github/workflows, publish-gh-pages) from the default branch so old commits use today’s builders.
  4. Resolve the container imageconfig.container.actions.unit-testsconfig.container.use → the shared worker :latest (base/none forces the bare NI image).
  5. Ensure Docker, log in to GHCR, and wait for the worker-image build (wait-for-worker-image.sh) when this commit changed a monitored dependency.
  6. Pull the image, then run the driver inside a throwaway container (below).
  7. Build the report on the host (Python), upload the artifact, deploy to gh-pages under unit-tests/<sha>/, and post the final commit status.

The container invocation is the heart of it — a single docker run --rm that bind-mounts the checkout and launches the driver under PowerShell:

docker run --rm `
  -e "GITHUB_REPOSITORY=${{ github.repository }}" `
  -v "${{ github.workspace }}:C:\workspace" `    # the checkout, mounted in
  $Env:LABVIEW_CONTAINER_IMAGE `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\workspace\.github\labview\run-unit-tests.ps1" `
    -WorkspaceRoot  "C:\workspace" `
    -ResultsDir     "C:\workspace\ci-out\unit-tests\results" `
    -LabVIEWVersion "2026" `
    -LabVIEWPath    "C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe"

Because ci-out/… lives under the bind-mounted workspace, the per-tool JUnit XML the driver writes appears back on the runner host for the report step. (The backfill path cannot rely on that — see below.)

Invoking LabVIEW headless inside the container

run-unit-tests.ps1 prepares the headless environment before running any framework:

The output contract

The container emits raw JUnit XML; the host builder normalises it. Two files matter:

The commit status is derived straight from results.json so it always matches the report:

The dashboard colours the Unit Tests badge from the passedPercent thresholds above (green at or above 100%, yellow at or above 80%, red below 80%). When Linux results are present the cell shows the same two-number Windows/Linux pill as Mass Compile.

Warm-container history backfill

Backfilling a long history one revision at a time would pay the multi-GB pull and the slow Windows LabVIEW cold-start once per revision. unit-tests-backfill.ps1 instead:

This is the opt-in warm path from §14: reusing one container means its mutable state carries across revisions, so it is never the default.

If the worker lacks a framework’s tooling A framework whose CLI add-on is not baked into the image fails to load: UTF/LUnit surface LabVIEW CLI error -350053 (“missing or bad files in the operation folder”). The driver records this in _tooling.json and the report renders an explicit “the selected container is missing this dependency” banner instead of a bare “no tests found.” The UTF JUnit reporter in particular is mandatory — see §15.

16.5 Antidoc #

At a glance

Driverrun-antidoc.ps1; report build-antidoc-report.py
Workflowrun-antidoc-windows-container.yml windows-2022; backfill antidoc-backfill-windows.yml
Triggerpush to the default branch + dispatch (no per-PR run — it’s a heavier render)
Output & statusantidoc/<sha>/; status CI / Antidoc
Dependenciescommitted .github/labview/antidoc/antidoc.vipc (the CLI + wovalab_lib_antidoc_add_on_labview_project_documentation)

What it runs (headless command)

Wovalab’s Antidoc CLI, driven through g-cli against the project’s .lvproj (auto-detected), producing AsciiDoc with embedded diagram images:

g-cli -v --lv-ver 2026 --timeout <ms> antidoc -- `
  -addon lvproj -pp "<project.lvproj>" -t "<title>" -out "<docdir>"

The tool is invoked as the g-cli tool antidoc (renamed from the older antidoccli) with -addon lvproj, so the LabVIEW-project documentation add-on must be baked into the worker alongside the CLI. Four implementation details make it work headless:

Runner composition

docker run --rm `
  -e "GITHUB_REPOSITORY=${{ github.repository }}" -e "GITHUB_SHA=${{ steps.sha.outputs.sha }}" `
  -v "${{ github.workspace }}:C:\workspace" `
  $Env:LABVIEW_CONTAINER_IMAGE `
  powershell -NonInteractive -ExecutionPolicy Bypass `
    -File "C:\workspace\.github\labview\run-antidoc.ps1" `
    -WorkspaceRoot "C:\workspace" -ReportDir "C:\workspace\ci-out\antidoc" `
    -LabVIEWPath "...\LabVIEW.exe"

The report

The report page is a two-pane in-browser viewer: a file navigator (main document, its include:: sections, every rendered image) beside a pane that shows the fully rendered document by default — Asciidoctor.js resolves Antidoc’s includes (fetched up front) and pins imagesdir at the deployed image folder — or raw source / an image preview on demand. If the renderer CDN is unreachable the raw source stays visible and every file remains downloadable.

Dependency intent is the committed .github/labview/antidoc/antidoc.vipc. As with ci-tooling.vipc, this file is authoritative and must remain VIPM-openable; optional JSON metadata may assist tooling but does not override the committed VIPC.

16.6 VI Snapshots & VI Browser #

At a glance

Driversbuild-snapshots.ps1 (orchestrator) → render-snapshots.ps1 (per-VI); gallery build-gallery.py; 2.0 engine toimages / lvctl toimages
Workflowsvi-snapshots.yml windows-2022; 2.0 vi-snapshots-json.yml Linux / vi-snapshots-json-windows.yml Windows
Output & statusvi-snapshots/ (content-addressed by-blob/<ab>/<blob>.html|.json); status CI / VI Snapshots

This is the one place that deliberately reuses a warm container. Snapshots are content-addressed by git blob SHA, so each unique VI is rendered exactly once — ever — and reused across every commit that contains it. If an older revision references a snapshot or report document not yet published, the VI Browser and framed report viewer show an in-page missing-document state with a Populate history link instead of framing a GitHub Pages 404.

The warm-container pattern

Both phases start one long-lived container and push every VI through it with docker exec — no per-VI or per-commit container churn:

docker run -d --name <name> ... $Image powershell -NoProfile `
  -Command "while ($true) { Start-Sleep -Seconds 3600 }"
docker exec <name> powershell -File C:\ops\render-snapshots.ps1 ...   # per worklist entry

Phase 1 — HTML snapshots and Windows 2.0 frames Windows

Orchestrated by build-snapshots.ps1, which starts a single long-lived container (docker run -d … while true sleep) and renders many commits through it via docker exec calling render-snapshots.ps1 — no per-VI or per-commit container churn. A TSV worklist (<blob>\t<rel-path>) drives rendering; render-snapshots.ps1 skips any blob already present in the deployed by-blob store (seeded from gh-pages) and writes a placeholder HTML for any VI that fails to render, so the gallery is never broken by a single bad VI. When config.viBrowser.positionAware includes windows and the bundled PrintToImagesJson operation is available, the same workflow also writes Windows 2.0 frame JSON beside the HTML as <blob>.windows.json. build-gallery.py emits the per-commit manifest.json (VI → by-blob HTML), the rolling commits.json, and the Windows JSON index used by the in-place browser.

Phase 2 — position-aware JSON (VI Snapshots 2.0) Windows Linux

Phase 2 is the separate VI Snapshots 2.0 capability, selectable on Windows and Linux (the classic gallery above is Windows-only). The vi-snapshots-json.yml (Linux) and vi-snapshots-json-windows.yml (Windows) workflows render position-aware frame JSON for the in-place browser. The render engine is baked into the Linux worker image (and runs in the stock NI image on Windows) — a Go batch runner (main.go) reads the same TSV worklist and shells out to lvctl toimages — a native Go VI-Server TCP client that launches LabVIEW under Xvfb, captures the front panel and block diagram as positioned frames, and emits a JSON array. Output is written to by-blob/<ab>/<blob>.json, deployed alongside the 1.0 HTML so any VI without JSON gracefully falls back to the classic view.

Both 2.0 renderers auto-run on every new revision via a workflow_run trigger after the snapshot pipeline finishes, each behind a gate job that reads config.viBrowser.positionAware: the Linux workflow runs when it includes linux, the Windows workflow when it includes windows (the configurator default). Because renders are content-addressed by git blob SHA, the worklist skips every VI whose blob already has a render — unchanged VIs reuse their existing frames with no work — and queues only the new/changed blobs, so a VI a revision actually changed gets a fresh render automatically.

The browser now remembers its viewer mode and 2.0 platform choice in local storage, defaults to 2.0 when a VI has a 2.0 render (preferring the Windows render when both platforms exist), and shows dismissible coaching tip strips for the block-diagram toggle and the case/event selector arrows. The 2.0 auto-default is now enforced on open whenever a 2.0 render exists (unless the URL explicitly pins viewer/plat2). Those tips are browser-local too: dismissing one with Never show this again hides it only in that browser profile, and the dismissal keys are versioned so a new coaching rollout can deliberately surface the tips once more for everyone.

17. Report data contracts #

Each capability writes a small, stable JSON document that the dashboard and the shared header consume. The most useful ones to know when extending or debugging:

FileProduced byRead by
masscompile/<sha>/summary.jsonMass Compile report builderDashboard cell (pass %, counts)
vi-analyzer/<sha>/summary.jsonVI Analyzer report builderDashboard cell
vi-analyzer/<sha>/linux/summary.jsonVI Analyzer report builder (Linux)Dashboard cell (Windows / Linux split)
vidiff/<segment>/<platform>/vidiff/changes.jsonVIDiffDashboard + VIDiff report
unit-tests/<sha>/results.jsonUnit-test report builderDashboard + report
builds/<sha>/summary.jsonBuilds report builderDashboard cell (succeeded/failed split) + report
builds/<sha>/summary.jsonBuilds report builderDashboard cell (succeeded/failed split) + report
vi-snapshots/<sha>/manifest.jsonSnapshot gallery builderVI Browser
dependencies/index.jsonDashboard publisherDependencies page install-time inventory
workers/<platform>/<version>/manifest.jsonWorker image workflowsDependencies page installed-package status
catalog.json (root)Installer / integrate-deployVersion badge, What's New

A per-revision report is considered "available" when its summary.json exists at <prefix>/<sha>/. The header's re-run action ("Re-run analysis", "Regenerate report", etc.) opens the dashboard's Populate history chooser pre-scoped to exactly the report on screen — that activity set to Re-run, every other activity to Skip, and the revision scope narrowed to the viewed commit (resolved via currentRevisionSha(), with the platform when the activity is platform-split). On the dashboard the chooser opens inline; on a report page it opens in a floating overlay iframe that loads the dashboard with ?lvci-populate=1&lvci-embed=1&cap&sha&platform — embed mode hides the dashboard chrome so only the dialog shows, and the user stays on the report page (the framed dashboard posts lvci:hist-close when the dialog closes, dismissing the overlay). Queuing dispatches the capability's workflow with inputs.commit_sha; the dashboard's optimistic queued bridge keys on <cap>|<sha>. The pre-select matches the viewed revision against the dialog's history via histIdxIn(HIST, sha). These conventions are codified in the DOCTYPES table in lvci-header.js — adding a new report type is a single entry there plus emitting the matching window.LVCI config from its generator.

18. Versioning & updates #

release.yml fires when catalog.json changes on main. It cuts an immutable tag (v3.24.5) and moves the major/minor aliases (v3, v3.24) so consumers pinned at @v3 receive improvements automatically, while consumers pinned at an exact version never change unless they act. Release notes are generated from history.releases[0].

Clients update in one of two ways:

The browser Apply to New Repo flow (re-applied on top of an existing install) mirrors the same prune: when it re-vendors a tooling directory it deletes any target file the source no longer ships, except the consumer's own preserved config — so re-applying after a file is removed upstream stays in sync with Update now and never leaves an orphan (e.g. an obsolete Go source) that breaks a build.

reconfigure.yml backs the Configure Pipeline dialog: it rewrites .github/labview-ci.yml from a workflow_dispatch form and opens a reviewable pull request rather than committing directly.

Release channels (Dev / Beta / Stable)

Every published build is a Dev build by default (the tooling ships many a day). The maintainer can then mark a build up two tiers: Beta (a release candidate — “might be good enough”) and Stable (a release, ready for clients). The catalog carries this as two optional top-level pointers — betaVersion and stableVersion — plus a "beta": true / "stable": true flag on each marked release entry. Marking never builds anything: it blesses an already-published, immutable v<version> release, and release.yml force-moves a rolling beta and stable git tag to the marked commits.

The install and What's New dialogs offer a cumulative channel picker — Release only (stable), Release + beta (beta), or Release + beta + dev (dev) — stored per browser in lvci_install_channel / lvci_update_channel. The header's update indicator and the What's New compare resolve the channel target the same way: dev = latest published; beta = newest of betaVersion/stableVersion; stable = stableVersion. Everything gracefully falls back to the latest published version while a tier hasn't been marked yet. The install dialog fetches the target version's own catalog so its version, capabilities, and vendored files are all consistent at the tag being installed.

The maintainer marks builds from the per-release Mark as beta / Mark as stable buttons on their own source repo's What's New page. They dispatch promote-release.yml (owner-only — GitHub requires Actions: write on the source repo) with a tier input, which verifies the v<version> tag exists, runs the catalog/source-sync gate, edits the catalog via promote-release.py --tier (set the pointer, flag the entry, bump the version), and commits to the default branch; the same buttons un-mark to roll a channel back to the previous build in that tier.

Relocation pointer

.github/labview-ci/source.json lets the project migrate (for example to an official NI repo) without breaking installs: if it names a different repo than the current one, the bootstrappers and the update workflow follow it automatically, and a redirected update re-pins the consumer's manifest to the new home.

19. Client discovery #

The Clients page is populated by discover-clients.yml / discover-clients.py, which run only in the source repository on a daily schedule (or on demand). Discovery is deliberately pull-based — your repo is never modified and never phones home. It queries GitHub's public search index for three signals an install necessarily leaves behind:

  1. the labview-ci repository topic (the primary, structured signal);
  2. this repo's slug as source.repo in a client's catalog.json;
  3. a uses: reference back to this repo in a client's workflows.

Every candidate is verified (public, and catalog actually points back here) before listing, and only public metadata GitHub already exposes is shown. A fork is listed when it is a genuine install (a fork of another project that added this stack); a fork of this framework repo itself is not, since it carries our own catalog verbatim. Private repositories are never indexed or listed. See the FAQ discovery entry for the privacy details.

20. Extending the system #

Adding a capability is intentionally cheap. Using "Unit Tests" as the worked example:

  1. Write the workflow(s) — e.g. unit-tests-windows-container.yml (and a Linux variant if applicable), following the pull-image → docker run --rm → host report → deploy → post-status shape.
  2. Write the driver + report builder — e.g. .github/labview/run-unit-tests.ps1 and build-unittest-report.py, emitting a summary.json/results.json under <prefix>/<sha>/.
  3. Add one catalog entry — id, name, status, supportsOs, requires, statusContext, and the files map. The configurator, installer, and dashboard pick it up generically.
  4. Register the report type — add a single entry to DOCTYPES in lvci-header.js and emit the matching window.LVCI from the report generator so the header's revision picker, regenerate action, and status text work.

No edits to UI rendering, installer logic, or the dashboard generator are required — the catalog and the DOCTYPES/statusContext conventions carry the wiring.

Keep this page in lock-step Whenever you add a capability, change container behaviour, or "learn" how to make something work (for example a new VIPM/headless trick), update this documentation in the same change. The FAQ covers the "what"; this page is the canonical "how", and it is expected to stay accurate. This rule is enforced for contributors and AI assistants alike — see the repository's contributor and Copilot instructions.

21. Security model #

22. Constraints & gotchas #

23. Workflow & runner reference #

Every job in this system runs on exactly one of two GitHub-hosted runner images, and the choice is mechanical: a job runs on windows-2022 only if it drives LabVIEW inside a Windows Docker container; everything else — orchestration, gating, Pages deploys, releases, Linux containers, discovery, and maintenance — runs on ubuntu-latest, which is faster to start and cheaper. The lightweight decision/publishing work is deliberately kept off the Windows runner so the slow, expensive Windows image only spins up for the one thing that genuinely needs it.

Why windows-2022, never windows-latest windows-latest now resolves to Windows Server 2025, whose Docker daemon is broken for these LabVIEW containers. Every Windows job pins windows-2022 explicitly.

Which runner OS a job uses

flowchart TD A["A workflow job needs to run"] --> B{"Runs LabVIEW inside a<br/>Windows Docker container?"} B -->|yes| W["windows-2022<br/>Windows container build"] B -->|no| U["ubuntu-latest<br/>orchestration, config gating<br/>Pages deploys, releases<br/>Linux containers, discovery<br/>maintenance, image publishing"]

Activity fan-out — the reusable workflow gates every job

labview-ci.reusable.yml is where repo settings decide which jobs run. A single config job (Ubuntu) reads .github/labview-ci.yml once and exposes capability + platform flags; a gate-images job (Ubuntu) then blocks the dependency-consuming activities until the worker image for this commit is rebuilt. Each activity job runs only when both its capability flag and its platform flag are true — toggling either in Configure Pipeline turns the job on or off with no workflow edits.

flowchart TD T["commit / PR / workflow_dispatch"] --> C["config · ubuntu-latest<br/>reads .github/labview-ci.yml"] C --> G["gate-images · ubuntu-latest<br/>waits for this SHA's worker-image rebuild"] G --> M1["mass-compile · windows-2022<br/>if has-masscompile and os-windows"] G --> M2["mass-compile · ubuntu-latest<br/>if has-masscompile and os-linux"] G --> V["vi-analyzer · win / linux<br/>if has-vi-analyzer and os-platform"] G --> D["vidiff · win / linux<br/>if has-vidiff and os-platform"] G --> S["snapshots · windows-2022<br/>if has-snapshots and os-windows"]

Image build — gate, coordinate, then the heavy lift

The worker image build (build-labview-image.yml, “Build LabVIEW CI Image”) is a three-job run that bakes project dependencies — it is distinct from the copy-labview-image.yml install seed described earlier, which only copies the base. It runs on an explicit dispatch (the dashboard’s “Review & update dependencies” dialog or a manual run), the monthly base-image cron, or a push that changes a monitored .vipc / .dragon file. The gate job enforces this opt-in: on a push it rebuilds only if a changed dependency file is flagged monitor: true in .github/labview-ci.yml, and skips otherwise. The staging step follows the same list: after a monitor list exists, only monitor: true entries are baked; before any list exists, a manual dependency build discovers all project .vipc / .dragon files so the fresh-repo default selections can be applied. The two Ubuntu jobs decide whether to rebuild and safely when; the final build-and-push job always runs on windows-2022 (it needs the Windows Docker daemon to bake the dependency layer), falling back to a plain crane copy of the base on that same runner if a dispatch declares no dependencies at all.

flowchart TD T["dispatch (dashboard 'update dependencies') / monthly cron"] --> G PUSH["push to a .vipc / .dragon"] --> G{"gate · ubuntu-latest<br/>dispatch/cron: rebuild<br/>push: only if a MONITORED file changed"} G -->|"rebuild"| CO["coordinate · ubuntu-latest<br/>drain or cancel in-flight CI"] G -->|"push, nothing monitored"| SKIP["skip — no rebuild"] CO --> B["build-and-push · windows-2022"] B --> P{"Any dependency to bake?"} P -->|"vipc/dragon (bake) · windows-2022"| BD["build dependency layer — slow"] P -->|"none (fallback) · windows-2022"| CP["crane copy / tag base — a minute or two"] CP --> PU["push tags → worker manifest → gh-pages"] BD --> PU INS["install seed (separate workflow)"] --> CW["copy-labview-image.yml · ubuntu-latest<br/>crane copy / tag base"] CW --> PU
Linux image builds mirror Windows The Linux worker uses the same two-action split as Windows, on ubuntu-latest (Linux containers build on a Linux runner). The install seed is its own workflow, copy-labview-linux-image.yml, which the installer dispatches to crane-copy the shared LCWC Linux base (…-labview:linux-2026) into the client's own package — a fast registry copy, not a rebuild, so a Linux install never scales with the number of .vipc / .dragon files. build-labview-linux-image.yml is bake-only: a project dependency change does not rebuild the worker by default (the “dependencies need to be installed” banner prompts an on-demand update), and a push rebuilds automatically only when it changes a monitored .vipc — exactly the opt-in behavior of the Windows worker. It also accepts the same update_mode dispatch input (replace rebuilds the moving linux-latest/linux-<year> tags in place; new publishes a fresh <tag>-deps-<date> tag alongside without moving them). (The Linux Beta builds directly on ubuntu-latest with no separate gate; the VI Browser 2.0 render engine is now baked into the Linux worker, so there is no separate render image.)
On-demand updates cover both workers The Dependencies page’s “Run the container update” flow lets you rebuild the Windows worker, the Linux worker, or both: a “Containers to rebuild” pair of checkboxes is pre-checked from the pending signal but you can select either or both explicitly. It then dispatches build-labview-image.yml for Windows and/or build-labview-linux-image.yml for Linux, each with the selected update_mode, and its live progress bar tracks every dispatched run at once. The deps-pending.json contract drives the defaults: the dashboard compares each project VIPC’s packages — and each installed capability VIPC (e.g. Antidoc), limited to the platform(s) that capability’s worker runs on (Antidoc = Windows) — against the per-platform baked worker manifest, so its containers array lists windows and/or linux independently (a Linux gap is reported only when a real, non-base Linux worker manifest resolved for the revision), and vipcs is the union of the files missing packages on either worker. The dependency table evaluates each column the same way: a package that is monitored for a platform but not yet baked into that worker shows as missing in that column (Linux is evaluated exactly like Windows), while a package that is simply not monitored for the platform stays N/A. Package matching is version-tolerant — a baked build satisfies a differently-versioned VIPC request of the same package (VIPM installs one version per package) — and each VIPC file’s header row rolls the same per-package result up into an N missing / installed pill, so you can see which file is short a package without expanding it. A push that adds or changes any .vipc/.dragon file now rebuilds the dashboard as well, so a newly declared — but not-yet-baked — package surfaces as missing and the “dependencies need installing” banner appears immediately, without waiting for a worker build or a commit status.
The dashboard refreshes after a build A completed worker build (Build LabVIEW CI Image / Build LabVIEW CI Image - Linux) is in the dashboard’s workflow_run triggers, so finishing a container update rebuilds the dashboard and regenerates deps-pending.json against the freshly published worker manifest — the “dependencies need installing” banner clears on its own once the update lands, with no manual refresh. (Publishing the worker manifest to gh-pages does not itself trigger a rebuild; the build workflow completing is the signal.) VIPC files under .github/ are treated as internal tooling — the build’s “Stage repo VIPC files” step never bakes them, so they are shown as N/A on the worker columns rather than as a false missing dependency.

Complete runner inventory

Every workflow in the repository, the runner(s) it uses, what triggers it, and the conditions under which it runs or is skipped. windows-2022 = Windows container work; ubuntu-latest = everything else.

Container activities

WorkflowRunnerTriggersRuns when / skips
Mass Compile — Windows Containerwindows-2022PR / push on VI source (**.vi/.ctl/.lvproj/.lvlib/.lvclass, excl. .github/), dispatchCompiles every VI; skipped when only tooling under .github/ changed.
Mass Compile — Linux Containerubuntu-latestsame as aboveLinux counterpart; deploys under masscompile/<sha>/linux/.
Run VI Analyzer — Windows Containerwindows-2022PR / push VI source, dispatchFull .viancfg suite incl. per-path rule subsets.
Run VI Analyzer — Linux Containerubuntu-latestsame as aboveLinux counterpart; deploys under vi-analyzer/<sha>/linux/.
VIDiff Report — Windows Containerwindows-2022PR / push **.vi/.ctl, dispatchDiffs changed VIs; runs on the bare NI image (no custom worker).
VIDiff Report — Linux Containerubuntu-latestPR / push **.vi/.ctl, dispatchLinux counterpart.
Run Unit Tests — Windows Containerwindows-2022PR / push (VIs, **.lvtest), dispatchWindows-only (depends on baked VIPM/UTF tooling).
Run Antidoc — Windows Containerwindows-2022push to default branch, dispatch (no PR)Windows-only; heavier doc render, so no per-PR run.
VI Snapshots and VI Browserwindows-2022push VI source (excl. .github/), dispatch (head / backfill)Renders HTML snapshots + Windows 2.0 frames in one warm container.
VI Snapshots JSON (VI Browser 2.0, Linux)ubuntu-latestworkflow_run after snapshots, dispatchGate job decides; auto-runs only when positionAware includes linux.
VI Browser 2.0 render (Windows container)windows-2022workflow_run after snapshots, dispatchGate job decides; auto-runs only when positionAware includes windows (the configurator default). Additive Windows frames via COM in the stock NI image.

Image builds

WorkflowRunnerTriggersRuns when / skips
Build LabVIEW CI Image (Windows worker)ubuntu-latest gate + coordinate, windows-2022 builddispatch (dashboard “Review & update dependencies” or manual), monthly cron, and a push that changes a monitored .vipc/.dragonDependency bake. On a push the gate rebuilds only if a changed dependency file is monitor: true; otherwise it skips. Always builds on windows-2022; with no dependencies declared it falls back to a plain crane copy of the base.
Copy LabVIEW CI Image (install seed)ubuntu-latestdispatch (the installer dispatches it on a fresh install)Seeds a fresh worker by copying the LCWC base into the repo’s GHCR with crane — no LabVIEW build; the install-time counterpart to the dependency build above.
Build LabVIEW CI Image — Linuxubuntu-latest (gate + build)dispatch (dashboard or manual), monthly cron, and a push that changes a monitored .vipcDependency bake (mirrors the Windows worker); also bakes the VI Browser 2.0 render engine into the worker. On a push the gate rebuilds only if a changed .vipc is monitor: true; otherwise it skips.
Copy LabVIEW CI Image — Linux (install seed)ubuntu-latestdispatch (the installer dispatches it on a fresh install)Seeds a fresh Linux worker by copying the LCWC Linux base into the repo’s GHCR with crane — no build; the Linux counterpart to the Copy workflow above.
Bake VIPC Windows Base Imagewindows-2022dispatch onlyProof-and-promote: verifies a VIPC installs before pushing tags.

Publishing & release

WorkflowRunnerTriggersRuns when / skips
CI Dashboard — GitHub Pagesubuntu-latestpush page assets, status event, workflow_run completions, dispatchLatest-wins rebuild; reacts to every capability's status with no enumeration.
Integrate Configurator — Pagesubuntu-latestpush configurator / catalog.json, dispatchPublishes Apply / Configure / What's New + catalog.
VIDiff Deploy — Pages + PR Commentubuntu-latestworkflow_run after VIDiff reportsDeploys the diff and comments on the PR (runs even on failure).
Catalog Source Syncubuntu-latestPR / push to catalog, docker, or build workflow; dispatchFails if installer file lists drift from the source-owned worker files.
Releaseubuntu-latestpush catalog.json on main, dispatchTags v<x.y.z> and moves the v<major>/v<major.minor> aliases.

Maintenance & diagnostics

WorkflowRunnerTriggersRuns when / skips
Discover Clientsubuntu-latestdaily cron, dispatchSource repo only (if: github.repository == source).
Apply LabVIEW CI updateubuntu-latestdispatch (the What's New "Update now")Runs install.py --update; commits to default branch or opens a PR.
Reconfigure LabVIEW CIubuntu-latestdispatch (Configure Pipeline)Rewrites labview-ci.yml and opens a reviewable PR.
VIDiff Backfill — Windows Containerwindows-2022dispatchWalks VI history rendering diffs through a warm container; resumable.
VIDiff Backfill — Linux Containerubuntu-latestdispatchLinux counterpart of the backfill.
Example project CI (composite-action demo)ubuntu-latest config + windows-2022 activitiesdispatchDogfoods the local ./actions path against the example project.
toimages COM probe (diagnostic)windows-2022dispatchConfirms COM-driven rendering in the stock NI image; touches nothing in production.
Self-hosted runners Both runner labels default to GitHub-hosted but can be redirected to self-hosted labels via the reusable workflow's windows-runner input or Actions variables. A self-hosted runner additionally keeps a warm Docker layer cache, so the multi-gigabyte worker image is not re-pulled every job (see §22).

← Back to the FAQ  ·  View the source on GitHub →