Skip to content

Latest commit

 

History

History
711 lines (611 loc) · 43 KB

File metadata and controls

711 lines (611 loc) · 43 KB

CI: Affected .NET Test Selection & Format Gates

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:


Overview

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
Loading

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.

Jobs

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.

dependency-compliance gating (#1395)

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.json or package-lock.json listed in the policy's npmLockFiles changes (src/Web/ReactApp/, tests/ui-validation/, tools/) — the selector test suite asserts the selector's list matches the policy; or
  • any path in the compliance bucket 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.

Selection logic (selector script)

scripts/ci/select-dotnet-tests.sh reads either:

  • CHANGED_FILES_FROM_Z: path to a NUL-terminated file list (preferred), or
  • CHANGED_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 → downstream mapping

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.

Full-safe (full_matrix=1) triggers

  • Any of: shared_config, ci_selector, unknown_src, discovery, settings, tests_other, devcontainer.
  • workflow_dispatch event.
  • push to main or development.
  • Caller sets FORCE_FULL_SAFE=1.
  • NUL-parse failure of the _Z file.
  • 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.

Shared build artifacts

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.

Exclusions

  • Ordinary dotnet-test matrix legs exclude DbHeavy and Docker categories through their --filter. The dotnet-test-providers job executes those categories on the same ordinary .NET PR runs whenever want_dotnet_test=true.

dotnet-format CI gate (#2978)

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.

Formatter SDK requirement

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.

Pre-push format gate

.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.

Contract

  • 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/**/*.csproj
    • src/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 -x into a detached temporary directory and runs dotnet format --verify-no-changes.
  • Successful verifications are cached; subsequent pushes of the same tree under the same SDK & formatter version skip the run.

C# encoding and generated migrations

  • src/.editorconfig requires UTF-8 with a BOM for C# files. Preserve the BOM when creating or rewriting a .cs file; otherwise the unfiltered gate reports CHARSET even when the source text is unchanged.
  • EF Core scaffolds migration history with block-scoped namespaces. The migration-only IDE0161 override 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.

Cache

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.

Fail-closed behaviour

  • Missing dotnet binary → push rejected (rc=1).
  • Missing sha256sum/equivalent → push rejected.
  • Missing src/farm-web.sln in the outgoing tree → push rejected.
  • Empty dotnet --version or dotnet format --version → push rejected.
  • Git-diff failure → push rejected.

Standard bypass

The hook is enforced by git push. The documented Git bypass is:

git push --no-verify
# or
git push -n

This 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.

Install the hooks

From the repo root:

.githooks/setup.sh

This 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.

Timing (order-of-magnitude)

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.

Failure diagnosis

  • select failed → inspect the "changed paths" section printed to the job summary; treat any surprise as a bug in the selector and add a test case in scripts/ci/tests/test-select-dotnet-tests.sh.

  • ci-tools failed → the selector or hook tests regressed. Reproduce with bash scripts/ci/tests/test-select-dotnet-tests.sh and bash .githooks/tests/test-pre-push.sh locally.

  • dependency-compliance failed → 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 with cd src && dotnet restore ./farm-web.sln && cd .. && node scripts/compliance/validate-compliance.mjs. If it unexpectedly ran (or was skipped) for a given PR, check want_dependency_compliance in the select job summary — it is want_dotnet_build plus npm-manifest and compliance bucket changes.

  • dotnet-format failed → the job log lists each file, line, and diagnostic ID. Reproduce with cd 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-test matrix leg failed → per-leg TestResults/*.trx is uploaded as dotnet-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-drift failed → dotnet ef migrations has-pending-model-changes exited non-zero for one or more context × provider matrix 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's dotnet ef output in the job log: if it reports pending model changes, regenerate the affected migration by running (from src/, one invocation per affected context × provider pair):

    DB_PROVIDER=<postgres|sqlserver> dotnet ef migrations add <PascalCaseName> \
      --project ./migrations/<MigrationsProject> \
      --startup-project ./migrations/<MigrationsProject> \
      --context <AppDbContext|SlicerDbContext>

    where <MigrationsProject> is one of Farm.Migrations.PostgreSQL, Farm.Migrations.SqlServer, Farm.Slicer.Migrations.PostgreSQL, or Farm.Slicer.Migrations.SqlServer — the matrix leg's MATRIX_PROJECT value in the failing job log identifies which one. AppDbContext pairs with the two Farm.Migrations.* projects; SlicerDbContext pairs with the two Farm.Slicer.Migrations.* projects. Commit the generated files under src/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.

Extending

  • 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 in select-dotnet-tests.sh, then add a matching test case in the selector suite. Add the project's direct-upload step to dotnet-build; consumers derive the matching archive name from matrix.project. Add the project to farm-web.sln when appropriate, but CI also supports required projects that intentionally live outside the solution. Run bash scripts/ci/tests/test-dotnet-test-manifest.sh to confirm the manifest still registers every *.Tests.csproj on disk exactly once (and, for Farm.Web.Api.Tests, that its shards remain exhaustive, mutually exclusive, non-empty, and that each xUnit test source is covered by its owning shard's FullyQualifiedName filter) before opening a PR — the same check also runs in the ci-tools job. bash scripts/ci/tests/test-select-dotnet-tests.sh also fails closed (case_manifest_upload_artifact_sync) if the new project's dotnet-build upload step is missing, misspelled, or never added — see "Upload-artifact/manifest sync guard" below — so a forgotten upload step is caught locally and in ci-tools instead 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_dispatch rather than expanding this one.

Test-project manifest

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.sh

The 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.sh

Upload-artifact/manifest sync guard

The 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 real ci.yml and the real manifest/selector must currently agree.
  • case_upload_artifact_guard_rejects_missing_upload_step — a manifest/ migration project with no matching upload step in dotnet-build (e.g. a new test project registered in the manifest but never wired into dotnet-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 and scripts/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.