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.
A run targets one branch, the one it was triggered on (github.ref_name): main builds a stable
release, develop a prerelease. The version is computed once and threaded downstream. A pull request
builds and tests but never publishes. The package publishes itself when a shipped input changes - the
source, the embedded data, the version floor, the build configuration, or the package versions
(Directory.Packages.props) - so releases track the code without a person cutting them. Listing the package
versions means a dependency bump republishes too, keeping the package's declared dependencies current. A
maintainer dispatches only to force a release. Dependabot and codegen
pull requests merge themselves once their checks pass.
- Entry workflow - has
push/pull_request/workflow_dispatchtriggers. The orchestrator a person or event starts. - Reusable workflow (task) - a
workflow_callworkflow invoked from an entry workflow through auses:reference. Never triggered directly. - Leaf - the reusable task that produces the shipped artifact (here, the NuGet package).
- Smoke build - a pull-request build that compiles and packs the library to prove it still ships,
publishing and uploading nothing. Linting and testing are the separate
validatejob. Driven by asmoke: trueinput. - Transfer artifact - a workflow artifact that hands a file between jobs of one run (e.g. the built package passed to the release job). The durable copy lives on the GitHub release / NuGet.org.
- Head-resolved vs base-resolved - a
pull_requestevent resolves a reusable./...reference from the base branch's copy, while apush/workflow_dispatchevent resolves it from the pushed head. Self-testing (section 3) depends on this. - Shipped input - a file that changes what the package ships: the library source (
LanguageTags/**), the embedded data (LanguageData/**), the version floor (version.json), the build configuration (Directory.Build.props), or the package versions (Directory.Packages.props). It is an explicit inclusion list (the publisher'son.push.paths), so a change confined to tests, the codegen tool, GitHub Actions, docs, or CI is not a shipped input. Package versions are included because a NuGet version cannot be re-pushed (no scheduled rebuild like a Docker image), so a dependency bump must republish to keep the package's declared dependencies current and close the stale/vulnerable-dependency window. GitHub Actions bumps stay excluded - they do not ship in the package. - 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). Automation that must trigger downstream workflows or write to bot pull requests uses this token, notGITHUB_TOKEN: a commit pushed with the built-in token does not trigger downstream workflows (GitHub's recursion guard), 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 and the branch-scoped, single-ref architecture 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. Every later use of "operational" means exactly this.
- Defect vs N/A. An item is N/A only when this repo has no such concern (for example a fork-PR scenario, since a fork cannot push here). It is not N/A because the workflow that should implement it is missing. A construct required by an applicable guarantee but absent is a defect (FAIL).
- Guarantees are scored independently. One line of YAML can satisfy one guarantee and violate another. Record each verdict on its own.
- Default branch is
main. Guarantees say "default branch" portably. This repo writes the literalmainin the prerelease expression and the validate gate, and the anchored^refs/heads/main$inversion.json'spublicReleaseRefSpec. All three must designatemain. - The verbs. Audit (static 5A, configuration 5D), Test (trace + probe, 5B/5C), Assess (verdict). Section 5 gives the procedure.
Legibility rules. Cheap to check, 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, so a tag swap cannot change executed code while Dependabot can still bump it. Use# vXonly when the upstream floating major tag has no specific patch SHA. The SHA-pin rule applies touses:action references; a CLI tool an action downloads at runtime (e.g. the actionlint binary) is not auses:ref and is out of scope. The soleuses:no-pin exception isdotnet/nbgv@master, whose tag stream lagsmastersuch that tag-tracking would downgrade. - Filename. Reusable workflows (
on: workflow_call) end in-task.yml. Entry-point workflows end in what they do (-pull-request.yml,-release.yml). Lowercase, hyphen-separated. A-task.ymlis invoked through auses:reference, never triggered directly. - Workflow
name:. Reusable workflow names end in "task", entry-point names in "action", so the UI label tells you orchestrator from callee at a glance. - 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-status-checkcontext:is codified inrepo-config/. It follows the suffix rule like any job, but changing it means updating those ruleset files and the live ruleset in lockstep, or required-check enforcement silently breaks. - Concurrency. Every entry-point workflow declares a
concurrencygroup. The default isgroup: '${{ github.workflow }}-${{ github.ref }}'withcancel-in-progress: true. Three entry workflows override it. The publisher uses a ref-independent group withcancel-in-progress: falseso publishes serialize and none is cancelled mid-release. The merge-bot keys on the PR number withcancel-in-progress: falseso each PR's events run to completion in order. The daily codegen workflow (run-periodic-codegen-pull-request.yml) uses the same ref-independent group expression as the publisher but withcancel-in-progress: true: it only opens the fixedcodegen-main/codegen-developpull requests, so a dispatch superseding an in-flight scheduled run is harmless, and the shared group still keeps the two from racing. - Shells. Every multi-line bash
run:starts withset -euo pipefail. - Conditionals. Multi-line
if:uses the folded scalarif: >-. A literal blockif: |embeds newlines into the boolean and is wrong. - Boolean inputs. A boolean used by both
workflow_callandworkflow_dispatchis declared in both trigger blocks.workflow_dispatchdelivers the string"true"/"false", so anyif:consuming it compares both forms:${{ inputs.foo == true || inputs.foo == 'true' }}. - Reusable-workflow permissions. Job-level
permissions:are validated beforeif:, so even a skipped job's declared permissions must be valid. Grant least privilege. A callee's extra scope (e.g.actions: writeto delete artifacts) is granted by the caller at theuses:job. - Allowlist
successandskippedexplicitly when chaining across an optional dependency.!= 'failure'letscancelledthrough. Use(needs.X.result == 'success' || needs.X.result == 'skipped'). - Line endings. Workflow YAML follows
.editorconfig(CRLF here). Preserve endings on every edit.
A run targets one branch, github.ref_name. The branch alone decides everything: main builds a stable
release, every other branch a prerelease. One run never builds, versions, or publishes a second branch.
There is no branch matrix, no plan job fanning out to multiple branches, no branch input that can
disagree with the triggering ref. Prevents the defect class where the CI ref, the checkout, and the
version classification disagree.
NBGV runs in exactly one job per run. Its outputs (SemVer2, GitCommitId, the assembly versions)
thread to every consumer through outputs:/needs:, and no other job re-invokes it. A build job may
check out a specific commit to compile it, but it consumes the threaded version. This keeps the
package version and the release tag in agreement.
Every run is a push/workflow_dispatch on a real branch, so actions/checkout lands on a branch tip
and NBGV classifies natively: the public-release ref (publicReleaseRefSpec = ^refs/heads/main$) builds a
clean X.Y.Z, every other branch a prerelease X.Y.Z-g<sha>. The detached-merge-ref case (NBGV seeing no
branch) never arises. version.json's version is the major.minor floor, and NBGV appends the git height
as the patch.
When a run carries a cross-input or input-versus-derived-state invariant, a dedicated entry job/step
asserts it once and fails fast with ::error:: before any build or publish. Downstream jobs needs: it.
Workflow artifacts are an intra-run handoff. The durable copy lives on the GitHub release / NuGet.org. A
transfer artifact is deleted by exact name at the point it is consumed, the delete gated to the consumer's
condition and best-effort. Every upload-artifact sets retention-days: 1 as a backstop, so a run that
skips the delete still reclaims its artifact. The run's artifact set is never blanket-deleted
(.artifacts[].id), which would destroy the diagnostic artifacts needed to debug a failed run. See D5.
A pull request validates fast and never publishes. Validation is a reusable validate-task holding two
jobs, unit-test (build and test) and lint (the editor's checks, enforced in CI). The pull request runs
it as a validate job alongside smoke-build (build and pack the library to prove it ships, uploading and
pushing nothing). Both run on every push with no paths filter (a branch-deletion push is the one exception -
a !github.event.deleted guard skips them, since github.sha is all-zeros and checkout would fail), so a
reusable-workflow change is always exercised head-resolved. Packaging validation as one task lets the
publisher run the identical gate (D4.6).
One required aggregator gates the merge. See D1.
A pull request exercises its own workflow files. No change waits to reach main first.
- CI runs on
pushto every branch. GitHub head-resolves the reusable./...workflows from the pushed head, so a pull request that edits a reusable task tests its own copy. The push run is the sole producer of the aggregator's ruleset-boundcontext:, on the head SHA branch protection evaluates. CI never publishes. - Single-producer invariant. Exactly one trigger path emits a given ruleset-bound context name. No
pull_request-triggered job emits it, which would race two check-runs on one SHA. - Only
main/developproduce releases. The publisher also runs onpushto the protected branches, gated on a shipped change (D4.1). CI and the publisher then run in separate workflows with separate concurrency, so they do not race: CI re-tests the merged tree, the publisher releases only on a shipped change. - A dispatched publish uses that branch's workflows, so a workflow change is usable on the branch that introduces it.
- Forks are the documented exception. A fork cannot push here, so its pull request produces no run and no aggregator check, and a maintainer lands the change on an in-repo branch (which pushes, and so validates) before merging. Dependabot is not an exception: its pull requests are in-repo branches, validated head-resolved by their push (a read-only token and the Dependabot secret store, enough for the gate). See D6.
The package publishes itself when a shipped input changes, so releases track the code without a person
cutting them. Every publish targets only the branch it ran on (develop -> prerelease, main -> stable).
Two things publish:
- An automatic release on a shipped change. The publisher runs on
pushtomain/developwith theon.push.pathsinclusion list (LanguageTags/**,LanguageData/**,version.json,Directory.Build.props,Directory.Packages.props), so it triggers only when a shipped input changed..github/**is not listed, so Actions bumps do not republish;Directory.Packages.propsis listed, so a dependency bump republishes to keep the package's dependencies current. The merge-bot merges with the App token, so its merge commits reach this push trigger. - A manual release on demand. A
workflow_dispatchon a branch publishes it immediately, whatever changed - the "release now" control.
There is no scheduled publish and no publish-on-every-merge. Every publish runs the same validate-task
the pull request runs (the identical definition, not a copy) as a validate job the publish job needs:,
so nothing ships that would fail the pull-request gate (D4.6). This matters because develop squashes and
main merge-commits, so the published commit is not the feature-head the pull request smoke-tested. See
D4.
- Dependabot pull requests merge themselves. Every Dependabot pull request, any ecosystem and any tier (semver-major included), auto-merges once the required checks pass, using the App token. The checks are the safety net: an update that breaks the build or tests fails them, auto-merge does not complete, and GitHub notifies the maintainer.
- Codegen refreshes data the same way. The codegen workflow regenerates
LanguageData/from its upstream registries daily, opens a pull request only when the data changed, and auto-merges it on green. The data is a shipped input, so the publisher then releases it.
The library is self-maintaining: data and dependencies stay current on both branches, each shipped change
releases automatically, and a person steps in only for a breaking change (a red check) or to force a
release by dispatch. A merged dependency bump republishes (its Directory.Packages.props change is a
shipped input), keeping the published package's dependencies current. See D8.
The repo produces exactly one shipped artifact, the NuGet package. The leaf pushes the package, and where
symbols are enabled its symbol package, to NuGet.org via OIDC trusted publishing (no long-lived API key,
D4.7), and bundles them with the compiled library into a single fixed-name LanguageTags.7z attached to
the GitHub release. There is no generic multi-target abstraction: no enable_<target> flag selecting among
leaves, no expect_release_assets toggle, no release-asset-<branch>-* glob. The single asset,
LanguageTags.7z, is attached by its fixed name, so releases/latest/download/LanguageTags.7z is a stable
download URL.
Three diagrams trace the architecture above: the pull-request gate, the self-publisher, and the bot automation. 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 and a non-publishing smoke build, 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?"}
D -- "yes: branch deletion" --> X(["all jobs + aggregator skip<br/>no failed run, no pending check"]):::stop
D -- "no" --> V["validate job<br/>(validate-task.yml)"]
D -- "no" --> S["smoke-build job<br/>build-release-task.yml<br/>smoke: true, publish: false"]
subgraph VT ["validate-task.yml"]
U["unit-test job<br/>dotnet test, warnings-as-errors"]
L["lint job<br/>CSharpier, dotnet format,<br/>markdownlint, cspell, actionlint"]
end
V --> VT
S --> SB["build + pack library<br/>head-resolved, no push, no uploads"]
VT --> A
SB --> A
A{"Check pull request workflow status job<br/>validate AND smoke-build succeeded?"}:::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 -> build-release-task.yml. A shipped-input push or a dispatch runs
the same validate gate, then versions once with NBGV, asserts branch-vs-version, builds the pinned commit,
pushes to NuGet via OIDC, and cuts the GitHub release (D2, D3, D4).
flowchart TD
P1(["push: main/develop<br/>paths = shipped inputs"]):::trig --> VAL
P2(["workflow_dispatch"]):::trig --> VAL
VAL["validate job<br/>(validate-task.yml)"] --> PG{"publish guard<br/>push OR ref in (main, develop)"}:::gate
PG -- "no" --> PSKIP(["publish skipped"]):::stop
PG -- "yes" --> GV
subgraph BRT ["build-release-task.yml (publish: true)"]
GV["get-version job<br/>NBGV @master, runs once<br/>SemVer2 + GitCommitId"] --> VR{"validate-release<br/>branch matches version?"}:::gate
VR -- "mismatch" --> VRX(["fail ::error::"]):::stop
VR -- "agree" --> B["build job<br/>checkout GitCommitId<br/>build + pack"]
B --> NP[("NuGet.org push<br/>OIDC key, skip-duplicate")]:::pub
B --> GR{"github-release<br/>tag new OR dispatch?"}:::gate
GR -- "exists, not dispatch" --> NOP(["skip create<br/>artifact reclaimed by backstop"]):::stop
GR -- "create" --> REL[("GitHub release<br/>tag = SemVer2 at GitCommitId<br/>prerelease = ref != main")]:::pub
end
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); a merged shipped input then drives the publisher above (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 LanguageData<br/>(deterministic)"] --> CGC{"data changed?"}
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<br/>squash develop / merge main"]
MB -- "synchronize by maintainer" --> DIS["disable auto-merge"]
end
EN --> CK{"required checks pass?"}:::gate
CK -- "yes" --> MRG(["PR merges (App token)"]):::pub
CK -- "no" --> BLK(["merge blocked<br/>maintainer notified"]):::stop
MRG -. "shipped input changed" .-> PUBR(["publisher auto-releases"]):::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. A workflow that violates any applicable guarantee is not operational (section 1).
- D0.1 One run, one branch. Input: any triggered run. Output: it builds/versions/publishes exactly
github.ref_name, with no job fanning out to a second branch. Prevents: mis-classified versions and mismatched tags from cross-branch ref mixing. - D0.2 One version, threaded. Output: NBGV runs in exactly one job, on a real-branch-tip checkout on
the publish path, and its outputs thread via
needs:to all consumers. No second job recomputes a version. Allowed: checking out a specific commit to compile it, and recording the built commit's SHA as the releasetarget_commitish(D4.3); neither re-runs NBGV. Prevents: a checkout that versions a package differently from its tag.
- D1.1 Every push builds, lints, and tests. Output: on any push the
validatejob - the reusablevalidate-task, holding theunit-testandlintjobs - andsmoke-buildrun with no paths filter. The one exception is a branch-deletion push: a!github.event.deletedguard skips every job (and the aggregator skips too, so the required check is not left pending), becausegithub.shais all-zeros and a checkout/build would fail.smoke-buildbuilds and packs the library in its branch configuration through the samebuild-release-taskthe publisher uses. Prevents: a reusable-workflow change shipping untested because a filter excluded it; a build/packaging break slipping through; a branch-deletion push failing CI. - D1.2 Unit tests always run. Output: the
unit-testjob (invalidate-task) runsdotnet test(build withTreatWarningsAsErrors, so analyzer/style warnings fail here), and the aggregator reaches it through thevalidatejob itneeds:. - D1.3 Lint enforces the editor checks in CI. Output: the
lintjob runs CSharpier (dotnet csharpier check),dotnet format style --verify-no-changes,markdownlint-cli2,cspellon the user-facing docs (README, HISTORY), andactionlint(which shellchecks everyrun:block). These are the same checks the editor and the local Husky hook run, enforced from the same config files. Prevents: formatting, markdown, spelling, or workflow-YAML defects reaching the branch on editor-faith. - D1.4 Smoke never publishes and never uploads. Output: full compile/pack, but no NuGet push, no
GitHub release, no artifact uploads (every
upload-artifactis gated!smoke). Prevents: a PR publishing; orphaned artifacts. - D1.5 One required aggregator gates merge. Output: a single aggregator job must succeed (not merely
"not fail"),
needs:validateandsmoke-build(and so transitivelyunit-testandlint), and blocks on any non-success. Its name is ruleset-bound, has a single producer (D6.2), and must not be renamed. Prevents: a library, lint, or workflow defect merging unverified.
- D2.1 Validate before expensive work. Output: a dedicated entry job/step asserts each
cross-input/derived-state invariant and fails fast with
::error::before builds. Downstream jobsneeds:it. - D2.2 Branch matches version classification. Input: a real (non-smoke) publish. Output: the gate
fails loudly if
maincarries a prerelease suffix or a non-mainbranch carries none. It strips+buildmetadatabefore testing for the prerelease-. It is skipped on smoke (smoke never publishes, so the check is moot, and a smoke build on a feature branch versions as prerelease regardless). "Skipped on smoke" means the gate runs and self-skips its body tosuccess, not that the job is absent. Prevents: a develop build published as stable; a build-metadata false positive; the gate blocking a smoke build.
- D3.1 One NBGV invocation, threaded. Output: NBGV runs once, classifying from
github.ref_name's real-branch checkout on the publish path, and its outputs thread to build and release. No consumer re-invokes NBGV. Prevents: a leg classified by the wrong ref; a package version diverging from the tag. - D3.2
main= stable, others = prerelease. Output:main->X.Y.Z(PublicRelease=true), any other branch ->X.Y.Z-g<sha>(PublicRelease=false). The gate and theprereleaseexpression namemain, andversion.json'spublicReleaseRefSpecis^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, out of scope for this verdict.) - D3.4 NuGet prerelease is derived, not set. Output: NuGet.org marks a package prerelease when its
PackageVersioncarries the SemVer2-g<sha>suffix, a consequence of D3.2, not a flag the workflow sets. (Distinct from the GitHub-releaseprereleaseboolean of D4.4, which the workflow does set.)
- D4.1 Publish only by dispatch or a shipped-input change. Output: the publisher is reachable via (a)
workflow_dispatchon a branch (force-publish, guarded tomain/develop), or (b) apushtomain/developmatching theon.push.pathsinclusion list of shipped inputs (LanguageTags/**,LanguageData/**,version.json,Directory.Build.props,Directory.Packages.props). The list is inclusion-only: it does not list.github/**, docs, tests, or the codegen tool, so a GitHub Actions bump or a docs change does not republish.Directory.Packages.propsis listed, so a dependency bump republishes (a NuGet version can't be re-pushed, so deps must republish to stay current). There is noscheduleand noPUBLISH_ON_MERGE. Prevents: a blind scheduled republish; a no-impact change (actions bump, docs) cutting a release; and a stale/vulnerable dependency lingering in the published package. - D4.2 Publish exactly the triggering branch. Output: the run publishes only
github.ref_name(develop-> prerelease,main-> stable; a shipped change or dispatch onmaincuts a stable release by design). Prevents: a publish shipping the wrong branch. - D4.3 Tag the built commit. Output: the release
target_commitishis the built commit's SHA (NBGV'sGitCommitId), nevergithub.shaof a moving ref. Prevents: the tag landing on a different commit than was built. - D4.4 Release contents and flag. Output: every release is a tag on the built commit plus the auto
source zip, README, and LICENSE, with a fixed-name
LanguageTags.7zattached that bundles the compiled library, the.nupkg, and (whereIncludeSymbols) the.snupkg. The GitHub-releaseprereleaseboolean is set togithub.ref_name != 'main'. (GitHub computes the "Latest" badge from semver across non-prerelease releases, a consequence, not a workflow assertion.) - D4.5 No-op republish. Input: a re-run whose version is unchanged. Output: the release-create step
is skipped when the tag already exists (refreshed only on
workflow_dispatch). The NuGet push runs and the server dedupes (dotnet nuget push --skip-duplicatetreats an existing-version 409 as success), the symbol push likewise. The paired transfer-artifact delete is gated to the release-create step, so on a no-op re-run the artifact is reclaimed by theretention-days: 1backstop. Prevents: duplicate releases and wasted pushes. - D4.6 Publish is tested as built. Input: any publish (dispatch or shipped-change). Output: the
publisher runs the same reusable
validate-task(the D1.2/D1.3unit-test+lintgate) as avalidatejob the publish jobneeds:, so the push and release are gated on its success. It is the identical definition the pull request runs, so nothing publishes that would fail the PR gate. The trade-off, accepted over polling a cross-workflow status check, is that a shipped-input push to a protected branch validates twice. Prevents: an auto-publish shipping a merged tree tested only as the pre-merge PR head, since the squash/merge commit (D8.1) differs from what the PR tested. - D4.7 Publish authenticates via OIDC trusted publishing. Output: the publish job grants
id-token: writeand obtains a short-lived NuGet key fromNuGet/login@v1(the action exchanges the GitHub OIDC token for a temporary key, using theNUGET_USERNAMEprofile name), anddotnet nuget pushuses that key. There is no long-livedNUGET_API_KEYsecret. The key is requested immediately before the push (1-hour lifetime, single use). The matching trusted-publishing policy on NuGet.org (section 6) namesbuild-release-task.yml, the reusable task that requests the token (the OIDCjob_workflow_ref), not thepublish-release.ymlentry workflow. Prevents: a leaked long-lived publish credential.
- D5.1 Delete at the point of consumption. Output: a cross-job transfer artifact is deleted by exact name/pattern right after the job that consumes it.
- D5.2 Gate the delete to the consumer's condition. Output: the delete runs under the same condition as its consuming step. A no-op re-run that skips the consumer skips the delete too and relies on the D5.4 backstop. Prevents: deleting a freshly built asset on a no-op re-run.
- D5.3 Best-effort. Output: cleanup is
continue-on-error: true, tolerates a failed listing, and deletes all matching ids. Prevents: a cleanup hiccup reddening a successful publish. - D5.4 Retention backstop. Output: every
upload-artifactsetsretention-days: 1. - D5.5 Never blanket-delete. Output: cleanup MUST NOT enumerate and delete the run's whole artifact
set (
.artifacts[].id). Prevents: destroying diagnostic/build-record artifacts.
- 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, with no dependency on the change first reaching
main. Prevents: the "promote tomainto test the fix" trap. - D6.2 Head-resolution, single producer, one 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 on the head SHA as the sole producer of that name. Dependabot pull requests are in-repo branches, so their push validates them the same way (restricted read-only token, enough for the gate). A fork is the one exception: it cannot push, so it has no run and is validated by maintainer action, never by a second producer of the gate context. Prevents: a dual-producer context race; a false self-test claim for fork PRs.
- D7.1 The publisher does not cancel mid-flight. Output: the publisher's concurrency uses a
ref-independent group with
cancel-in-progress: false. All other entry workflows use the...-${{ github.ref }}group withcancel-in-progress: true, except the merge-bot (PR-number group, D8.1) and the daily codegen workflow (ref-independent${{ github.workflow }}group withcancel-in-progress: true, section 2). - D7.2 Skipped jobs still need valid permissions. Output: every reusable job runs under valid least-privilege
permissions:, and a callee's extra scope is granted by the caller. - D7.3 Boolean inputs both forms. Output: boolean inputs are declared in both trigger blocks and
compared against
trueand'true'. - D7.4 Optional-dependency chaining. Output: cross-job conditions allowlist
success/skippedexplicitly rather than!= 'failure'.
- D8.1 Merge-bot. Output: runs on
pull_request_target, holds the App token, and merges the pull request by URL without checking out its code. Enables auto-merge onopened/reopened. Produces a linear (squashed) history ondevelopand a merge commit intomain, chosen by the PR's base ref. Disables auto-merge when a maintainer pushes to a bot branch. Concurrency keyed on the PR number. Prevents: two PRs colliding in auto-merge; a bot merge that fails to trigger downstream workflows. - D8.2 Dependabot auto-merges on green, every tier. Output: every Dependabot pull request, any
ecosystem and semver-major included, auto-merges once the required checks pass, with no version-tier
exception. A failing check blocks the merge and surfaces via GitHub's check-failure notification. A
merged dependency bump republishes (
Directory.Packages.propsis a shipped input, D4.1), keeping the published package's declared dependencies current; a GitHub-Actions bump does not. Prevents: a breaking update merging unverified; a safe update stalled waiting for a human; and a stale/vulnerable dependency lingering in the published package. - D8.3 Codegen is deterministic and content-gated. Output: codegen regenerates
LanguageData/purely from its upstream sources (no per-run timestamps/GUIDs), opens a pull request only when the data changed, and auto-merges it on green. The merged data is a shipped input, so the publisher releases it (D4.1). Codegen is dual-target, the workflow analog of Dependabot's per-target-branch config: each branch is regenerated independently against its own checkout, into its owncodegen-<branch>PR, so a data update never depends on a cross-branch merge-back. A matrix (one leg per branch) is the expected form; the no-branch-matrix rule (D0) is scoped to the build/version/publish path and does not apply here, since codegen neither versions nor publishes.
- D9.1 Every action SHA-pinned with a version comment (sole exception:
dotnet/nbgv@master). A tool an action installs (e.g. the actionlint binary behindraven-actions/actionlint) is not auses:ref and is left unpinned to track latest, so CI picks up new lint rules. - D9.2 File/workflow/job/step names follow the suffix rules. A name also used as a ruleset
required-check
context:is codified inrepo-config/and changed only in lockstep with the ruleset. - D9.3 Bash
run:blocks startset -euo pipefail; multi-lineif:uses>-. - D9.4 Line endings follow
.editorconfig. - D9.5 No decorative / non-shipped workflow remains, in particular no date-badge workflow
(
build-datebadge-*). The contract ships exactly the package and its release. A workflow that produces neither is out of scope, and its presence is a defect to remove. - D9.6 Style is enforced in CI, not just the editor: the
lintjob (D1.3) runs CSharpier check,dotnet format style,markdownlint-cli2,cspellon the user-facing docs, andactionlint, from the same config files the editor and the Husky hook use (CODESTYLE clean-compile sync).
- D10.1 Required configuration is present. Output: the secrets, branch rulesets, and repository settings that section 6 lists are all in place. Prevents: a green-looking repo whose first real publish or auto-merge fails on a missing secret, an unenforced ruleset, or a disabled setting. The detail and the validation procedure are in section 6; the audit is 5D.
An agent verifies the repo in escalating modes, then renders the section-1 verdict. Skip N/A items (section 1); a required-but-missing construct is a FAIL, not N/A.
Read the workflow files plus version.json and assert the structural fact behind each applicable
guarantee, each pass/fail/N-A with a file:line citation:
- D0: no branch matrix and no plan job in the publisher; no
IGNORE_GITHUB_REF, nogit checkout -B, nobranchinput that can differ fromgithub.ref_name; NBGV invoked in exactly one job, every other consumer reading it vianeeds:outputs (a second invocation that recomputes is the defect; a commit checkout that only compiles is allowed). - D1: the PR workflow runs on
pushwith no paths filter; thevalidatejob (the reusablevalidate-task, holdingunit-test+lint) andsmoke-buildrun on every push except a branch deletion (every job, the aggregator included, carries a!github.event.deletedguard); the smoke call sets publish off andsmoke: true; every buildupload-artifactis gated!smoke; thelintjob runs CSharpier check,dotnet format style --verify-no-changes,markdownlint-cli2,cspellon README/HISTORY, andactionlint; the aggregatorneeds:validate+smoke-buildand blocks on any non-success. - D2: the release gate checks both directions, strips
+buildmetadata, and self-skips on smoke tosuccess. - D3:
mainappears in the gate and theprereleaseexpression (!= 'main');version.json'spublicReleaseRefSpecis^refs/heads/main$. - D4: the publisher's triggers are
workflow_dispatchand apushtomain/developwith anon.push.pathsinclusion list of exactlyLanguageTags/**,LanguageData/**,version.json,Directory.Build.props,Directory.Packages.props(no.github/**); noschedule, noPUBLISH_ON_MERGE; the dispatch path is guarded tomain/develop; the publisher calls the samevalidate-taskas avalidatejob and the publish jobneeds:it (D4.6); the run publishes onlygithub.ref_name;target_commitishis the NBGV commit id; the GitHub-releaseprereleaseboolean== (github.ref_name != 'main'); the release body attaches the source zip, README, LICENSE, and a fixed-nameLanguageTags.7zbundle (compiled library +.nupkg/.snupkg); the leaf pushes*.nupkgand*.snupkg(symbols enabled) with--skip-duplicate; the publish job grantsid-token: writeand pushes with aNuGet/login@v1short-lived key, not aNUGET_API_KEYsecret (D4.7); release-create gatedexists == false || workflow_dispatch. - D5: each cross-job transfer artifact has a delete gated to its consumer,
continue-on-error: true, looping all ids; every upload setsretention-days: 1; no.artifacts[].idblanket delete exists. - D6: PR-validated logic is head-resolved (a
pushtrigger on every branch), and the ruleset-bound aggregator context has exactly one producer. Dependabot PRs are in-repo and validate via that push, a fork PR has no run and needs maintainer action, and there is nopull_request-triggered fallback. - D7: the publisher group is ref-independent with
cancel-in-progress: false; the merge-bot keys on PR number; other entry workflows use the standard group; reusable jobs declare permissions; booleanif:uses both forms. - D8/D9: the merge-bot runs on
pull_request_targetwith the App token and keys concurrency on PR number; Dependabot auto-merge has no semver-major exception (gated only on the required check); codegen is deterministic + per-branch; no multi-targetenable_*/expect_release_assetsabstraction; no date-badge / decorative workflow exists; actions SHA-pinned; names/shells/conditionals per section 2.
For each applicable scenario, evaluate every job's if:/needs: against the inputs and emit the
predicted run/skip + version + release + artifact-end-state, then compare to expected. One input is
assumed as a given rather than re-derived from the YAML: the version classification (clean vs -g<sha>),
determined by NBGV from the checkout state in section 3.
| # | Input | Expected output | Exercises |
|---|---|---|---|
| S1 | push touching LanguageTags/** |
validate (unit-test + lint) and smoke-build all run; smoke (smoke:true) builds and packs, no push, no uploads, no release; validate-release self-skips on smoke; aggregator success; version = prerelease (branch is not main); no dangling artifacts |
D0, D1, D2.2, D3 |
| S2 | push changing only docs/README | validate and smoke-build run; lint checks the markdown; smoke-build rebuilds the unchanged library; aggregator success; nothing publishes |
D1, D1.5 |
| S3 | push changing only .github/workflows/** |
validate and smoke-build run; smoke-build exercises the changed reusable workflow head-resolved (self-test); lint runs actionlint on it; aggregator success |
D1.1, D6.1 |
| S4 | workflow_dispatch on develop |
builds/publishes only develop; the validate task the publish job needs: gates it (D4.6); version X.Y.Z-g<sha>; release prerelease=true; NuGet prerelease; target_commitish=built SHA; transfer artifact consumed-then-deleted; no dangling artifacts |
D0, D3, D4, D5 |
| S5 | workflow_dispatch on main |
builds/publishes only main; the validate gate the publish job needs: gates it; version X.Y.Z; release prerelease=false; NuGet stable; .snupkg pushed; no dangling artifacts |
D0, D3, D4, D5 |
| S6 | merge of a source change to develop/main |
push changed a shipped input -> that branch auto-publishes, validated by the needs: validate gate before publish (D4.6) |
D4.1, D4.6 |
| S7 | re-run, version unchanged (tag exists) | release-create skipped; transfer artifact reclaimed by backstop; NuGet push a --skip-duplicate no-op; no duplicate release |
D4.5, D5.2 |
| S8 | branch/version classification disagree (e.g. main carries -g) |
validate-release fails loud; build/publish skip | D2.2 |
| S9 | merged codegen LanguageData/** change |
shipped input changed -> that branch auto-publishes | D4.1, D8.3 |
| S10 | merged GitHub-Actions version bump only | .github/workflows/** is not a shipped input -> no publish |
D4.1 |
| S11 | merged dependency bump, any kind (e.g. Microsoft.Extensions.Logging.Abstractions or xunit.v3) |
Directory.Packages.props is a shipped input -> that branch auto-publishes, keeping the package's declared dependencies current |
D4.1, D8.2 |
| S12 | PR with a CSharpier, dotnet-format, markdown, spelling, or workflow-YAML violation | the lint job fails -> aggregator blocks the merge |
D1.3, D1.5 |
| S13 | version.json floor bump merged to a branch |
version floor is a shipped input -> auto-publish that branch at the new floor | D3.3, D4.1, D4.2 |
| S14 | Dependabot major bump whose tests fail | required check fails -> auto-merge does not complete; no merge, no publish; maintainer notified | D8.2 |
| S15 | develop -> main promotion (merge commit) carrying a shipped change |
the merge commit's diff (before..after, before = prior main tip) includes the promoted shipped input -> main auto-publishes the stable release; a promotion carrying only non-shipped changes does not |
D4.1, D4.2, D8.1 |
| S16 | a branch is deleted (a push event with github.sha all-zeros) |
the !github.event.deleted guard skips validate, smoke-build, and the aggregator -> no failed CI run, no pending required check |
D1.1 |
- Open a trivial-change PR touching the library and confirm S1 (smoke builds, nothing pushed, aggregator green, 0 artifacts left).
- Drive a
smoke: truepush-probe of the release-build path on a throwaway branch for thedevelopandmainclassifications, and assert clean vs prerelease and that the gate passes, without publishing. - After any real publish, query NuGet.org for the expected version +
isPrerelease, confirm a re-run added no duplicate, and inspect the run forPublicRelease/SemVer2and the artifact lifecycle. The live-only guarantees a static read cannot settle (D4.5 server-dedupe, the artifact end-state, livePublicRelease) are what 5C confirms. Absent publish rights, record them indeterminate and rely on the 5A/5B static evidence.
Run repo-config/configure.sh check (section 6). It confirms the listed secrets exist,
the main/develop rulesets enforce the required merge method + status check + signed commits +
strict-off, and the repository settings (auto-merge, allowed merge methods) are in place, exiting non-zero
on any drift. A missing or incorrect configuration item is a defect (D10). Secret values cannot be read
back, so the audit asserts the names exist and a GitHub App is installed. The NuGet.org trusted-publishing
policy (D4.7) lives outside GitHub and cannot be checked by gh api; the script flags it as a manual
verification 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. N/A items are excluded; a required-but-missing construct is a FAIL. Procedure:
- Audit with 5A and 5D; record pass/fail/N-A with
file:lineor the config item. - Trace the applicable S-scenarios with 5B; diff predicted vs expected.
- Probe with 5C only for what a static trace cannot settle, without publishing; where unprobeable, mark indeterminate.
- Verdict: operational or not, with the failing guarantee(s), the triggering input for each, the items recorded N/A or indeterminate, and (during adoption) the conformance baseline so an expected pre-refactor failure is not read as a regression.
The workflows depend on configuration outside the YAML: secrets, branch rulesets, and repository settings. A misconfiguration surfaces only as a failed run (a missing secret, a merge that never auto-completes, a tag on the wrong branch), so the configuration is part of "operational" and is testable in its own right, not merely discoverable by failure (D10; audit 5D).
Secrets.
NUGET_USERNAME- the NuGet.org profile name passed toNuGet/login@v1for OIDC trusted publishing (D4.7). Actions store. NoNUGET_API_KEYsecret is used; publishing is keyless.CODEGEN_APP_CLIENT_ID/CODEGEN_APP_PRIVATE_KEY- the GitHub App credentials the merge-bot and codegen mint the App token from. Required in both the Actions and Dependabot secret stores: codegen and the publisher read them from Actions, but the merge-bot reads them from the Dependabot store when it acts on a Dependabot PR (Dependabot-triggered runs get the Dependabot store, not Actions secrets). The App must be installed on the repo withcontents: writeandpull_requests: write.- The built-in
GITHUB_TOKENneeds no setup. NoPUBLISH_ON_MERGEvariable is used; its presence is stale configuration to remove.
NuGet.org trusted-publishing policy. Publishing is keyless via OIDC (D4.7), so a trusted-publishing
policy must exist in the NuGet.org account naming Repository Owner ptr727, Repository LanguageTags, and
Workflow File build-release-task.yml (filename only) - the reusable task that runs NuGet/login and
requests the token, which the OIDC job_workflow_ref claim names rather than the publish-release.yml
entry workflow. It lives on NuGet.org, not GitHub, so configure.sh cannot read it - a manual checklist
item. A private-repo policy stays provisional for 7 days until the
first successful publish locks it to the repo and owner IDs.
Branch rulesets.
main- merge-commit merges only; requires the aggregator status check (the ruleset-boundcontext: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 pull requests auto-merge in parallel without one pushing the otherBEHIND).- The required check's
context:name matches the aggregator job name verbatim (D6.2, D9.2).
Repository settings.
- Auto-merge enabled. Both squash and merge-commit methods allowed (each ruleset narrows its branch to one).
- Actions enabled with permission to run the pinned actions. Dependabot version and security updates enabled.
- The GitHub App installed with the scopes above.
Validation. This configuration is codified in repo-config/: the branch rulesets
and repository settings as JSON, applied and audited by an idempotent gh api script.
repo-config/configure.sh check reads the live rulesets, settings, and secret names and exits non-zero
on any drift; that command is the 5D audit. repo-config/configure.sh apply configures a fresh repo
to match. Secret values cannot be read back, so the audit asserts the names exist and a GitHub App is
installed rather than checking contents.