The single guide for this repo's CI/CD workflows (GitHub Actions): code style, architecture, a
behavioral contract (expected inputs and outputs), and a test methodology. Source code style lives
in CODESTYLE.md. This file covers everything under
.github/workflows/.
It describes required outcomes, not a required implementation. A workflow is correct when it satisfies the contract (section 4), whatever shape its YAML takes. Section 2 keeps workflows legible. Section 3 is the model. Section 4 is what they must do. Sections 5 and 6 are how to verify it and the configuration it assumes. Each guarantee names the failure it prevents, so the reason survives a reimplementation.
NxWitness ships Docker images only: a multi-product, multi-base set of multi-arch images on Docker Hub
(five Nx VMS products x {plain, LSIO} = ten product images, plus two shared base images nx-base /
nx-base-lsio, across 12 Docker Hub repositories). There is no NuGet publish: the .NET project
(CreateMatrix) is a build-time matrix generator only, not a shipped package. Three things do the work:
- CI runs on push to every branch: it validates (unit tests + lint) and smoke-builds a representative image subset, publishing nothing. A pull request merges only when its required check is green.
- The publisher is a triggered-Docker workflow, one run = one branch. It runs on a weekly schedule
(rebuilds
mainonly - full product matrix, shared base refresh for CVEs, versioned release), on a path-scoped push tomainwhen codegen commits a newMake/Matrix.jsonpin (publishes the new product versions at once), and on manual dispatch (publishes the branch it is started from:main->latest/stable,develop->:develop). It never runs on an ordinary merge. - Codegen runs daily, dual-targeting
mainANDdevelop: it regeneratesMake/Version.json+Make/Matrix.jsonfrom upstream Nx product versions and opens a PR against each base, which the merge-bot auto-merges. A merge tomainwhose matrix changed triggers the publisher's path-scoped push.
There is no publish-on-merge for code and no two-branch matrix in the publisher - building only the trigger
branch keeps github.ref aligned with the branch being versioned. Dependabot pull requests merge themselves
once their checks pass.
- Entry workflow - has
push/schedule/workflow_dispatchtriggers. The orchestrator that an event or a person starts. - Reusable workflow (task) - a
workflow_callworkflow invoked through auses:reference, never triggered directly. File ends in-task.yml. - Product image - one shipped image built from a
Make/Matrix.jsonrow's Dockerfile (e.g.NxMeta,NxMeta-LSIO), pushed to its own Docker Hub repo (docker.io/ptr727/<image>). - Shared base image -
nx-base/nx-base-lsio, built once and reused as theFROMfor the product images. Its tag (:ubuntu-noble) is branch-agnostic (one shared tag, not per-branch). - Matrix / version inputs -
Make/Matrix.json(the per-product image build rows: name, Dockerfile tags, build args, branch) andMake/Version.json(the upstream Nx version pins), both regenerated by codegen. - Smoke build - a CI build that compiles a representative image subset (NxMeta + NxMeta-LSIO, amd64) to
prove the Dockerfiles still build, publishing and pushing nothing. Driven by a
smoke: trueinput. - Transfer artifact - a workflow artifact handing data between jobs of one run. The durable copy lives on the GitHub release / Docker Hub.
- Threaded version - the single NBGV
SemVer2(plusGitCommitId) computed once inget-version-taskand passed down assemver2/refinputs to every consumer, never recomputed in a build task. - GitHub App token - a short-lived installation token from
actions/create-github-app-token, minted from the App credentials (CODEGEN_APP_CLIENT_ID/CODEGEN_APP_PRIVATE_KEY). The merge-bot and the codegen PR-opener use it, notGITHUB_TOKEN: aGITHUB_TOKENpush does not trigger downstream workflows, and that token is read-only on Dependabot pull requests.
- Contract, not implementation. Conform to the outcomes in section 4 and the architecture in section 3. Job names and file layout may vary; the input/output behavior may not.
- "Operational" - the one definition. The repo is operational when every applicable section-4 guarantee holds, every applicable section-5B scenario's observed output equals its expected output (corroborated by a 5C live probe where a live signal exists), and the section-6 configuration is in place. Anything else is not operational.
- Defect vs N/A. An item is N/A only when this repo has no such concern (e.g. a fork-PR scenario, since a fork cannot push here, or a NuGet-publish guarantee, since this repo publishes none). A construct required by an applicable guarantee but absent is a defect.
- Default branch is
main. Guarantees say "default branch" portably. This repo writes the literalmainin the publish guards and the release-version backstop, and the anchored^refs/heads/main$inversion.json'spublicReleaseRefSpec.
Legibility rules. Necessary but not sufficient: a perfectly styled workflow can still violate section 4.
- Action pinning. Pin every action to a commit SHA with a trailing
# vX.Y.Zcomment. Use# vXonly when the upstream floating major tag has no specific patch SHA. Sole exception:dotnet/nbgv@masteris consumed via the floating@masterref, never SHA-pinned - its tag stream lagsmastersubstantially, so Dependabot tag-tracking would only propose downgrades to stale tags. The rationale lives in an inline comment inget-version-task.yml; leave that comment intact. A tool an action installs (not auses:ref) is left unpinned to track latest. - Filename. Reusable workflows end in
-task.yml; entry workflows end in what they do (-pull-request.yml,-release.yml). A-task.ymlisuses:-d, never triggered directly. - Workflow
name:. Reusable names end in "task", entry names in "action". - Job and step
name:. Every jobname:ends in "job", every stepname:in "step", the aggregator included (Check pull request workflow status job). A job name also bound as a ruleset required-checkcontext:is codified inrepo-config/and changed only in lockstep with the live ruleset. - Concurrency. Every entry workflow declares a
concurrencygroup. CI usesgroup: '${{ github.workflow }}-${{ github.ref }}',cancel-in-progress: true. The publisher overrides it: a ref-independent group withcancel-in-progress: false, so two publishes never overlap (a schedule, a dispatch, and aMake/Matrix.jsonpin push, or back-to-back dispatches against the shared Docker tags) and none is cancelled mid-release. Codegen keys on the workflow only (it writes fixedcodegen-*branches). - Shells. Every multi-line bash
run:starts withset -euo pipefail. - Conditionals. Multi-line
if:uses the folded scalarif: >-. - Boolean inputs. A boolean used by both
workflow_callandworkflow_dispatchis declared in both trigger blocks and compared againsttrueand'true'. - Reusable-workflow permissions. Job-level
permissions:are validated beforeif:, so even a skipped job needs valid permissions. Grant least privilege; a callee's extra scope is granted by the caller. - Allowlist
successandskippedexplicitly across an optional dependency: use(needs.X.result == 'success' || needs.X.result == 'skipped'), not!= 'failure'. - Line endings. Workflow YAML follows
.editorconfig(CRLF). Preserve on every edit.
CI (test-pull-request.yml) and the publisher
(publish-release.yml) are separate workflows with separate
concurrency, so they never race. CI re-tests every pushed tree and never publishes; the publisher releases on
its own triggers and never runs on an ordinary merge. Codegen
(run-periodic-codegen-pull-request.yml ->
run-codegen-pull-request-task.yml) keeps the
version/matrix data current. Prevents a merge from silently cutting a release, and a CI run from racing a
publish on the same ref.
A publish builds exactly one branch - the run's trigger ref. The schedule and the pin push both
run on main; a dispatch runs on the branch it is started from (main or develop). The jobs pass
github.ref_name as both ref and branch, so the branch built, versioned, and tagged is always the run's
own ref. No matrix and no cross-branch ref mixing - github.ref is the branch being published. The jobs
are guarded to the long-lived branches (main / develop); a stray dispatch from a feature branch is a
no-op. To refresh :develop, dispatch the workflow from develop.
Because the run's ref is the built branch, GitHub resolves the local uses: ./... reusable workflows
from that same branch's commit - so a develop dispatch runs develop's own task definitions, and the
schedule runs main's.
The publisher's push trigger is branch-filtered to main and path-filtered to Make/Matrix.json. So an
ordinary code merge never publishes; only a change to the codegen matrix pin (committed to main by the
auto-merged codegen-main PR) does, shipping the new upstream Nx product versions immediately. develop's
daily codegen pin update is sync-only - the push trigger is main-only - and never publishes; :develop is
refreshed by manual dispatch. The weekly schedule still runs with no matrix change, to refresh the shared
base image for CVEs.
The publisher decomposes into a single get-version -> build-base -> build-docker ->
github-release -> docker-readme -> cleanup-artifacts chain (a multi-product Docker repo, not the
template's single-target branch matrix). build-base-images-task.yml
builds the two shared bases; build-docker-task.yml builds the
product matrix from Make/Matrix.json (max-parallel: 4). The shared base is built once (on the main
run) and reused: a develop dispatch sets build_base: false and pulls main's published base, so it never
overwrites the branch-agnostic nx-base tag. The product build reads both branches' registry buildcaches
(buildcache-main, buildcache-develop) and writes only its own branch's cache, only when pushing.
NBGV runs once (in get-version-task.yml), classifying from
github.ref, and its outputs (SemVer2, GitCommitId) thread to every consumer via outputs: / needs: /
semver2 inputs. build-docker-task accepts the threaded semver2 as the image LABEL_VERSION and never
re-runs NBGV - one classification feeds every product leg, so no second NBGV run can reclassify or collide a
tag. A build job may check out a specific commit to compile it (main pins to GitCommitId) but consumes the
threaded version. main (the public ref, publicReleaseRefSpec = ^refs/heads/main$) builds a clean
X.Y.<height>; every other branch a prerelease X.Y.<height>-g<sha>. Keeps each image's embedded version
and the release tag in agreement.
NBGV classifies publicReleaseRefSpec from the GITHUB_REF environment variable. Because the publisher
builds the trigger ref (one branch per run), GITHUB_REF already equals the branch being versioned - a
schedule, a pin push, or a main dispatch classifies as public; a develop dispatch as prerelease - so no
GITHUB_REF override is needed (IGNORE_GITHUB_REF is only for matrix publishers that build a non-trigger
branch). The main-version backstop (D2.2) catches any misclassification.
NBGV's version is the GitHub release tag on main and the LABEL_VERSION build arg baked into the images.
The Docker image tags themselves carry the Nx product version from Make/Matrix.json, distinct from the
NBGV build version.
A run that carries a cross-input invariant (main must not carry a prerelease suffix) asserts it once with
::error:: before the release is published. The github-release job needs: the version job and runs the
backstop step first.
CI runs on push to every branch, so GitHub head-resolves the reusable ./... workflows from the pushed head:
a pull request that edits a reusable task tests its own copy. CI validates (the reusable validate-task:
Husky lint + dotnet test) on every push, and smoke-builds a representative image subset only when image
files changed (an inline git diff change-gate, no dorny/paths-filter), uploading and pushing nothing. One
aggregator job, the ruleset-bound required check, gates the merge. A branch-deletion push (all-zeros
github.sha) is skipped by a !github.event.deleted guard on every job, so a deletion never runs a failing
build.
The github-release job tags the built commit and creates the GitHub release (auto source zip + README +
LICENSE; target_commitish pinned to GitCommitId; skip-existing guard; main-only). This repo ships no
release-asset-* binaries or packages - the published artifacts are the Docker Hub images - so there is no
release-asset download step and fail_on_unmatched_files is omitted. The GitHub release exists only as the
version anchor / tag. The Docker Hub repository overview (the repo README.md) is pushed to every product +
base repo on a main Docker publish (the docker-readme jobs), since Docker Hub does not read the GitHub
README; the repo list is derived inline from Make/Matrix.json.
Workflow artifacts are an intra-run handoff; the durable copy lives on the GitHub release / Docker Hub. The
publisher and CI both run a terminal cleanup-artifacts job that deletes the run's transfer artifacts so
they do not accumulate against the small account-wide storage quota; it is continue-on-error so housekeeping
never reds the run.
Every Dependabot pull request, any ecosystem and any tier, auto-merges once the required checks pass, except
a semver-major NuGet bump, which waits for human review. Codegen opens a codegen-main -> main and a
codegen-develop -> develop PR daily; the merge-bot auto-merges each independently (--delete-branch).
A merged dependency bump does not itself publish; a merged matrix pin on main does (the pin-push trigger).
A person steps in only for a breaking change (a red check) or to dispatch a release.
Four diagrams trace the architecture above: the pull-request gate, the self-publisher, the bot automation, and the trigger chain that turns a daily codegen run into a published release. They depict the same outcomes that the section 4 contract specifies, drawn from the workflow YAML; if a diagram and a guarantee disagree, one of them is a defect. Triggers are blue, gates yellow, durable/published outputs green, and stop/skip outcomes red.
Pull request (CI) - test-pull-request.yml. Every push head-resolves the reusable tasks, runs the
validate gate, smoke-builds a representative image subset only when image files changed, and a single
aggregator produces the ruleset-bound required check (D1, D6).
flowchart TD
T(["push: every branch<br/>(or workflow_dispatch)"]):::trig
T --> D{"github.event.deleted?"}:::gate
D -- "yes: branch deletion" --> X(["all jobs + aggregator skip<br/>no failed run, no pending check"]):::stop
D -- "no" --> CH["changes job<br/>inline git diff change-gate<br/>image? base?"]
D -- "no" --> V["validate job<br/>(validate-task.yml)"]
subgraph VT ["validate-task.yml"]
VU["Husky lint (CSharpier,<br/>dotnet format style)<br/>+ dotnet test"]
end
V --> VT
CH --> SG{"image files changed?<br/>(Docker/**, Make/Matrix.json, Make/Version.json)"}:::gate
SG -- "no" --> SS(["smoke-build skipped<br/>(aggregator allows skip)"]):::stop
SG -- "yes" --> S["smoke-build job<br/>build-docker-task.yml<br/>smoke: true, push: false<br/>NxMeta + NxMeta-LSIO, amd64"]
CH --> A
VT --> A
S --> A
SS --> A
A{"Check pull request workflow status job<br/>changes AND validate AND smoke-build<br/>succeeded or skipped?"}:::gate
A -- "yes" --> G(["required check passes<br/>merge unblocked"]):::pub
A -- "no" --> R(["required check fails<br/>merge blocked"]):::stop
classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Publish - publish-release.yml. A weekly schedule (main), a Make/Matrix.json pin push (main), or a
dispatch versions once with NBGV, builds the shared base (main only), validates, builds the 12-image
product matrix with the threaded SemVer2, then cuts the main-only GitHub release and refreshes the Docker
Hub overviews (D0, D2, D3, D4).
flowchart TD
P1(["schedule: weekly Mon 02:00 UTC<br/>(main only)"]):::trig --> GV
P2(["push: main<br/>paths = Make/Matrix.json (codegen pin)"]):::trig --> GV
P3(["workflow_dispatch<br/>(main or develop)"]):::trig --> GV
GV{"get-version job<br/>ref_name in (main, develop)?"}:::gate
GV -- "feature branch" --> GVS(["all jobs skip<br/>no publish"]):::stop
GV -- "yes" --> GVR["get-version job<br/>(get-version-task.yml)<br/>NBGV @master, runs once<br/>SemVer2 + GitCommitId"]
GVR --> BB{"ref_name == main?"}:::gate
BB -- "develop dispatch" --> BBS(["build-base skipped<br/>reuse main's nx-base"]):::stop
BB -- "main" --> BBJ["build-base job<br/>(build-base-images-task.yml)<br/>nx-base + nx-base-lsio<br/>amd64 + arm64, branch ref (github.ref_name)"]
GVR --> VAL["validate job<br/>(validate-task.yml)<br/>main: pinned to GitCommitId"]
VAL --> BD
BBJ --> BD
BBS --> BD
BD["build-docker job<br/>(build-docker-task.yml, build_base: false)<br/>12-image matrix from Make/Matrix.json<br/>amd64 + arm64, max-parallel 4<br/>LABEL_VERSION = threaded SemVer2"]
BD --> DH[("Docker Hub<br/>10 product repos (branch tags)<br/>+ 2 shared base repos")]:::pub
BD --> RG{"ref_name == main?"}:::gate
RG -- "develop" --> RGS(["no GitHub release<br/>(:develop images only)"]):::stop
RG -- "main" --> VPR{"github-release job<br/>SemVer2 has no prerelease '-'?<br/>(strip +buildmetadata)"}:::gate
VPR -- "prerelease suffix" --> VPRX(["fail ::error::<br/>refuse to publish"]):::stop
VPR -- "clean" --> EX{"tag exists AND not dispatch?"}:::gate
EX -- "yes" --> EXS(["skip release create<br/>(no-op republish)"]):::stop
EX -- "no" --> REL[("GitHub release<br/>tag = SemVer2 at GitCommitId<br/>prerelease: false, source zip + README + LICENSE")]:::pub
BD --> DRR["docker-readme-repos job<br/>derive repo list from Matrix.json"]
DRR --> DRM["docker-readme job (matrix)<br/>push README to each Docker Hub repo"]
DRM --> DRO[("Docker Hub overviews<br/>10 product + 2 base repos")]:::pub
classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Automation - codegen + Dependabot + merge-bot. Daily codegen and Dependabot open in-repo bot PRs; the merge-bot enables auto-merge (or disables it on a maintainer push); the required check gates each merge (D8).
flowchart TD
SCH(["schedule daily 04:00 UTC<br/>(or workflow_dispatch)"]):::trig --> CG
subgraph CGT ["run-codegen-pull-request-task.yml (matrix: main, develop)"]
CG["codegen job per branch<br/>regenerate Version.json + Matrix.json<br/>(deterministic, forward-only guard)"] --> CGC{"data changed?"}:::gate
CGC -- "no" --> CGN(["no PR"]):::stop
CGC -- "yes" --> CPR["open codegen-<branch> PR<br/>(App token)"]
end
DEP(["Dependabot opens PR<br/>any ecosystem/tier"]):::trig --> MB
CPR --> MB
subgraph MBT ["merge-bot-pull-request.yml (pull_request_target)"]
MB{"event / author"}:::gate
MB -- "opened/reopened<br/>bot author" --> EN["enable auto-merge --delete-branch<br/>squash develop / merge main"]
MB -- "synchronize by maintainer" --> DIS["disable auto-merge"]
end
EN --> SM{"semver-major NuGet?"}:::gate
SM -- "yes" --> HUM(["wait for human review"]):::stop
SM -- "no" --> CK{"required check passes?"}:::gate
CK -- "yes" --> MRG(["PR merges (App token)"]):::pub
CK -- "no" --> BLK(["merge blocked<br/>maintainer notified"]):::stop
MRG -. "codegen-main Matrix.json change" .-> PUBR(["publisher pin-push auto-publishes main"]):::pub
classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Trigger chain - daily codegen to published release. The recurring path that ships a new upstream Nx
product version with no human in the loop: only a codegen-main matrix change reaches the publisher's
pin push; the develop pin update is sync-only, and :develop / the weekly base refresh come from the
schedule and dispatch (D4.1, D8.3).
flowchart TD
PER(["schedule daily 04:00 UTC<br/>run-periodic-codegen-pull-request.yml<br/>(or workflow_dispatch)"]):::trig --> CGM
CGM["codegen matrix: main + develop<br/>regenerate Version.json + Matrix.json"] --> DCH{"Matrix.json changed?"}:::gate
DCH -- "no" --> NOPR(["no PR, nothing ships"]):::stop
DCH -- "yes: main" --> PRM["codegen-main -> main PR<br/>(merge-bot auto-merges)"]
DCH -- "yes: develop" --> PRD["codegen-develop -> develop PR<br/>(merge-bot auto-merges)"]
PRD --> SYNC(["develop Matrix.json updated<br/>sync-only, push trigger is main-only<br/>:develop refreshed by dispatch"]):::stop
PRM --> PUSH(["push to main<br/>paths = Make/Matrix.json"]):::trig
PUSH --> PUB["publish-release.yml<br/>pin-push: build base + 12-image matrix"]
PUB --> SINK[("Docker Hub product + base images<br/>+ main GitHub release")]:::pub
SCHED(["weekly schedule (main)<br/>base refresh for CVEs"]):::trig --> PUB
DISP(["workflow_dispatch (main or develop)<br/>force publish that branch"]):::trig --> PUB
classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Each is a MUST, stated as input -> output plus the failure it prevents.
- D0.1 CI is one run, one branch. Input: any push. Output:
test-pull-requestbuilds/validates exactlygithub.ref_nameand publishes nothing. Prevents cross-branch ref mixing in CI. - D0.2 The publisher builds one branch: the trigger ref. Output: the publisher passes
github.ref_nameasrefandbranch, so it checks out, versions, and tags exactly the run's own branch (the schedule/pin push'smain, or a dispatch's branch). No branch matrix; the jobs are guarded tomain/develop. Prevents cross-branch ref mixing -github.refis the branch being published. - D0.3 One version, threaded. Output: NBGV runs once (
get-version-task); every consumer reads it vianeeds:outputs / thesemver2input; no consumer recomputes it. Allowed: checking out a specific commit to compile it, and recording the built commit as the releasetarget_commitish. Prevents an image's embedded version diverging from its tag, and a second NBGV run colliding image tags.
- D1.1 Every push validates; image changes smoke-build. Output: on any push the
validatejob (the reusablevalidate-task) runs with no paths filter;smoke-build(NxMeta + NxMeta-LSIO, amd64, no push) runs when the inline change-gate detects an image-file change (Docker/**,Make/Matrix.json,Make/Version.json). Prevents a reusable-workflow or Dockerfile break shipping untested. - D1.2 Unit tests always run. Output:
validate-taskrunsdotnet test(the codegen tool + its tests). - D1.3 Lint enforces the editor checks in CI. Output:
validate-taskruns Husky (dotnet husky run: CSharpier +dotnet format style --verify-no-changes) - the same checks the editor and the pre-commit hook run. Workflow YAML is lint-checked byactionlint(run from the editor / locally). - D1.4 Smoke never publishes and never uploads a release asset. Output: a smoke build compiles the image
subset but makes no GitHub release and no Docker push (
push: false,smoke: true). The Docker login runs on every build including smoke (for higher pull/cache rate limits against the registry buildcache), so a Dependabot-triggered push-CI smoke build needs the Docker Hub credentials in both secret stores; fork PRs do not run this push-CI. - D1.5 One required aggregator gates merge. Output: a single aggregator job must succeed (success or,
for the conditionally-skipped
smoke-build, skipped; never failure/cancelled),needs:changes,validate, andsmoke-build, and treats achangesfailure as blocking (so an image-changing PR cannot merge on achangesfailure mis-read as skip). Its name is ruleset-bound (D6.2) and must not be renamed.
- D2.1 Validate the cross-input invariant before publishing. Output: the
github-releasejob asserts each cross-input invariant with::error::(the main-version backstop, D2.2) before the release is created; downstream stepsneeds:the version job. The publish run also re-runsvalidate-task(thevalidatejob gatesbuild-docker), so a publish can never ship a tree that would fail the same lint +dotnet testgate CI enforces on push - on the trigger branch CI already validated, re-checked at publish time. - D2.2 Main matches version classification. Input: a real publish run for
main. Output: the release fails loudly ifmaincarries a prerelease suffix. It strips+buildmetadatabefore testing for the prerelease-. Prevents a develop build published as the stablelatest.
- D3.1 NBGV runs once, threaded. Output: NBGV runs once, classifying from the checked-out branch; no
consumer re-invokes it. The run builds the trigger ref, so
GITHUB_REFalready matches the branch being versioned and NBGV classifies it correctly (noIGNORE_GITHUB_REF). The threadedsemver2is the imageLABEL_VERSION. - D3.2
main= stable, others = prerelease. Output:main->X.Y.Z, any other branch ->X.Y.Z-g<sha>. The release-version backstop namesmain;publicReleaseRefSpecis^refs/heads/main$. - D3.3 Version floor + git height. Output:
version.jsonsets the major.minor floor, NBGV appends the git height as the patch, never bumped on a cadence. (Who raises the floor and when is a human-process rule inAGENTS.md.)
- D4.1 Publish only on schedule, the matrix-pin push, or dispatch - never on an ordinary merge. Output:
publish-releasetriggers areschedule(weekly),workflow_dispatch, and apushbranch-filtered tomainand path-filtered toMake/Matrix.json. There is no otherpushtrigger and noPUBLISH_ON_MERGEvariable. The jobs are guarded togithub.ref_namein (main,develop), so a stray dispatch from a feature branch is a no-op. Prevents per-merge release churn while still shipping a new product pin at once. - D4.2 A publish builds the one trigger branch in full. Output: the run builds the shared base (main run
only) + the full product matrix and creates the GitHub release for
github.ref_name- the schedule/pin push rebuildsmain(stable /latest); a dispatch publishes its own branch (mainstable /latest,developprerelease /:develop, no GitHub release). Prevents a half-published image set and cross-branch ref mixing. - D4.3 Tag the built commit. Output: the release
target_commitishis the run'sGitCommitId(the tip ofmain), and main's images are built from that pinned commit, never the moving ref. Prevents the tag / embedded version landing on the wrong commit. - D4.4 Release contents and gate. Output: a release is a tag on the built commit plus the auto source zip,
README, and LICENSE - no binary release assets (
fail_on_unmatched_filesomitted; the images are the artifact). The release ismain-only (github.ref_name == 'main');prerelease: false. Prevents publishing a develop build as a stable GitHub release. - D4.5 No-op republish. Input: a weekly re-run whose version is unchanged. Output: the release-create step
is skipped when the tag already exists (refreshed only on
workflow_dispatch), while the Docker push still runs - re-pushing the same tags refreshes the shared base image. Prevents duplicate releases while still refreshing the images. - D4.6 Publish is built from the tree CI validated. Output: the run's ref is the published branch, so
the reusable-task definitions and the built tree resolve from that branch - the same tree CI validated on
push (the required check gates every merge to it) with the identical
validate-taskdefinition - and the publish run re-runs thatvalidate-task(thevalidatejob gatesbuild-docker). The main-version backstop (D2.2) is the additional in-publisher gate. - D4.7 Docker publishing authenticates with Docker Hub credentials. Output: the base and product builds
log in via
docker/login-actionwithDOCKER_HUB_USERNAME+DOCKER_HUB_ACCESS_TOKENand push withdocker/build-push-action; the Docker Hub overview is pushed with the same token. There is no NuGet/OIDC publishing in this repo. Prevents a missing-credential publish failure. - D4.8 Branch-scoped Docker buildcache. Output: the base and product builds read both branches' registry
caches (
buildcache-main,buildcache-develop) and write only their own branch's cache, only when pushing, so amainand adeveloppublish never overwrite each other's cache. Prevents one branch's publish destroying the other's cache hit-rate. - D4.9 Multi-arch, multi-product, shared-base fan-out. Output: the publish builds every product image from
Make/Matrix.jsonforlinux/amd64+linux/arm64, on the shared base built once and reused; each product image pushes to its own Docker Hub repo. Prevents a per-arch or per-product gap, and a redundant per-branch base rebuild overwriting the shared tag.
- D5.1 Terminal cleanup, best-effort. The publisher and CI each run a terminal
cleanup-artifactsjob (always(),continue-on-error) that deletes the run's transfer artifacts, independent of the required aggregator so housekeeping never gates the merge. - D5.2 Never red the run on cleanup. Cleanup failures warn, never fail.
- D6.1 A change is testable on its own branch. Output: a workflow or build change is exercised by CI on
the branch that introduces it, no dependency on reaching
mainfirst. - D6.2 Head-resolution, single producer, fork exception. Output: CI runs on
pushto every branch so reusable./...logic resolves from the head, and the aggregator's ruleset-boundcontext:is produced by that push run as the sole producer of that name. Dependabot and codegen PRs are in-repo branches, validated the same way. A fork cannot push, so it has no run and is validated by maintainer action - the one exception. Prevents a dual-producer context race and a false self-test claim for forks.
- D7.1 The publisher does not cancel mid-flight. Output: ref-independent group,
cancel-in-progress: false. CI uses the...-${{ github.ref }}group withcancel-in-progress: true; the merge-bot keys on PR number (D8.1); codegen keys on the workflow. - D7.2 Skipped jobs still need valid permissions. Output: every reusable job runs under valid least-privilege
permissions:; a callee's extra scope is granted by the caller. - D7.3 Boolean inputs both forms. Declared in both trigger blocks, compared against
trueand'true'. - D7.4 Branch-deletion guard. Output: every CI job carries
if: !github.event.deletedand the aggregatoralways() && !github.event.deleted, so a branch-deletion push (all-zerosgithub.sha) runs no failing build and does not fail the required check.
- D8.1 Merge-bot. Output: runs on
pull_request_target, holds the App token, merges the PR by URL without checking out its code. Enables auto-merge onopened/reopenedwith--delete-branch; squash ondevelop, merge-commit onmainby the PR's base ref; disables auto-merge when a maintainer pushes to a bot branch (no--delete-branchon the disable path). Concurrency keyed on PR number. - D8.2 Dependabot auto-merges on green, semver-major NuGet excepted. Output: every Dependabot PR auto-merges once the required checks pass, except a semver-major NuGet bump (human review). A failing check blocks the merge. A merged dependency bump does not itself publish (it ships in the next scheduled run or the next matrix-pin push).
- D8.3 Codegen dual-targets
mainANDdevelop. Output: the daily codegen matrix opens acodegen-main->mainand acodegen-develop->developPR (strict head/base pairing in the merge-bot), regeneratingMake/Version.json+Make/Matrix.jsononly (not the Dockerfiles - those are a separate human-drivenMake/Create.shpath). The merge-bot auto-merges each independently. develop's matrix update is sync-only; a mergedcodegen-mainmatrix change fires the publisher's pin push. Prevents non-linear rebase/merge-block conflicts (the rejected single-target scheme) and keeps both branches current with upstream Nx versions. - D8.4 Forward-only version guard. Output: the codegen tool's forward-only guard (
ReleaseVersionForward, covered byCreateMatrixTests) prevents a regenerated matrix from regressing a product version. Prevents a generic-tag downgrade slipping in via codegen.
- D9.1 Every action SHA-pinned with a version comment (sole exception:
dotnet/nbgv@master, whose tag stream lags master so Dependabot tag-tracking would only propose downgrades to stale tags - a deliberate documented float, rationale inline inget-version-task.yml); an installed-tool version is left unpinned to track latest. - D9.2 File/workflow/job/step names follow the suffix rules; a ruleset-bound
context:name moves only in lockstep withrepo-config/. - D9.3 Bash
run:blocks startset -euo pipefail; multi-lineif:uses>-. - D9.4 Line endings follow
.editorconfig. - D9.5 No decorative / dropped workflows. No date-badge (
build-datebadge-*), no standalone docker-readme task (folded into the publisher), noPUBLISH_ON_MERGEvariable, nodorny/paths-filter(replaced by the inline change-gate). Their presence is a defect to remove. - D9.6 Style is enforced in CI by
validate-task(D1.3), from the same config files the editor and Husky hook use.
- D10.1 Required configuration is present. Output: the secrets, branch rulesets, and repository settings
section 6 lists are all in place, codified in
repo-config/and audited by itsconfigure.sh check(the 5D audit). The detail is in section 6.
Read the workflow files plus version.json and Make/Matrix.json and assert the fact behind each applicable
guarantee with a file:line citation:
- D0: CI has no branch matrix; the publisher passes
github.ref_nameasref/branchand is guarded tomain/develop; NBGV invoked once inget-version-task,build-docker-taskhas no nestedget-versionand consumes thesemver2input; the run builds the trigger ref soGITHUB_REFmatches the versioned branch. - D1: CI runs on
pushwith no paths filter onvalidate; the inlinechangesgate setsimage/base;smoke-buildissmoke: true,push: false, amd64 NxMeta subset; the aggregatorneeds:changes+validate+smoke-build, blocks on non-success, treatschangesfailure as blocking. - D2: the main release backstop checks the prerelease
-, strips+buildmetadata;validate-taskis the shared gate. - D3:
mainappears in the backstop and the releaseprerelease: false/guard;publicReleaseRefSpecis^refs/heads/main$;semver2threads toLABEL_VERSION. - D4:
publish-releasetriggers areschedule+workflow_dispatch+push(branches[main], paths[Make/Matrix.json]) only (no other push, noPUBLISH_ON_MERGE); jobs guarded togithub.ref_namein (main,develop);target_commitish=GitCommitId; main pinsreftoGitCommitId, develop reuses the base (build_base: false); the build logs in withDOCKER_HUB_*; buildcache branch-scoped and write-gated on push; release-create gatedexists == false || workflow_dispatch; release + docker-readme gated tomain; the product matrix builds amd64+arm64 fromMake/Matrix.json. - D5: the publisher and CI each have a terminal
cleanup-artifacts(always(),continue-on-error), independent of the aggregator. - D6: CI is
pushon every branch; the aggregator context has exactly one producer; nopull_requesttrigger; every CI job has the!github.event.deletedguard. - D7: the publisher group is ref-independent with
cancel-in-progress: false; the merge-bot keys on PR number; codegen keys on the workflow; CI uses the standard group + deletion guard; reusable jobs declare permissions. - D8/D9: the merge-bot runs on
pull_request_targetwith the App token, keyed on PR number, both merge jobs use--delete-branch; Dependabot auto-merge excepts semver-major NuGet only; codegen dual-targets main+develop and regenerates onlyVersion.json/Matrix.json; no date-badge, standalone docker-readme task,PUBLISH_ON_MERGE, ordorny/paths-filter; actions SHA-pinned exceptnbgv@master; names/shells/conditionals per section 2.
| # | Input | Expected output | Exercises |
|---|---|---|---|
| S1 | push touching Docker/** |
validate + smoke-build (NxMeta amd64) run, no push, no release; aggregator success; no dangling artifacts |
D0.1, D1 |
| S2 | push changing only docs | validate runs; the changes gate sets image=false; smoke-build skipped; aggregator success (skip allowed) |
D1, D1.5 |
| S3 | push changing only .github/workflows/** |
validate runs head-resolved; smoke-build skipped (no image files); aggregator success |
D1.1, D6.1 |
| S4 | weekly schedule |
builds + publishes main only: shared base refresh + full product matrix (amd64+arm64) + stable release + latest; target_commitish = main's SHA; develop untouched; no dangling artifacts |
D4.1, D4.2, D4.9 |
| S5 | push to main changing Make/Matrix.json (codegen pin) |
publishes main with the new product versions immediately |
D4.1, D8.3 |
| S6 | workflow_dispatch from develop |
builds + publishes develop: :develop images, prerelease classification, build_base: false (reuses main's base), no GitHub release |
D4.1, D4.2, D3.2 |
| S7 | workflow_dispatch re-run on main, no new commits |
release-create refreshed on dispatch (skipped on schedule if the tag exists); Docker re-pushed (base refresh); no duplicate release | D4.5 |
| S8 | workflow_dispatch from a feature branch |
the github.ref_name in (main, develop) guard skips every job -> no publish |
D4.1 |
| S9 | merged dependency bump (any) | not a matrix-pin change; merges don't publish -> no release; ships in the next scheduled run | D4.1, D8.2 |
| S10 | merged develop codegen PR (Matrix.json change on develop) |
sync-only; the pin push is main-only -> no publish | D8.3 |
| S11 | PR with a CSharpier / format / unit-test failure | validate fails -> aggregator blocks the merge |
D1.2, D1.3, D1.5 |
| S12 | version.json floor bump merged |
merges don't publish -> no immediate release; the new floor ships in the next publish | D3.3, D4.1 |
| S13 | Dependabot semver-major NuGet bump | gated on human review -> does not auto-merge; other majors auto-merge on green | D8.2 |
| S14 | branch-deletion push | every CI job + the aggregator skip (!github.event.deleted) -> no failing required check |
D7.4 |
| S15 | develop -> main promotion (merge commit) |
the merge itself does not publish; if it changed Matrix.json the pin push publishes main, else the next schedule does |
D4.1, D8.1 |
- Open a trivial doc-only PR and confirm S2 (
validateruns,smoke-buildskipped, aggregator green, 0 artifacts left); open an image-touching PR and confirm S1 (smoke-build runs amd64, nothing pushed). - After a
mainpublish (schedule, pin push, or dispatch) confirm a stable release (isPrerelease == false) with multi-arch product images (docker buildx imagetools inspectshows amd64 + arm64) and the shared base tags intact; after adevelopdispatch confirm:developimages, prerelease classification, and that the sharednx-base:ubuntu-nobletag was not overwritten. A re-run adds no duplicate release. Absent publish rights, record indeterminate and rely on 5A/5B. - Trigger codegen via dispatch: confirm both
codegen-main->mainandcodegen-develop->developPRs open and the merge-bot auto-merges with branch deletion.
Run repo-config/configure.sh check. It confirms the listed secrets exist (in both
stores), the main/develop rulesets enforce the required merge method + status check + signed commits +
strict-off, and the repository settings are in place, exiting non-zero on drift. Secret values cannot be
read back, so it asserts the names exist (failing if it cannot query them). The GitHub App installation is a
best-effort check (a precise check needs app-level auth, so it notes rather than fails). The Docker Hub
token's validity and push scope across the 12 repositories are a manual checklist item.
Operational when every applicable 5A item passes, every applicable 5B scenario matches (corroborated by 5C where a live signal exists), and 5D configuration is in place. Procedure: Audit (5A + 5D) -> Trace (5B) -> Probe (5C, without publishing) -> Verdict with the failing guarantee(s) and the triggering input for each.
The workflows depend on configuration outside the YAML. A misconfiguration surfaces only as a failed run, so the configuration is part of "operational" (D10; audit 5D).
Secrets.
DOCKER_HUB_USERNAME/DOCKER_HUB_ACCESS_TOKEN- Docker Hub credentials the base and product builds log in with to push the images and the repository overviews. Required in both the Actions and Dependabot secret stores: a Dependabot-triggered push runs CI whose Docker smoke build logs in too (when an image file changed), and that run gets the Dependabot store. The access token needs push scope on every product + base repo underdocker.io/ptr727/(derived fromMake/Matrix.json- seerepo-config/README.md). There is no NuGet/OIDC publishing.CODEGEN_APP_CLIENT_ID/CODEGEN_APP_PRIVATE_KEY- the GitHub App credentials the merge-bot and the codegen PR-opener mint the App token from. Required in both the Actions and Dependabot secret stores (a Dependabot-triggered run gets the Dependabot store). The App must be installed on the repo withcontents: writeandpull_requests: write.- The built-in
GITHUB_TOKENneeds no setup. NoPUBLISH_ON_MERGEvariable is used.
Branch rulesets.
main- merge-commit merges only; requires the aggregator status check (Check pull request workflow status job); requires signed commits; "require branches up to date before merging" is off (a forward-onlydevelopmakes every post-releasemaintip unreachable fromdevelop, so the strict check would fail every release).develop- squash merges only (keeps history linear); requires the same status check; requires signed commits; "up to date" is off (so same-batch bot PRs auto-merge in parallel).- The required check's
context:matches the aggregator job name verbatim (D6.2, D9.2).
Repository settings. Auto-merge enabled; squash and merge-commit both allowed (each ruleset narrows its
branch to one); rebase off; auto-delete-on-merge off (so main/develop survive a promotion; the
merge-bot deletes bot branches explicitly with --delete-branch). Dependabot version and security updates
enabled. The GitHub App installed with the scopes above.
Validation. This configuration is codified in repo-config/ and applied/audited by
repo-config/configure.sh; check is the 5D audit. Secret values cannot be read back, so the audit asserts
the names exist (failing if they cannot be queried); the App installation is a best-effort check.