Skip to content
Purview Dev logo

Verification Model

Experimental Engineering Playbook Reviewed 2026-10-12 purview-dev/engineering-playbook Star on GitHub architecture-tests best-practices coding-agents conventions csharp documentation dotnet engineering msbuild-sdk tunit

Experimental. This project is an experiment: the public API, defaults and packaging can change between prereleases, and there is no production support guarantee.

Checks are tiered by cost, and a repository declares which tiers its pipeline runs. A pull request runs the fast, deterministic tier. Anything that needs a real external resource is selected deliberately — locally, or on a runner that has the resource.

The tier a test belongs to is a property of where it lives, not of an attribute someone remembered to add: the SDK reads the test project’s name suffix and emits an assembly-level category for it.

Project nameCategoryRuns on a pull request?
<Target>.UnitTestsUnitYes
Architecture.UnitTestsUnitYes
<Target>.ContractTestsContractUsually
<Target>.IntegrationTestsIntegrationOnly when the resource is available
<Target>.E2ETestsE2ENo

Architecture.UnitTests is named that way deliberately, and the obvious alternative is a trap. The SDK takes the category from the word before Tests, so Architecture.ArchitectureTests would get Category=Architecture — and a pull-request filter of [Category=Unit] would quietly stop running the dependency-direction rules. The rules are fast, deterministic and have no external resource, so they belong in the Unit tier; naming them Architecture.UnitTests is what puts them there.

A gate is only useful if it is credible, and credibility comes from speed. The moment the pull-request build needs a database, a container runtime and four minutes of waiting, somebody makes it optional — and then nothing is gated.

The tiering also changes what a test is allowed to assume. A Unit test may assume it runs everywhere, so it may not touch the network, the clock or the file system. An Integration test may assume it runs somewhere real, and pays for that by having to be selected.

Three steps, none of which is a hand-written attribute:

  1. The project name. Domain.UnitTests.csproj — the SDK detects Unit and emits [assembly: Category("Unit")].

  2. The filter. The pipeline passes --treenode-filter, and the repository declares it once:

    {
    "Build": {
    "Solution": "src/Acme.slnx",
    "TestRoot": "src/tests",
    "TestPatterns": "*Tests.csproj",
    "TestFilter": "/*/*/*/*[Category=Unit]"
    }
    }
  3. The run. dotnet test --solution ... --treenode-filter "/*/*/*/*[Category=Unit]".

The filter string is a path, not a search: /*/*/*/* matches the tree-node depth of a test, and [Category=Unit] restricts it to the categorised assemblies. Because the category comes from the assembly, adding a Domain.IntegrationTests project is enough to keep it out of the pull-request tier — no filter edit, no attribute, no list to forget.

That last sentence is only true if TestPatterns is left at its default, *Tests.csproj. TestPatterns and TestFilter are two different mechanisms, applied in that order: TestPatterns decides which project files are discovered under TestRoot, and TestFilter decides which of the discovered tests run. Narrow TestPatterns to *UnitTests.csproj and you have added a second place that has to be edited before a new tier is ever seen — the list to forget, reintroduced. Keep the default, express the tier in TestFilter alone, and reach for TestPatterns only when you genuinely want a project excluded from discovery altogether. See build: Configuration Reference.

The repository owns a caller workflow; the pipeline lives once, in build. The shared workflow’s inputs are the contract:

InputDefaultEffect
dotnet-version10.0.xSDK used by the runner. Pin it when global.json pins one too.
run-teststrueEnable discovered tests.
run-linttrueRun CSharpier.
run-packtruePack.
validate-packtrueValidate the produced packages.
test-filter(from config)Tree-node filter.
test-projects(from config)Restrict which discovered projects run.
release-modeNoneNone, NuGet or GitHubRelease.

Two details are easy to get wrong:

  • The shared workflow’s defaults are not your defaults. It defaults to run-pack: true and validate-pack: true, so a repository that ships no package must turn both off — in the workflow and in purview-build.json, or the two disagree about what a green build means.
  • An optional input left empty is not the same as omitted. Setting Build__TestFilter to an empty string overrides the repository’s configuration, because environment variables outrank the JSON. The shared workflow therefore forwards test-filter and test-projects only when the caller supplied them.

The pattern for a suite that cannot run in the fast tier is a second job in the same workflow, not a disabled test. containers is the reference: the shared pipeline runs the [Category=Unit] tier, and a separate integration-docker job on the same ubuntu-latest runner runs the Docker-backed suites. The suites skip themselves when no daemon is reachable, so a non-zero result is a real failure rather than a missing runtime.

build-sdk shows the other shape: a matrix over three test projects, including an integration suite, because the whole point of that repository is that the SDK works end to end.

Either way the rule holds: the fast tier is always on, and the slow tier is explicit.

purview-build.json is probed for in a documented order, first match wins, and files are never merged:

purview-build.json → .config/purview-build.json → .build/purview-build.json
→ build/purview-build.json → .purview/purview-build.json → .github/purview-build.json

Paths inside it — Build:Solution, Build:TestRoot, PackValidation globs — are anchored to the repository root, wherever the file was found. Moving the file into .config/ therefore does not change what it points at, which is the whole reason it can be moved safely.

The file is validated against a JSON Schema generated from the tool’s own settings, so a mistyped key is an error naming the key rather than a line that is silently ignored. Add the $schema line and your editor completes it.

This playbook ships no NuGet package, so its pipeline turns packing off and its release is a tag:

jobs:
build:
name: Build and test
uses: purview-dev/build/.github/workflows/purview-build.yml@main
with:
run-pack: false
validate-pack: false
test-filter: "/*/*/*/*[Category=Unit]"
secrets: inherit

Three things are visible in that snippet, and all three are deliberate: the repository owns only the caller; the pack switches match .config/purview-build.json; and the filter is the same string as the one in the configuration, so the two cannot drift apart silently.

RuleEnforced by
The fast tier runs on every pull requestThe caller workflow, and branch protection that makes it required
The tier is derived from the project nameThe build (SDK category emission)
The repository’s configuration is validThe pipeline, which validates the JSON against the schema
The slow tier is run before a releaseReview — and, where it matters, a release gate
  • One tier. Everything is UnitTests. Do not add a filter yet.
  • A suite that needs a resource. Add <Project>.IntegrationTests, keep it out of the filter, and give it a second job. Make the suite skip itself when the resource is absent, so a missing runtime is a skip and never a pass.
  • A package. Turn on run-pack and validate-pack, and treat pack validation as the gate that stops a package shipping without the assets it documents.
  • Several repositories. Keep the pipeline shared and let each repository own only its caller workflow and its configuration. That is the arrangement that makes a fix in one place a fix everywhere.