This document describes PrintFarmer's CI strategy, the dotnet-format CI
job, and the local pre-push formatting hook that gives the same feedback
before a push.
Related:
- Workflow:
.github/workflows/ci.yml - Selector script:
scripts/ci/select-dotnet-tests.sh - Selector tests:
scripts/ci/tests/test-select-dotnet-tests.sh - Pre-push hook:
.githooks/pre-push - Pre-push hook tests:
.githooks/tests/test-pre-push.sh - Hook installer:
.githooks/setup.sh
flowchart LR
A[PR opened] --> B[select job]
B -->|frontend inputs or full-safe| C[frontend job]
B -->|dotnet inputs or full-safe| D[dotnet-build full sln]
D -->|project tarballs| E[dotnet-test matrix]
D -->|project tarballs| F[migration-drift]
D -->|API test + App migration tarballs| J[dotnet-test-providers]
B --> G[ci-tools]
B -->|dotnet, npm-manifest, or compliance inputs or full-safe| I[dependency-compliance]
B -->|dotnet inputs or full-safe| K[dotnet-format]
C --> H[summary]
E --> H
F --> H
J --> H
G --> H
I --> H
K --> H
Every PR triggers select and ci-tools. The selector classifies changed
paths and emits outputs consumed by the conditional jobs. This produces a
required, stable check name even when no application build runs, such as a
docs-only PR.
| Job | Runs when | Notes |
|---|---|---|
select |
always | Classifies changed paths; emits want_*, matrix, mig_matrix. |
ci-tools |
always | Runs bash -n + selector + hook tests + node --test compliance/squad-tooling suites + restore-free validate-compliance.mjs --skip-nuget; no .NET restore. |
dependency-compliance |
want_dependency_compliance: any .NET input, npm manifest/lockfile, or compliance bucket changed OR full-safe |
dotnet restore + node scripts/compliance/validate-compliance.mjs — NuGet and npm dependency-license/provenance inventory, including stale npm license-text fallbacks (LICENSE_POLICY_STALE). See #1395, #3150. |
dotnet-format |
any .NET input changed OR full-safe (same as want_dotnet_build) |
Asserts SDK >= 10.0.200, restores, then runs dotnet format ./farm-web.sln --verify-no-changes --no-restore. Runs in parallel with dotnet-build. See #2978. |
frontend |
React or compliance inputs changed OR full-safe |
npm ci, lint, build, npm run test:coverage in src/Web/ReactApp/; coverage first runs the zero-diagnostic test gate, application typecheck, and source-coverage guards through pretest:coverage. |
dotnet-build |
any .NET input changed OR full-safe | Restores/builds once, explicitly builds IntegrationTests when selected, and uploads one compressed tarball per selected project. |
migration-drift |
App or Slicer schema-relevant inputs changed OR full-safe | Restores runner-local project metadata, downloads its compiled project, then runs has-pending-model-changes --no-build. |
dotnet-test |
.NET test-relevant inputs changed OR full-safe | Matrix — one leg per project/shard; downloads the archive keyed by matrix.project, then executes the test DLL directly. |
dotnet-test-providers |
.NET test-relevant inputs changed OR full-safe | Restores locally for EF metadata, downloads API-test and App-migration outputs, applies both providers, and executes the API test DLL. |
summary |
always (if: always()) |
Aggregates gates; hard-fails on required check regression. |
ci-tools used to unconditionally run a full dotnet restore +
validate-compliance.mjs on every event — including mobile-only and
docs-only PRs — solely to keep dependency-license/provenance validation
fail-closed. That restore is real network/CPU cost with nothing to validate
when no .NET-relevant bucket changed: the restore only ever covers
farm-web.sln's project graph, and any edit to a .csproj already
referenced by that graph lands in a src/** bucket (api, infra,
backends, slicer, tools, etc.) that already forces
want_dotnet_build=true; adding a new project to the graph requires
editing farm-web.sln itself, which is shared_config and always forces
full-safe.
dependency-compliance now runs the restore-then-validate pair in its own
job, originally gated by the exact same want_dotnet_build output the
dotnet-build job uses (now want_dependency_compliance, see below), so
mobile-only/docs-only PRs skip it entirely. Coverage does not
regress: want_dotnet_build is forced true (full-safe) on every trusted
push to main/development, on workflow_dispatch, and on any
shared_config bucket change (Directory.Packages.props, NuGet.Config,
any *.sln, Directory.Build.*) — see "Full-safe (full_matrix=1)
triggers" above — so dependency/license drift is still caught fail-closed
on the branches that matter. ci-tools itself stays restore-free and fast
on every PR.
The gate is the selector's want_dependency_compliance output (#3150), a
strict superset of want_dotnet_build. The NuGet graph argument above does
not cover the npm graph: src/Web/ReactApp/package-lock.json can change
without any .NET bucket changing, and the compliance policy/evidence files
under compliance/** and scripts/compliance/** belong to no .NET bucket at
all. want_dependency_compliance is therefore also true when:
- a
package.jsonorpackage-lock.jsonlisted in the policy'snpmLockFileschanges (src/Web/ReactApp/,tests/ui-validation/,tools/) — the selector test suite asserts the selector's list matches the policy; or - any path in the
compliancebucket changes.
A compliance bucket change also forces want_frontend=true, so the
frontend job's create-npm-notices.mjs step (which needs npm ci
node_modules) runs against a policy-only change instead of first failing in
the Docker build. validate-compliance.mjs itself checks the npm bundle
inventory — including stale fallback hashes and missing evidence files — and
does not need node_modules.
The selector cannot track every path the licensing policy reads (LICENSE,
THIRD-PARTY-NOTICES.md, policy-listed docs, Dockerfiles, release workflows,
first-party package manifests). ci-tools therefore runs
node scripts/compliance/validate-compliance.mjs --skip-nuget on every PR:
every check except the NuGet inventory, which needs a restore. The full run
in dependency-compliance adds the NuGet inventory.
scripts/ci/select-dotnet-tests.sh reads either:
CHANGED_FILES_FROM_Z: path to a NUL-terminated file list (preferred), orCHANGED_FILES: newline-separated list (used by the workflow's fallback path).
It classifies each path into one of the buckets below and emits selection
outputs on $GITHUB_OUTPUT. --no-renames is passed on every git diff
invocation so that renames decompose to add+delete pairs — both endpoints
classify.
| Bucket and exact path selector | Frontend | .NET build | .NET tests | Migration drift | Full-safe |
|---|---|---|---|---|---|
frontend: src/Web/** |
✓ | ||||
wire_contract: fixtures/wire-contracts/manifest.json, fixtures/wire-contracts/api/**/*.json |
✓ | Farm.Web.Api.Tests |
|||
api: src/api/** |
✓ | Farm.Web.Api.Tests, Farm.Slicer.Module.Tests, Farm.Web.IntegrationTests, Farm.Modules.Identity.Tests, Farm.Modules.Inventory.Tests, Farm.Modules.Administration.Tests |
AppPg, AppSqlServer |
||
infra: src/infra/** |
✓ | Farm.Infrastructure.Tests, Farm.Slicer.Module.Tests, Farm.OrcaSlicer.Worker.Tests, Farm.Modules.SmartPlug.Tests, Farm.Modules.PrintQueue.Tests, Farm.Modules.Maintenance.Tests, Farm.Modules.Calibration.Tests, Farm.Modules.Devices.Tests, Farm.Modules.Gcode.Tests, Farm.Modules.Identity.Tests, Farm.Modules.Inventory.Tests, Farm.Modules.Administration.Tests, Farm.Modules.Observability.Tests, Farm.Modules.Printers.Tests, Farm.Backend.Plugins.Tests |
AppPg, AppSqlServer |
||
backend_core: src/backends/Farm.Backend.Plugin.Core/** |
✓ | Farm.Web.Api.Tests, Farm.Slicer.Module.Tests, Farm.OrcaSlicer.Worker.Tests, Farm.Web.IntegrationTests, Farm.Infrastructure.Tests, Farm.Backend.Plugins.Tests, Farm.Modules.Printers.Tests |
|||
backend_plugin: every other src/backends/** path (concrete plugin projects) |
✓ | Farm.Web.Api.Tests, Farm.Web.IntegrationTests, Farm.Infrastructure.Tests, Farm.Modules.PrintQueue.Tests, Farm.Backend.Plugins.Tests |
|||
slicer: src/slicer/**, src/Slicers/**, src/worker-shared/** |
✓ | Farm.Web.Api.Tests, Farm.Slicer.Module.Tests, Farm.OrcaSlicer.Worker.Tests, Farm.Web.IntegrationTests, Farm.Infrastructure.Tests, Farm.Modules.PrintQueue.Tests, Farm.Modules.Calibration.Tests, Farm.Modules.Gcode.Tests |
SlicerPg, SlicerSqlServer |
||
orca_worker: src/orcaslicer-worker/** |
✓ | Farm.OrcaSlicer.Worker.Tests |
|||
smartplug: src/modules/Farm.Modules.SmartPlug/** |
✓ | Farm.Modules.SmartPlug.Tests, Farm.Web.Api.Tests |
|||
printqueue: src/modules/Farm.Modules.PrintQueue/** |
✓ | Farm.Modules.PrintQueue.Tests, Farm.Modules.Printers.Tests, Farm.Web.Api.Tests |
|||
maintenance: src/modules/Farm.Modules.Maintenance/** |
✓ | Farm.Modules.Maintenance.Tests, Farm.Web.Api.Tests |
|||
calibration: src/modules/Farm.Modules.Calibration/** |
✓ | Farm.Modules.Calibration.Tests, Farm.Modules.Gcode.Tests, Farm.Modules.Printers.Tests, Farm.Web.Api.Tests |
|||
gcode: src/modules/Farm.Modules.Gcode/** |
✓ | Farm.Modules.Gcode.Tests, Farm.Web.Api.Tests |
|||
identity: src/modules/Farm.Modules.Identity/** |
✓ | Farm.Modules.Identity.Tests, Farm.Web.Api.Tests |
|||
inventory: src/modules/Farm.Modules.Inventory/** |
✓ | Farm.Modules.Inventory.Tests, Farm.Web.Api.Tests |
|||
administration: src/modules/Farm.Modules.Administration/** |
✓ | Farm.Modules.Administration.Tests, Farm.Web.Api.Tests |
|||
observability: src/modules/Farm.Modules.Observability/** |
✓ | Farm.Modules.Observability.Tests, Farm.Web.Api.Tests |
|||
printers: src/modules/Farm.Modules.Printers/** |
✓ | Farm.Modules.Printers.Tests, Farm.Web.Api.Tests |
|||
migrations_app: src/migrations/Farm.Migrations.*/** |
✓ | Farm.Web.Api.Tests, Farm.Web.IntegrationTests, Farm.Infrastructure.Tests |
AppPg, AppSqlServer |
||
migrations_slcr: src/migrations/Farm.Slicer.Migrations.*/** |
✓ | Farm.Web.Api.Tests, Farm.Slicer.Module.Tests, Farm.Web.IntegrationTests, Farm.Infrastructure.Tests |
SlicerPg, SlicerSqlServer |
||
tests_api: src/tests/Farm.Web.Api.Tests/** |
✓ | Farm.Web.Api.Tests, Farm.Modules.Identity.Tests, Farm.Modules.Inventory.Tests, Farm.Modules.Administration.Tests |
|||
tests_infra: src/tests/Farm.Infrastructure.Tests/** |
✓ | Farm.Infrastructure.Tests |
|||
tests_backend_plugins: src/tests/Farm.Backend.Plugins.Tests/** |
✓ | Farm.Backend.Plugins.Tests |
|||
tests_slicer: src/tests/Farm.Slicer.Module.Tests/** |
✓ | Farm.Slicer.Module.Tests |
|||
tests_orca: src/tests/Farm.OrcaSlicer.Worker.Tests/** |
✓ | Farm.OrcaSlicer.Worker.Tests |
|||
tests_smartplug: src/tests/Farm.Modules.SmartPlug.Tests/** |
✓ | Farm.Modules.SmartPlug.Tests |
|||
tests_printqueue: src/tests/Farm.Modules.PrintQueue.Tests/** |
✓ | Farm.Modules.PrintQueue.Tests |
|||
tests_maintenance: src/tests/Farm.Modules.Maintenance.Tests/** |
✓ | Farm.Modules.Maintenance.Tests |
|||
tests_calibration: src/tests/Farm.Modules.Calibration.Tests/** |
✓ | Farm.Modules.Calibration.Tests |
|||
tests_gcode: src/tests/Farm.Modules.Gcode.Tests/** |
✓ | Farm.Modules.Gcode.Tests |
|||
tests_identity: src/tests/Farm.Modules.Identity.Tests/** |
✓ | Farm.Modules.Identity.Tests |
|||
tests_inventory: src/tests/Farm.Modules.Inventory.Tests/** |
✓ | Farm.Modules.Inventory.Tests |
|||
tests_administration: src/tests/Farm.Modules.Administration.Tests/** |
✓ | Farm.Modules.Administration.Tests |
|||
tests_observability: src/tests/Farm.Modules.Observability.Tests/** |
✓ | Farm.Modules.Observability.Tests |
|||
tests_printers: src/tests/Farm.Modules.Printers.Tests/** |
✓ | Farm.Modules.Printers.Tests |
|||
tests_integration: src/tests/Farm.Web.IntegrationTests/** |
✓ | Farm.Web.IntegrationTests |
|||
tests_shared: src/tests/Farm.Testing.Shared/** |
✓ | ✓ | all | all | ✓ |
tests_other: every other src/tests/** path |
✓ | ✓ | all | all | ✓ |
discovery: src/discovery/**, src/printer-discovery/** |
✓ | ✓ | all | all | ✓ |
settings: src/settings/** |
✓ | ✓ | all | all | ✓ |
shared_config: global.json, any *.sln, Directory.Build.*, Directory.Packages.props, NuGet.Config, src/.editorconfig |
✓ | ✓ | all | all | ✓ |
ci_selector: .github/workflows/**, scripts/ci/**, .githooks/**, .devcontainer/** |
✓ | ✓ | all | all | ✓ |
unknown_src: every other src/** path |
✓ | ✓ | all | all | ✓ |
tools: src/tools/** |
✓ | ||||
docs: docs/**, root *.md, LICENSE*, root .editorconfig, .gitignore, .gitattributes |
|||||
mobile: mobile/** |
|||||
compliance: compliance/**, scripts/compliance/** (dependency-compliance too) |
✓ | ||||
unclassified: every other repository path |
Canonical API corpus inputs also drive the iOS selector. Changes to
fixtures/wire-contracts/manifest.json, API fixture JSON, src/api/Program.cs,
src/api/Startup/{Controller,SignalR}Startup.cs, serialization-source C# under
src/infra/{Contracts,Domain,Dtos,Json,Models,Serialization}, the canonical
parts-inventory ProblemDetails producer, or any src/infra/**/*Contract.cs
run the real iOS unit-test job so WireContractCorpusTests exercises the
payloads through APIClient. Corpus documentation and lock files remain inert.
infra and the Farm.Web.Api test legs (issue #2033): an src/infra/**-only
change no longer selects the Farm.Web.Api.Tests/Farm.Web.IntegrationTests matrix
legs. Those legs restore/build/test Farm.Web.Api.csproj (or its Farm.Web.Api
production reference) as part of their own step, so re-running them on an
infra-only change would pay for a full Farm.Web.Api build inside a test leg. The
separate dotnet-build job — still forced by want_dotnet_build=true for infra —
already compiles Farm.Web.Api against the change, so compile coverage is not lost;
only the (slower) web-host test suite is skipped. Farm.Infrastructure.Tests itself
has no ProjectReference to Farm.Web.Api, so selecting it satisfies "a change
touching only src/infra/** selects this leg without building Farm.Web.Api."
The narrow exception is an API serialization-source change under
src/infra/{Contracts,Domain,Dtos,Json,Models,Serialization} or any
src/infra/**/*Contract.cs, plus
src/infra/Infrastructure/PartsInventory/PartsInventoryProblemDetails.cs;
those paths additionally select Farm.Web.Api.Tests so the producer
wire-contract assertions execute.
ci-tools is unconditional and therefore runs for every bucket, including
docs, mobile, and unclassified. dependency-compliance is gated on
want_dependency_compliance: it runs for every bucket that sets the
".NET build" ✓ column above, plus the compliance bucket and the npm
manifests described in dependency-compliance gating.
It does NOT run for docs- or mobile-only buckets, or for React source
changes that leave package.json/package-lock.json untouched, but DOES run
for a tools-only bucket, since src/tools/** sets .NET build to ✓ (a
tools-only change still needs the restored project.assets.json the
validator reads).
Unlike orca_worker (a pure-service module with no owned controller),
smartplug also selects Farm.Web.Api.Tests: AdminPowerMonitorsController
moved into Farm.Modules.SmartPlug, but its own coverage
(RouteTableSnapshotTests, the CustomWebApplicationFactory-based
AdminPowerMonitorsControllerTests) intentionally stayed behind in
Farm.Web.Api.Tests — see docs/MODULE_MIGRATION_PATTERN.md. Any future
Farm.Modules.* phase (9-18) that owns a controller must add its API-tests
project the same way; a phase that moves only services (no controller) can
stay as narrow as orca_worker.
printqueue (issue #2040, Phase 12) follows the same controller-owning
pattern as smartplug: PrintJobManagementService and its 8 dependent
controllers (including SlicePrintBridgeController) moved into
Farm.Modules.PrintQueue, but the Dispatch/ CustomWebApplicationFactory
integration suite and RouteTableSnapshotTests intentionally stayed behind
in Farm.Web.Api.Tests, so printqueue also selects it. Unlike
smartplug, Farm.Modules.PrintQueue also references Farm.Slicer.Module
directly (SlicePrintBridgeController consumes IArtifactsService/
ISliceJobRepository) and its test project references
Farm.Backend.Plugin.OctoPrint directly (PrintJobManagementService
History-seeding tests), so the slicer and backend_plugin rows above
also list Farm.Modules.PrintQueue.Tests as a dependent.
Similarly, maintenance selects Farm.Web.Api.Tests: five controllers plus
MaintenanceHub (the first SignalR hub extracted into a module) moved into
Farm.Modules.Maintenance, but RouteTableSnapshotTests,
MaintenanceHubAuthorizationIntegrationTests, and
MaintenanceScheduleDeploymentToolheadScopeTests intentionally stayed behind
in Farm.Web.Api.Tests.
calibration follows the same controller-owning pattern: its two moved
controllers' own coverage (RouteTableSnapshotTests, the calibration
contract-negotiation and health-check tests) intentionally stayed behind in
Farm.Web.Api.Tests, so the bucket selects both Farm.Modules.Calibration.Tests
and Farm.Web.Api.Tests. Because Farm.Modules.Calibration depends on both
Farm.Infrastructure and Farm.Slicer.Module, the infra and slicer buckets
also select Farm.Modules.Calibration.Tests. Farm.Modules.Gcode project-references
Farm.Modules.Calibration directly (GcodeArtifactPromoter implements
IGcodeArtifactPromoter, which moved into Calibration in Phase 10), so the
calibration bucket also selects Farm.Modules.Gcode.Tests -- a Calibration-only
change must re-run Gcode's tests too.
gcode follows the same controller-owning pattern: its five moved
controllers' own coverage (RouteTableSnapshotTests) intentionally stayed
behind in Farm.Web.Api.Tests, so the bucket selects both
Farm.Modules.Gcode.Tests and Farm.Web.Api.Tests. Because
Farm.Modules.Gcode depends on Farm.Infrastructure,
Farm.Slicer.Module/Farm.Slicer.Module.Api (this module requires
AddSlicerModule on), and Farm.Modules.Calibration (GcodeArtifactPromoter
implements IGcodeArtifactPromoter, which moved into Calibration in Phase 10),
the infra, slicer, and calibration buckets also select
Farm.Modules.Gcode.Tests.
identity follows the same controller-owning pattern: its nine moved
controllers' own coverage (the 4 reflection-based architecture tests,
RouteTableSnapshotTests, and the genuine CustomWebApplicationFactory
integration tests exercising the assembled host) intentionally stayed behind in
Farm.Web.Api.Tests, so the bucket selects both Farm.Modules.Identity.Tests
and Farm.Web.Api.Tests. Unlike the other modules, one relocated test
(SecurityAuditControllerTests) did move into Farm.Modules.Identity.Tests
even though it depends on the host's CustomWebApplicationFactory<Program> —
resolved via a ProjectReference from Farm.Modules.Identity.Tests to
Farm.Web.Api.Tests plus a matching InternalsVisibleTo. That reverse
dependency is why the api and tests_api buckets also select
Farm.Modules.Identity.Tests: an api-only or Farm.Web.Api.Tests-only change
can alter CustomWebApplicationFactory's runtime behavior without touching any
identity-owned path, and would otherwise silently escape re-selection.
inventory follows the same controller-owning pattern: its eleven moved
controllers' own retained route coverage (RouteTableSnapshotTests) still stays
behind in Farm.Web.Api.Tests, so the bucket selects both
Farm.Modules.Inventory.Tests and Farm.Web.Api.Tests. Like identity, some
relocated tests in Farm.Modules.Inventory.Tests also reuse
CustomWebApplicationFactory and shared TestInfrastructure helpers from
Farm.Web.Api.Tests via a direct project reference, which is why the api,
infra, and tests_api rows above also include Farm.Modules.Inventory.Tests.
administration (issue #2042, Phase 14) follows the same controller-owning,
reverse-dependency pattern as identity: the Admin Control Center overview
aggregation, admin data export/import, Home Assistant, and Telegram admin
controllers, plus the settings and unified-settings controllers, moved into
Farm.Modules.Administration, but RouteTableSnapshotTests intentionally
stayed behind in Farm.Web.Api.Tests, so the bucket selects both
Farm.Modules.Administration.Tests and Farm.Web.Api.Tests. Three relocated
tests (AdminDataControllerTests, UnifiedSettingsPerKeyPostTests,
UnifiedSettingsAnonymousAccessTests) moved into
Farm.Modules.Administration.Tests even though they depend on the host's
CustomWebApplicationFactory<Program> — resolved via a ProjectReference
from Farm.Modules.Administration.Tests to Farm.Web.Api.Tests plus a
matching InternalsVisibleTo. That reverse dependency is why the api and
tests_api buckets also select Farm.Modules.Administration.Tests: an
api-only or Farm.Web.Api.Tests-only change can alter
CustomWebApplicationFactory's runtime behavior without touching any
administration-owned path, and would otherwise silently escape re-selection.
Farm.Modules.Administration also depends on Farm.Settings.Abstractions
directly (for SettingsController/UnifiedSettingsController's
IAppSetting/IValidatableSetting types), in addition to Farm.Infrastructure,
which is why the infra bucket also selects Farm.Modules.Administration.Tests.
- Any of:
shared_config,ci_selector,unknown_src,discovery,settings,tests_other,devcontainer. workflow_dispatchevent.pushtomainordevelopment.- Caller sets
FORCE_FULL_SAFE=1. - NUL-parse failure of the
_Zfile. - Git-quoted path detected in newline-form input (non-ASCII name → forces full-safe).
The workflow intentionally has no push.paths filter. Every push to main or
development dispatches CI, and the selector forces full-safe before reading
the changed-path set.
Farm.Web.IntegrationTests is invoked as a project-scoped matrix leg, not via
farm-web.sln. The selector emits run_integration=true for that leg. When it
is selected, dotnet-build restores and builds it explicitly with
-p:RunIntegrationTests=true after the solution build. Its consumer then runs
the compiled DLL in assembly mode, where project-evaluation properties no
longer apply.
Each matrix leg also carries a filter field. The default PR gate uses
Category!=DbHeavy&Category!=Docker, and dotnet-test passes that value through
to assembly-mode dotnet test <test.dll> --filter, so the selector and workflow
stay aligned without re-encoding the same category rule in one branch only.
VSTest applies the same =, !=, ~, |, and & filter grammar in assembly
mode. This keeps the ordinary PR path narrow while leaving provider-heavy
DbHeavy / Docker runs to the separate provider job and the fail-closed
full-safe matrix.
Projects with manifest shards expand into one leg per shard. Leg names use
<leg>-<shard> (for example, Farm.Web.Api.Tests-core), which is readable in
the checks UI and safe for the leg's TRX filename and artifact name. Every
shard keeps the same project path and run_integration value. Its effective
filter is:
(<shard FullyQualifiedName filter>)&(<project defaultFilter>)
The parentheses are required because shard namespace clauses use |, while
the category exclusions use &. Thus Farm.Web.Api.Tests runs as core,
infra, and services in parallel without reintroducing provider-heavy tests.
Projects whose shards list is empty retain their previous single matrix leg
unchanged.
The workflow compiles the solution once in dotnet-build. It packages only
each selected runnable project's bin/Debug/net10.0 directory as a
pre-compressed .tgz, then uploads that single file with
actions/upload-artifact@v7 and archive: false. Consumers use
actions/download-artifact@v8 and fetch only the project archives they need.
Artifacts are keyed by matrix.project, not matrix.name: multiple matrix
shards can execute the same assembly without rebuilding or publishing duplicate
outputs.
The workflow deliberately does not transport obj/. Generated
project.assets.json and *.nuget.g.props files embed absolute workspace,
package-cache, and source-root paths. Those paths commonly match between two
GitHub-hosted Ubuntu runners, but they are not contractual, especially while
the workflow selects a floating 10.0.x SDK patch. Tests avoid project
evaluation entirely by executing the compiled DLL. EF consumers still need
project metadata, so each migration leg performs a measured 7–9 second local
restore before running dotnet ef --no-build; the provider job similarly
restores locally before consuming the API-test and App-migration binaries.
Per-project archives are also an economic constraint, not just organization.
A measured build produced about 4 GiB under all bin/Debug trees; even the 11
relevant output directories were about 1.8 GiB raw. One 714 MiB compressed
monolith downloaded by every consumer would transfer roughly 8.4 GiB per
full-safe run. Project archives preserve dependency closures but prevent each
leg from downloading unrelated test and migration outputs. Tar also turns
hundreds of filesystem entries into one transfer object and preserves
permissions without paying for a second artifact compression pass.
- Ordinary
dotnet-testmatrix legs excludeDbHeavyandDockercategories through their--filter. Thedotnet-test-providersjob executes those categories on the same ordinary .NET PR runs wheneverwant_dotnet_test=true.
CI originally dropped its dotnet format step and relied on the pre-push
hook below. Because that hook is local and opt-in, it never ran in agent
worktrees or in host checkouts that skipped .githooks/setup.sh. As a
result, 140 real diagnostics (missing BOMs, whitespace, import ordering, and
braces) accumulated on development. The dotnet-format job now enforces
the documented command on the server. It is gated on want_dotnet_build
(like dotnet-build) and needs only a restore, so it runs in
parallel with dotnet-build. It adds runner minutes but no wall-clock time
before the test fan-out.
Run format verification with .NET SDK 10.0.200 or newer. In the 10.0.1xx
feature band (10.0.100 through 10.0.1xx, including the
mcr.microsoft.com/dotnet/sdk:10.0 images), dotnet format ignores
DiagnosticSuppressors. xunit.analyzers suppresses VSTHRD200 (the
Async suffix rule) on test methods, and the build honors that suppressor,
so the build is clean. The old formatter does not honor it and reports
thousands of false VSTHRD200 errors on xUnit test methods. That produced
the roughly 6,353-line output reported in #2978. Upstream fixed this in
dotnet/sdk#48512 and backported
it to 10.0.2xx only. The 10.0.1xx backport,
dotnet/sdk#51997, is still
unmerged. The CI job fails closed on a 10.0.1xx SDK.
global.json still pins 10.0.100 with latestMinor roll-forward, so it
picks the newest installed 10.0 SDK. Do not raise that pin to fix the
formatter: the Docker build images use the 1xx band. If dotnet format
reports VSTHRD200 on [Fact]/[Theory] methods, check
dotnet --version from src/ before renaming anything.
.githooks/pre-push runs the same check locally before a push. It runs
dotnet format ./farm-web.sln --verify-no-changes against the exact
outgoing Git tree — not your working directory — so local dirty state cannot
poison the check.
- Reads Git's push list from stdin (
<local_ref> <local_sha> <remote_ref> <remote_sha>\n). - For each non-delete ref, computes
.NET-relevant paths in the outgoing diff:src/**/*.cs,src/**/*.csprojsrc/farm-web.sln,src/.editorconfig,src/Directory.Build.props|targets
- If none affected → skip the format run and pass immediately.
- Otherwise, extracts the tip's tree via
git archive | tar -xinto a detached temporary directory and runsdotnet format --verify-no-changes. - Successful verifications are cached; subsequent pushes of the same tree under the same SDK & formatter version skip the run.
src/.editorconfigrequires UTF-8 with a BOM for C# files. Preserve the BOM when creating or rewriting a.csfile; otherwise the unfiltered gate reportsCHARSETeven when the source text is unchanged.- EF Core scaffolds migration history with block-scoped namespaces. The
migration-only
IDE0161override keeps the file-scoped namespace preference for handwritten code while avoiding mass indentation churn in generated migration bodies. Do not manually reformat generated migrations solely to satisfy that style rule. - Intentional analyzer exceptions in handwritten code remain local to the behavior that requires them; do not add those diagnostics to the solution-wide suppression list.
Successful verifications are stamped at:
$(git rev-parse --git-common-dir)/pre-push-fmt-cache/<key>
where <key> is:
sha256(
"pre-push-format-v2" ||
sha256(hook_script) ||
<tree_sha> ||
<dotnet --version> ||
<dotnet format --version>
)
Any of these changing invalidates the cache. All five fields must produce a 64-hex digest — empty SDK or formatter version fails the push closed.
- Missing
dotnetbinary → push rejected (rc=1). - Missing
sha256sum/equivalent → push rejected. - Missing
src/farm-web.slnin the outgoing tree → push rejected. - Empty
dotnet --versionordotnet format --version→ push rejected. - Git-diff failure → push rejected.
The hook is enforced by git push. The documented Git bypass is:
git push --no-verify
# or
git push -nThis skips all local pre-push hooks (including this one) exactly once, per Git's design. Use it in genuine emergencies only.
Local hooks are not server-enforceable. Anyone can bypass or delete their
copy. The dotnet-format CI job is the server-side enforcement; the hook
exists to give the same feedback before a push.
From the repo root:
.githooks/setup.shThis points core.hooksPath at .githooks/ and marks the hooks executable.
Devcontainer setup calls this on first attach; you only need to run it
manually on host-native checkouts.
Baselines depend on runner load, artifact-service throughput, and PR size. Run 32928133031 measured the duplicated restore/build work that this topology removes:
| Full-safe duplicated work | Measured before | Shared-build shape |
|---|---|---|
| Seven ordinary test legs | 1,081 runner-seconds | One project archive download/extract per leg |
| Four migration legs | 577 runner-seconds | 7–9 second local restore per leg plus one project archive |
| Provider job | 242 runner-seconds | Local restore plus three project archives |
| Total restore/build duplication | about 1,900 runner-seconds | One roughly 296-second solution build plus packaging and consumers |
The pre-change experiment projected about 1,640 runner-seconds (27
runner-minutes) saved per full-safe run even with a monolithic archive.
Per-project archives reduce transfer below that conservative model, although
API sharding downloads the same API-project archive once per shard. This is a
billing optimization: the central build becomes a fan-out barrier, so expect a
roughly 60–90 second fan-out delay before test execution. In exchange, API
test sharding targets the measured 26-minute long tail at roughly 12 minutes
wall-clock by running its three partitions concurrently. Narrow .NET
selections can see a smaller version of the shared-build tradeoff. React-only
and docs-only runs are unchanged because dotnet-build remains selector-gated.
The pre-push hook gives local dotnet format feedback before a push. It is
cached after the first successful verification of each tree. The
dotnet-format CI job re-runs the check in parallel with dotnet-build.
-
selectfailed → inspect the "changed paths" section printed to the job summary; treat any surprise as a bug in the selector and add a test case inscripts/ci/tests/test-select-dotnet-tests.sh. -
ci-toolsfailed → the selector or hook tests regressed. Reproduce withbash scripts/ci/tests/test-select-dotnet-tests.shandbash .githooks/tests/test-pre-push.shlocally. -
dependency-compliancefailed → a NuGet or npm package license/provenance check regressed (including a stale npm fallback hash or a missing evidence file), or the solution failed to restore. Reproduce withcd src && dotnet restore ./farm-web.sln && cd .. && node scripts/compliance/validate-compliance.mjs. If it unexpectedly ran (or was skipped) for a given PR, checkwant_dependency_compliancein theselectjob summary — it iswant_dotnet_buildplus npm-manifest andcompliancebucket changes. -
dotnet-formatfailed → the job log lists each file, line, and diagnostic ID. Reproduce withcd src && dotnet restore ./farm-web.sln && dotnet format ./farm-web.sln --verify-no-changes --no-restore. Fix with the same command without--verify-no-changes, using SDK >= 10.0.200 (see Formatter SDK requirement). If the SDK assertion step failed, the runner resolved a 10.0.1xx SDK. -
dotnet-testmatrix leg failed → per-legTestResults/*.trxis uploaded asdotnet-test-results-<leg>artifact. Download and inspect. The workflow also asserts that the TRX reports non-zero executed tests, so an empty test run is a hard failure rather than a silent pass. -
migration-driftfailed →dotnet ef migrations has-pending-model-changesexited non-zero for one or morecontext × providermatrix legs. That exit code does not uniquely mean "the model drifted"; the same non-zero status is also returned for EF Core tooling, design-time context, provider loading, or restore/build failures. Inspect the failing leg'sdotnet efoutput in the job log: if it reports pending model changes, regenerate the affected migration by running (fromsrc/, one invocation per affectedcontext × providerpair):DB_PROVIDER=<postgres|sqlserver> dotnet ef migrations add <PascalCaseName> \ --project ./migrations/<MigrationsProject> \ --startup-project ./migrations/<MigrationsProject> \ --context <AppDbContext|SlicerDbContext>
where
<MigrationsProject>is one ofFarm.Migrations.PostgreSQL,Farm.Migrations.SqlServer,Farm.Slicer.Migrations.PostgreSQL, orFarm.Slicer.Migrations.SqlServer— the matrix leg'sMATRIX_PROJECTvalue in the failing job log identifies which one.AppDbContextpairs with the twoFarm.Migrations.*projects;SlicerDbContextpairs with the twoFarm.Slicer.Migrations.*projects. Commit the generated files undersrc/migrations/<MigrationsProject>/Migrations/alongside the model change. If instead the log reports a tool / design-time / provider / build error, fix that — no new migration is needed.
- New test project: add an entry to the checked manifest
scripts/ci/dotnet-test-manifest.json(name,productionProject,testProject,pathPrefixes,dependsOnProjects,defaultFilter,shards,requiresProviders,runIntegration,leg) and (as needed) the classification map inselect-dotnet-tests.sh, then add a matching test case in the selector suite. Add the project's direct-upload step todotnet-build; consumers derive the matching archive name frommatrix.project. Add the project tofarm-web.slnwhen appropriate, but CI also supports required projects that intentionally live outside the solution. Runbash scripts/ci/tests/test-dotnet-test-manifest.shto confirm the manifest still registers every*.Tests.csprojon disk exactly once (and, forFarm.Web.Api.Tests, that itsshardsremain exhaustive, mutually exclusive, non-empty, and that each xUnit test source is covered by its owning shard'sFullyQualifiedNamefilter) before opening a PR — the same check also runs in theci-toolsjob.bash scripts/ci/tests/test-select-dotnet-tests.shalso fails closed (case_manifest_upload_artifact_sync) if the new project'sdotnet-buildupload step is missing, misspelled, or never added — see "Upload-artifact/manifest sync guard" below — so a forgotten upload step is caught locally and inci-toolsinstead of silently drifting. - New bucket: extend
classify_path()and add a case in the selector suite. - New full-safe trigger: extend the trigger switch in
main()of the selector and add a case. - Docker/external-service opt-in: consider a separate workflow triggered
by
workflow_dispatchrather than expanding this one.
scripts/ci/select-dotnet-tests.sh loads its ALL_TEST_PROJECTS list from
scripts/ci/dotnet-test-manifest.json at startup (via a python3/python
loader; override the path with TEST_MANIFEST_PATH for testing) instead of a
hardcoded array, so there is exactly one checked source of truth per test
project. The manifest is fail-closed: a missing file, invalid JSON, or an
empty testProjects list all exit non-zero (rc=3) rather than silently
producing an empty test matrix.
Per-entry fields:
| Field | Meaning |
|---|---|
name |
Test-project identifier used to select a manifest entry; emitted shard legs derive unique names from it. |
productionProject |
The .csproj under src/ whose changes this test project primarily covers. Documentation only. |
testProject |
Path to the test .csproj, relative to src/. Consumed by the selector and the CI matrix. |
pathPrefixes |
Repo paths whose changes should select this project. Documents the existing classify_path() bucket mapping; not re-interpreted at runtime — see the bucket table above for the authoritative mapping. |
dependsOnProjects |
Additional production paths this test project's coverage depends on (declarative documentation, validated by eye, not enforced). |
defaultFilter |
The dotnet test --filter expression used by the ordinary PR gate. |
shards |
Optional {name, namespacePrefixes, filter} partitions. Each shard becomes a unique matrix leg while retaining the same matrix.project, so all shards consume one compiled project artifact. |
requiresProviders |
Non-empty only for projects with DbHeavy/Docker-tagged tests exercised by the separate dotnet-test-providers job (e.g. ["postgres", "sqlserver"]). |
runIntegration |
true only for Farm.Web.IntegrationTests; passed through as -p:RunIntegrationTests=true. |
leg |
Base CI matrix-leg name. Shards add a unique suffix while matrix.project remains the stable build-artifact identity. CI summary remains the only aggregate required check. |
Farm.Moonraker.Emulator.Tests and Farm.Slicer.ProfileParsing.Tests are
registered in the manifest (see #2022) but have no dedicated bucket in
classify_path() — a change to either project's own directory only
currently reaches them via the tests_other full-safe fallback, and a
change to their production dependencies does not scope-select them. This is
intentionally unchanged by the manifest; giving them a dedicated bucket is a
possible future improvement, not part of this file's job.
Validate the manifest itself with:
bash scripts/ci/tests/test-dotnet-test-manifest.shThe validator's own logic (duplicate-testProject-path detection, full
schema enforcement, fail-closed behavior on a crashed reader) has its own
regression suite, run against mutated copies of the real manifest:
bash scripts/ci/tests/test-dotnet-test-manifest-checks.shThe dotnet-build job's per-project actions/upload-artifact@v7 steps are
hardcoded (see "Extending" above) while the select job's TEST_MATRIX/
MIG_MATRIX outputs are generated at runtime from
scripts/ci/dotnet-test-manifest.json and select-dotnet-tests.sh's
ALL_MIG_ENTRIES migration list. Nothing enforced these two halves stay in
sync until the manifest-upload-artifact guard was added (#2091): a test
project added to the manifest with no matching upload step would silently
fail every consuming test job with a missing-artifact error, and a stale
upload step left behind after removing a project would silently upload
nothing useful.
scripts/ci/tests/test-select-dotnet-tests.sh now asserts, in both
directions, that the set of Upload <name> build steps in the
dotnet-build job of .github/workflows/ci.yml matches exactly the set of
manifest testProjects[].name values plus the four migration project names
parsed out of select-dotnet-tests.sh's own ALL_MIG_ENTRIES array (so the
guard cannot itself drift from the selector's canonical migration list):
case_manifest_upload_artifact_sync— the realci.ymland the real manifest/selector must currently agree.case_upload_artifact_guard_rejects_missing_upload_step— a manifest/ migration project with no matching upload step indotnet-build(e.g. a new test project registered in the manifest but never wired intodotnet-build) is rejected, naming both the project and.github/workflows/ci.yml.case_upload_artifact_guard_rejects_orphaned_upload_step— an upload step naming a project that no longer exists in the manifest or migration list is rejected, naming both the project andscripts/ci/dotnet-test-manifest.json.
This runs as part of bash scripts/ci/tests/test-select-dotnet-tests.sh,
which the ci-tools job already runs on every PR — no separate opt-in step
is required.