# Build > The shared build, test, and release system for purview-dev. The shared build/test/release system for the purview-dev organisation. A Modular Pipelines based .NET tool packaged as a pinned dotnet tool, exposed through a GitHub composite action and thin reusable workflows. Consuming repositories own configuration; they do not own pipeline source code. - Repository: https://github.com/purview-dev/build - Package: https://www.nuget.org/packages/Purview.Build - Project page: https://purview.dev/projects/build/ - Documentation: https://purview.dev/docs/build/ - Full machine-readable content: https://purview.dev/projects/build/llms-full.txt # Getting Started `Purview.Build` is consumed from a repository that owns its own configuration. The pipeline code lives here; consumers reference the composite action or one of the reusable workflows (or run the tool locally) and configure it with `purview-build.json`. ## Two independent version axes - The `@ref` suffix on a reusable-workflow or composite-action reference selects the workflow/action **code**: `@main` always runs the latest code, while a release tag (e.g. `@v0.2.0`) pins it for reproducibility. There is no `@latest`; the `@ref` is required for cross-repository references. - The `build-version` input selects the installed `Purview.Build` **tool**. Omit it to always install the latest stable tool from nuget.org, or pin an exact version (e.g. `build-version: 0.2.0`) for reproducibility. Mixing a pinned old `@ref` with a floating `build-version` runs newer tool code through older workflow inputs. ## Minimal repository setup (reusable workflow) ```yaml # .github/workflows/pr.yml name: PR on: pull_request: branches: [main] jobs: build: uses: purview-dev/build/.github/workflows/purview-build.yml@main # `build-version` is optional; when omitted, the latest stable Purview.Build # from nuget.org is installed. Pin it (e.g. `build-version: 0.2.0`) for # reproducible builds. secrets: inherit ``` ```yaml # .github/workflows/release.yml — release on main name: Release on: push: branches: [main] concurrency: # Serialize releases; callers own concurrency (see Release Flow). group: release-${{ github.ref }} cancel-in-progress: false jobs: release: uses: purview-dev/build/.github/workflows/purview-release.yml@main with: release-mode: NuGet secrets: inherit ``` For the **main-as-head / release-branch model**, point the release caller at the release branch instead: ```yaml on: push: branches: [release] ``` The reusable release workflow checks whether `v{version}` (read from `package.json`) is already tagged and skips if so, so merging `main` into `release` releases exactly once. The reusable workflows install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. ## Minimal repository setup (composite action) ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: purview-dev/build/.github/actions/purview-build@main env: Build__TestFilter: "/*/*/*/*[Category=Unit]" ``` The action's `build-version` input is optional. When omitted, `dotnet tool install` resolves the latest stable `Purview.Build` from nuget.org; pass an exact version to pin the build. ## Local use ```shell dotnet tool install Purview.Build --tool-path ./.tools ./.tools/purview-build ``` Omit `--version` to install the latest stable release. ## Release a consuming repository Releasing consists of bumping the `version` field in the repository's root `package.json` and merging the validated pull request into the release head. The pipeline owns tagging (`v{version}`) and the GitHub release; maintainers must not push release tags manually. See [Release Flow](release-flow/). ## See also - [Architecture](architecture/) - [Configuration Reference](configuration-reference/) - [Pipeline Modules](pipeline-modules/) - [Secrets and Environment Variables](secrets-and-environment-variables/) --- # Architecture ## Decision The shared artifact is a .NET tool NuGet package, not a reusable workflow and not an MSBuild SDK. Modular Pipelines is an executable orchestration system, so a tool is its natural package boundary. A tool manifest gives each consumer deterministic version pinning and Renovate/Dependabot-compatible upgrades. It also keeps GitHub Actions as a thin host; the same command runs locally, in GitHub Actions, or in another CI service. The implementation is the generalized `PipelineCLI` that originated in `sourcegeneratorframework` (its most advanced version, including pack validation). It supersedes the earlier `Purview.Build` modules. The repository additionally exposes: - a **composite action** (`.github/actions/purview-build`) that installs a pinned `Purview.Build` version and runs it, for repositories embedding the build in their own jobs, and - two **reusable workflows** (`purview-build.yml`, `purview-release.yml`) that wrap that logic with structured inputs/secrets, reducing a consumer to one reusable-workflow job plus `purview-build.json`. An MSBuild SDK remains a possible future companion for shared compile-time properties, analyzers, or package metadata. It should not own CI orchestration. ## Ownership boundary The package owns module implementation, dependency ordering, safe defaults, secret lookup, NuGet/GitHub integration, and diagnostics. Each repository owns its tool-version pin, paths and discovery patterns, feature switches, and release-mode selection. A project needing truly custom behavior can invoke its own command before/after the shared tool; a generally useful variation should be added as a typed option here. ## Module ordering The pipeline is registered in `BuildPipeline.cs` (`Program.cs` is the CLI boundary: informational options, the version banner, configuration binding, and failure reporting) in this order, with explicit `[DependsOn]` edges defining the graph: ```text CleanArtifactsModule → RestoreModule → BuildModule → RunTestsModule ─┐ CleanArtifactsModule → RestoreModule → LintModule ├→ PackModule → ValidatePackModule CleanArtifactsModule → VersionModule ────────────────────────────────┘ ``` Explicit `[DependsOn]` edges: - `VersionModule` and `RestoreModule` depend on `CleanArtifactsModule`, so the artifacts folder is reset before any other module starts. - `BuildModule` depends on `RestoreModule`. - `LintModule` depends on `RestoreModule` (Web lint needs the dependencies installed by `bun install`; dotnet lint is unaffected beyond running after restore). - `RunTestsModule` depends on `BuildModule`. - `PackModule` depends on `RunTestsModule` and `VersionModule`. - `ValidatePackModule` depends on `PackModule`. - `PublishNuGetModule` depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. - `PublishLocalNuGetModule` depends on `PackModule` and `ValidatePackModule`. - `CreateGitHubReleaseModule` depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. See [Pipeline Modules](../pipeline-modules/) for per-module behavior and skip conditions. ## Repository root resolution The tool locates the repository root by walking up from the current working directory to the nearest `package.json` (`PathHelpers.FindRepositoryRoot`); `Environment.CurrentDirectory` is set to that root before modules run. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json` when the defaults do not apply. Command-line overrides use configuration syntax, for example: ```shell dotnet purview-build --Build:TestPatterns=*IntegrationTests.csproj --Build:RunPack=false ``` ## Project, testing, and release support - **Project types**: the pipeline is dotnet-first (libraries, source generators, analyzers, MSBuild SDKs, Aspire hosting extensions), gated by `Build:ProjectType`. Non-dotnet project types are implemented as configuration-gated module branches: - `Web` (Bun/JS/TS sites such as the Astro/Starlight purview.dev portal) runs the repository's root `package.json` scripts: `bun install` (restore), `bun run build` (build, after an automatic `data:sync` when one is declared and the command is left at the default), `bun run format:check` + `bun run lint` (lint), and `bun run test` (tests). The Web pack step zips `Build:WebBuildOutput` into `Build:ArtifactsFolder` as `-.zip`. Every Web command is overridable via the `Web*` settings. - `WebExtension` (JS/Azure DevOps extensions) remains future work. - **Testing types**: TUnit on Microsoft.Testing.Platform (default) and xUnit, both configurable via `TestFramework`/`TestFilter`. Web projects run the `WebTestCommand` (default `bun run test`); other non-dotnet runners (Vitest, Playwright, Jest) can be targeted by overriding that command. - **Release types**: nuget.org (API key or Trusted Publishing), GitHub Packages internal feed, local NuGet feed, GitHub release (optionally with package/vsix assets), and future Aspire-deploy / Azure DevOps marketplace publishing. ## Release behavior - `None`: build/test/pack may run, but nothing publishes. - `LocalNuGet`: pushes packages to the resolved local feed for developer testing. Only honoured when the tool runs **locally**; it is ignored in CI, so it cannot be driven through the reusable workflows. - `NuGet`: pushes packages to the configured feed and, by default, creates a GitHub release. - `GitHubRelease`: creates a GitHub release (optionally uploading `ArtifactsFolder` assets) without publishing NuGet packages. The workflow decides whether a version is eligible to release (for example, only an untagged version on `main` or `release`) and sets `Release__Mode`. Credentials remain CI secrets. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) --- # Configuration Reference Configuration is optional in a consuming repository; defaults are baked into the tool. Add `purview-build.json` at the repository root to override them. ## Precedence Command line > environment variables > `purview-build.json` > baked-in defaults (`appsettings.json`) > code-level defaults. - Environment variables use `__` for nesting, for example `Release__Mode=NuGet`. - Command-line overrides use configuration syntax, for example `--Build:RunPack=false`. - Secrets must not be committed; they are supplied at runtime through env vars / CI secrets. See [Secrets and Environment Variables](../secrets-and-environment-variables/). ### Informational options | Option | Behaviour | | --- | --- | | `-v`, `--version` | Print the tool version and exit without running the pipeline. | | `-h`, `--help`, `-?` | Print usage, options, and configuration keys, then exit. | Failures are reported the way a CLI build tool reports them: the tool prints the failing module and that module's output, then exits with code 1. Set `PURVIEW_BUILD_STACKTRACE=1` to add stack traces when diagnosing the tool itself. ## `Build` | Key | Default | Purpose | | --- | --- | --- | | `LogLevel` | `Information` | `Trace`/`Debug`/`Information`/`Warning`/`Error`/`Critical`/`None`; applied to the pipeline logger. `Information` reports every module's command output, progress, and completion; `Warning` keeps CI logs quiet | | `ProjectType` | `DotNet` | `DotNet` (dotnet restore/build/test/pack) or `Web` (Bun commands from the root `package.json` scripts) | | `Solution` | `src/Product.slnx` | Solution, project, or directory passed to restore/build/pack (dotnet only) | | `Configuration` | `Release` | .NET configuration | | `ArtifactsFolder` | `artifacts` | Package output directory | | `CleanArtifacts` | `true` | Delete and recreate `ArtifactsFolder` before the run produces anything, so validation, publishing, and release uploads only see the current run's packages. Ignored when `RunPack` is `false` | | `RunTests` | `true` | Enable discovered tests | | `TestRoot` | `src/tests` | Test discovery root (relative to the repository root) | | `TestPatterns` | `*Tests.csproj` | Comma-separated project search patterns applied under `TestRoot` | | `TestProjects` | `*` | Comma-separated project names/globs to run; `*` runs all discovered | | `TestFramework` | `TUnit` | `TUnit` (tree-node filter) or `xUnit` (VSTest filter) | | `TestFilter` | `/*/*/*/*/` | TUnit tree-node filter or xUnit `--filter`; empty disables it | | `RunLint` | `true` | Restore local tools and run CSharpier check (dotnet) or `format:check` + `lint` scripts (Web) | | `RunPack` | `true` | Enable packing | | `ValidatePack` | `true` | Enable pack validation (dotnet only; always skipped for Web) | | `WebInstallCommand` | `bun install` | Install command for `ProjectType=Web` | | `WebBuildCommand` | `bun run build` | Build command for `ProjectType=Web`; when left at the default the module first runs a `data:sync` script if one is declared | | `WebLintCommand` | `bun run lint` | Lint command for `ProjectType=Web` | | `WebFormatCheckCommand` | `bun run format:check` | Format-check command for `ProjectType=Web` | | `WebTestCommand` | `bun run test` | Test command for `ProjectType=Web` | | `WebBuildOutput` | `src/dist` | Directory zipped into `ArtifactsFolder` by the Web pack step | ## `PackValidation` | Key | Default | Purpose | | --- | --- | --- | | `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa, except analyzer-only packages that embed their PDBs under `analyzers/dotnet/` | | `RequireSymbolFiles` | `true` | Every `.snupkg` must contain at least one `.pdb` | | `RequireSourceLink` | `false` | Every `.dll`/`.exe` must have a matching portable PDB containing a Source Link record | | `RequireDeterministic` | `false` | Every `.dll`/`.exe` must be built deterministically (the PE carries the Reproducible debug directory entry) | | `RequiredCompilerFlags` | `[]` | Compiler-flag `key=value` entries that must appear in each assembly's PDB compiler-flags record (e.g. `optimization=release`) | | `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package). Entries may use the `$(TFM)` target-framework partial token, e.g. `lib/$(TFM)/Foo.dll` | | `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package). Also supports the `$(TFM)` partial token | | `RequireExplicitContent` | `false` | Makes `RequiredContent` exhaustive: every generated package must match a rule, and every non-metadata entry must match a declared glob; undeclared packages/entries are errors | Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Entries may also contain the `$(TFM)` partial token (e.g. `lib/$(TFM)/Foo.dll`), which expands to one entry per target framework the package actually ships (short folder name, discovered from the package's own content groups); each expanded entry is checked independently. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. When `RequireExplicitContent` is `true`, `RequiredContent` becomes exhaustive: every generated package must match a rule, and every non-metadata entry in a matched package must match a declared (and `$(TFM)`-expanded) glob — undeclared packages or entries are errors. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). > **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above. ## `NuGet` | Key | Default | Purpose | | --- | --- | --- | | `FeedUrl` | nuget.org v3 | Remote package source | | `TrustedPublishing` | `false` | Push without an API key (NuGet Trusted Publishing / OIDC) | | `APIKey` | unset | Secret; use `NUGET_APIKEY` or `NuGet__ApiKey` | | `EnvAPIKey` | unset | Binds `NuGet__NUGET_APIKEY`; also falls back to process env `NUGET_APIKEY`/`NUGET_API_KEY` | ## `PublishLocalNuGet` | Key | Default | Purpose | | --- | --- | --- | | `LocalFeedPath` | unset | Absolute local package source | | `EnvLocalFeedPath` | unset | Binds `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH`; also falls back to process env `LOCAL_NUGET_FEED_PATH` | | `OverwriteExistingPackages` | `true` | Overwrite packages already in the local feed | | `ShutdownDotnetBuilderServer` | `true` | Shut down the dotnet build server after publishing | | `ClearPackageCache` | `true` | Clear the local NuGet package caches for the published packages | ## `GitHub` | Key | Default | Purpose | | --- | --- | --- | | `AccessToken` | unset | Secret; use `GITHUB_TOKEN` | | `EnvAccessToken` | unset | Binds `GitHub__GITHUB_TOKEN`; also falls back to process env `GITHUB_TOKEN` | | `ProductHeader` | `Purview.Build.Pipeline` | GitHub API product header | ## `Release` | Key | Default | Purpose | | --- | --- | --- | | `Mode` | `None` | `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease` | | `UploadArtifacts` | `false` | Upload every file in `Build:ArtifactsFolder` as GitHub release assets | | `MarkPrerelease` | `true` | Create the GitHub release as a prerelease when the package version is a prerelease (for example `2.0.0-prerelease.25`). Affects GitHub release metadata only — NuGet publication is unaffected | ## Example ```json { "Build": { "Solution": "src/MyProduct.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { "RequireSymbolPackage": true, "RequireSourceLink": true, "RequireDeterministic": true, "RequiredCompilerFlags": ["optimization=release"], "RequiredContent": { "my.product": ["lib/netstandard2.0/My.Product.dll"] } }, "Release": { "Mode": "None" } } ``` ## See also - [Pipeline Modules](../pipeline-modules/) - [Secrets and Environment Variables](../secrets-and-environment-variables/) --- # Pipeline Modules The pipeline is a Modular Pipelines orchestration. Modules are registered in `BuildPipeline.cs` (`Program.cs` handles the CLI: informational options, the version banner, and failure reporting); explicit `[DependsOn]` edges define ordering, while `ModuleConfiguration` skip conditions gate opt-in behavior. Module categories are `Build` and `Release`. Each module logs a line when it starts (`Running BuildModule...`) before its command output, so long runs report progress in CI logs where the live progress display is disabled. ```text CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → ValidatePack → Publish → GitHub release ``` ## CleanArtifactsModule Deletes `Build:ArtifactsFolder` (when it exists) and recreates it empty, before any other module runs. The folder is shared output: pack writes it, validation inspects every package in it, publishing moves packages out of it, and the release step can upload its contents — so a leftover package from an earlier (or differently configured) run would otherwise be validated, published, or uploaded as if it belonged to this run. `VersionModule` and `RestoreModule` depend on this module, so the reset completes before any other module starts. Skip conditions: skipped when `Build:CleanArtifacts` is false, or when `Build:RunPack` is false (nothing will be packed, so existing artifacts — for example a folder being inspected ahead of a manual publish — are left untouched). ## VersionModule Reads the SemVer `version` field from the repository root `package.json` and produces a `NuGetVersion`. Fails when the file is missing, the field is missing/empty, or the value is not valid SemVer. The version feeds `PackModule` (via `Version`/`PackageVersion`) and `CreateGitHubReleaseModule` (via the `v{version}` tag). ## RestoreModule Runs `Build:WebInstallCommand` (default `bun install`) when `Build:ProjectType` is `Web`, otherwise `dotnet restore` against `Build:Solution`. ## BuildModule Depends on `RestoreModule`. - **DotNet**: runs `dotnet build` against `Build:Solution` with `Build:Configuration` and `--no-restore`. - **Web**: when `Build:WebBuildCommand` is left at the default (`bun run build`) and the repository declares a `data:sync` script, first runs `bun run data:sync` (so a fresh checkout can build offline), then runs the build command. Overriding `WebBuildCommand` takes full control of the build (for example a chain of validation scripts); the automatic data sync is then skipped. ## LintModule Depends on `RestoreModule` (Web lint invokes tooling installed by the restore step, so it must wait for `bun install`; dotnet lint is unaffected beyond running after restore). Skip condition: skipped when `Build:RunLint` is false. - **DotNet**: restores the repository's local tools (`dotnet tool restore` against `.config/dotnet-tools.json`, retried up to 3 times with a 2-second backoff on failure) and then runs `dotnet tool run csharpier check `. The repository root is resolved by walking up to the nearest `package.json`. - **Web**: runs `Build:WebFormatCheckCommand` (default `bun run format:check`) then `Build:WebLintCommand` (default `bun run lint`). Each step is skipped when the corresponding script is not declared in the root `package.json`. ## RunTestsModule Depends on `BuildModule`. Skip condition: skipped when `Build:RunTests` is false. - **DotNet**: discovers test projects by enumerating `*.csproj` recursively under `Build:TestRoot`, matching file names against `Build:TestPatterns` (comma-separated glob/name patterns, case-insensitive), then restricting the run list with `Build:TestProjects` (default `*`). If no projects match, it logs a warning and returns an empty result. Runs each test project with `dotnet test --no-build --no-restore` in parallel, using `Build:Configuration`: - **TUnit** (default): passes `--ignore-exit-code 8` (Microsoft.Testing.Platform exits with code 8 when no tests are selected; treated as success) and, when `Build:TestFilter` is non-empty, `--treenode-filter `. - **xUnit**: when `Build:TestFilter` is non-empty, passes `--filter `. - **Web**: runs `Build:WebTestCommand` (default `bun run test`) from the repository root. Per-project timings are logged, ordered by elapsed time. ## PackModule Depends on `RunTestsModule` and `VersionModule`. Skip condition: skipped when `Build:RunPack` is false. `CleanArtifactsModule` resets `Build:ArtifactsFolder` before the run produces anything, so the folder only contains packages from the current run. - **DotNet**: creates `Build:ArtifactsFolder` and runs `dotnet pack` against `Build:Solution` with `Build:Configuration`, `--output `, and `-p:PackageVersion= -p:Version=` where the version comes from `VersionModule`. - **Web**: creates `Build:ArtifactsFolder` and zips `Build:WebBuildOutput` (default `src/dist`) into `-.zip` (name from the root `package.json` `name` field, version from `VersionModule`). Logs a warning and produces no artifact when the build output directory does not exist. ## ValidatePackModule Depends on `PackModule`. Skip condition: skipped when `Build:ValidatePack` is false **or** `Build:ProjectType` is `Web` (Web projects produce no `.nupkg`). Inspects every `.nupkg`/`.snupkg` in `Build:ArtifactsFolder`. Because `CleanArtifactsModule` cleared the folder at the start of the run, the packages inspected are exactly those the current run packed. Fails the run if any package has errors. Produces a summary of valid/invalid package counts. See [Pack Validation](../pack-validation/) for the full rule set. ## PublishNuGetModule Category `Release`. Depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** `Release:Mode` is not `NuGet` **or** (`NuGet:TrustedPublishing` is false and no API key resolves via `NuGet:GetNuGetAPIKey()`). Pushes every `*.nupkg` in `Build:ArtifactsFolder` to `NuGet:FeedUrl` with `--skip-duplicate`. When `NuGet:TrustedPublishing` is true, pushes without an API key (NuGet Trusted Publishing / OIDC federation). ## PublishLocalNuGetModule Depends on `PackModule` and `ValidatePackModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** the tool is not running **locally** (`ctx.IsRunningLocally()`) **or** `Release:Mode` is not `LocalNuGet`. This mode is intentionally ignored in CI. Validates `PublishLocalNuGet:LocalFeedPath` (resolved via `GetLocalFeedPath()`, falling back to `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` and then process env `LOCAL_NUGET_FEED_PATH`). The path must be absolute; drive-relative paths such as `p:foo` (backslashes stripped by a sh-style shell) are rejected with a remediation message. See [Local Development](../local-development/). Moves the `.nupkg`/`.snupkg` files from `Build:ArtifactsFolder` into the local feed (skipping existing files unless `OverwriteExistingPackages` is true), optionally clears the NuGet global-packages and HTTP caches for the published packages (`ClearPackageCache`), and optionally shuts down the dotnet build server (`ShutdownDotnetBuilderServer`). ## CreateGitHubReleaseModule Category `Release`. Depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. Skip condition: skipped unless `Release:Mode` is `NuGet` or `GitHubRelease` **and** a GitHub token resolves via `GitHub:GetGitHubToken()`. Creates a GitHub release with tag `v{version}` and `GenerateReleaseNotes = true`. Releases whose version is a prerelease (for example `2.0.0-prerelease.25`) are created as GitHub prereleases unless `Release:MarkPrerelease` is false, so prerelease builds are not presented as the latest stable release. This changes GitHub release metadata only: prerelease versions are still published to the NuGet feed — `PublishNuGetModule` is unaffected. When `Release:UploadArtifacts` is true, uploads every file in `Build:ArtifactsFolder` as a release asset — for Web projects this is the `-.zip` produced by `PackModule`. The tag must not already exist; callers gate release eligibility (the tool does not skip an existing tag itself). ## See also - [Architecture](../architecture/) - [Configuration Reference](../configuration-reference/) - [Pack Validation](../pack-validation/) - [Release Flow](../release-flow/) --- # Pack Validation `ValidatePackModule` inspects every `.nupkg`/`.snupkg` produced in `Build:ArtifactsFolder` and fails the pipeline when any package has validation errors. Each package is reported as valid/invalid in the summary. `CleanArtifactsModule` resets `Build:ArtifactsFolder` before the run produces anything (see [Pipeline Modules](../pipeline-modules/)), so validation only ever inspects the packages the current run packed — a leftover package from an earlier or differently configured build cannot fail (or pass) validation. Set `Build:CleanArtifacts=false` to keep existing artifacts. ## Symbol package pairing (`RequireSymbolPackage`) Every `.nupkg` must have a matching `.snupkg` (same id/version) and vice versa. A package without its symbol sibling is an error. ## Symbol package contents (`RequireSymbolFiles`) Every `.snupkg` must contain at least one `.pdb`. Symbol packages must not contain non-symbol files other than OPC metadata (`[Content_Types].xml`, `_rels/`, `package/services/metadata/`) and the `.nuspec`. ## PDB placement in `.nupkg` The `.nupkg` must not contain `.pdb` files outside `tools/` — PDBs are delivered through the `.snupkg`. The exception is tool packages (`PackAsTool`), whose runtime PDBs legitimately live under `tools/`; the `Purview.Build` csproj strips those PDBs from the `.nupkg` after `GenerateNuspec` so the `.snupkg` keeps them for source-link validation. ## Content rules (`RequiredContent` / `ForbiddenContent`) Both maps are keyed by package-id glob (case-insensitive; `"*"` matches every package) and contain entry-path glob lists (forward slashes, e.g. `tools/**/Purview.Build.dll` or `**/*.pdb`). - **Required**: the rule is satisfied when any package entry matches the glob; a missing match is an error. - **Forbidden**: any matching entry is an error. ### Target-framework partials (`$(TFM)`) An entry may contain the literal token `$(TFM)`, e.g. `lib/$(TFM)/Purview.Telemetry.dll`. The token is expanded into one entry per target framework the package actually ships (short folder name, e.g. `net8.0`, `net48`, `netstandard2.0`, discovered via the package's own `lib`/`ref`/`build`/`tools`/`frameworkAssemblies` groups), so the rule must be satisfied independently for every one of the package's target frameworks. If the package has no detectable target frameworks, a `$(TFM)` entry is reported as an error rather than silently skipped. ### Explicit/exhaustive content (`RequireExplicitContent`) When `RequireExplicitContent` is `true`, `RequiredContent` becomes the precise, exhaustive definition of every package's contents instead of a "must contain at least" list: - Every produced `.nupkg`'s package id must match a `RequiredContent` key; a generated package with no matching rule is an error. - Every entry in a matched package (after `$(TFM)` expansion) — excluding standard NuGet/OPC metadata (`.nuspec`, `[Content_Types].xml`, `_rels/`, `package/services/metadata/`, `.signature.p7s`) — must match one of that package's `RequiredContent` globs; any undeclared entry is reported as an error. `ForbiddenContent` is unaffected by `RequireExplicitContent` and continues to apply as a simple deny-list. ## Assembly inspection When any of `RequireSourceLink`, `RequireDeterministic`, or `RequiredCompilerFlags` is enabled, each `.dll`/`.exe` in the `.nupkg` that the package ships symbols for (a sibling PDB exists in the `.snupkg` or `.nupkg`) is inspected: - **Deterministic (`RequireDeterministic`)**: the PE must carry the Reproducible debug directory entry (`PEReader.ReadDebugDirectory` with type `Reproducible`). Bundled third-party binaries without symbols are not judged. - **Source link (`RequireSourceLink`)**: the matching portable PDB must contain a Source Link record (custom debug info GUID `CC110556-A091-4D38-9FEC-25AB9A351A6A`). - **Compiler flags (`RequiredCompilerFlags`)**: the PDB's compiler-flags record (custom debug info GUID `B5FEEC05-8CD0-4A83-96DA-466284BB4BD8`, stored as NUL-separated `key=value` pairs) must contain each required flag, matched case-insensitively (e.g. `optimization=release`). Unreadable packages and invalid PE/PDB files are reported as errors. ## Filename checks Package file names must match the nuspec id/version, i.e. `..nupkg` / `..snupkg`. ## See also - [Pipeline Modules](../pipeline-modules/) - [Configuration Reference](../configuration-reference/) --- # Versioning and Release Flow `Purview.Build` follows SemVer. The package version is the compatibility contract for configuration keys, defaults, module ordering, and tool behavior. - Patch: fixes that preserve configuration and pipeline behavior. - Minor: additive options or modules with backward-compatible defaults. - Major: renamed/removed keys, changed defaults with material effects, or a required runtime upgrade. The reusable workflows and composite action are referenced with an `@ref` suffix (required for cross-repository references), which selects the workflow/action *code*: `@main` always runs the latest code, while a release tag (e.g. `@v0.2.0`) pins it for reproducibility. `build-version` is an independent axis that selects the installed `Purview.Build` *tool*; it defaults to the latest stable release from nuget.org, and consumers that need reproducibility pin an exact version via the `build-version` input (and, for local use, `.config/dotnet-tools.json`). Automated dependency updates should open a pull request, where the consumer's normal build validates the new tool before merge. Keep the previous major supported while migrations are in progress. The version is declared by the `version` field in the repository's root `package.json`. Releasing consists of bumping that field and merging the validated pull request into the release head. ## Branch models Each repository is gated by a pull-request build. Two release trigger models are supported; the consuming repository's tiny caller workflow chooses: - **Release on `main`**: the release caller triggers on `push: branches: [main]`. - **Main-as-head / release branch**: development merges to `main`, and merging `main` into a `release` branch performs the release. The release caller triggers on `push: branches: [release]`. In both models the reusable `purview-release.yml` workflow reads `package.json`'s `version`, skips when the `v{version}` tag already exists, and otherwise runs the pipeline with `Release__Mode` set. Because publication is idempotent (`--skip-duplicate`) and the tag is created by the workflow, re-merging `main` into `release` after a failed release is safe. The reusable workflow does **not** define a concurrency group. GitHub Actions cancels a run as a deadlock when a caller workflow and the reusable workflow it calls share the same concurrency group (the caller's `purview-release-main` collided with the reusable workflow's `purview-release-${{ inputs.release-branch }}` resolving to the same value, producing *"Canceling since a deadlock was detected for concurrency group"*). Callers must own release serialization by defining their own `concurrency` block: ```yaml concurrency: group: release-${{ github.ref }} cancel-in-progress: false ``` The `release-branch` input is retained for backward compatibility only. ## This repository's CI/CD This repository dogfoods the shared tool. CI performs restore, warnings-as-errors compilation, packing, installation from the generated package, then runs `purview-build` against this repository so the project builds and packs itself. See [Repository CI/CD](../repository-ci-cd/). On a push to `main`, the release workflow reads and validates the `package.json` version, skips when `v{version}` already exists, then builds and installs the tool from the current source and runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` set to nuget.org, and `Release__UploadArtifacts=true`. The tool performs the release build/pack steps, publishes the immutable package to `https://api.nuget.org/v3/index.json` using the `NUGET_APIKEY` secret, and creates `v{version}` plus a generated-notes GitHub release with the package attached — tagging itself exactly like every other purview-dev repository. The tool therefore owns tagging; maintainers must not push release tags manually. ## GitHub package visibility GitHub creates NuGet packages as private on first publication. To make sure every package is **Internal** (visible to all Purview-Dev members), set both: 1. **Organization default (prevents future private packages)** — org owner: GitHub → purview-dev → Settings → Packages → **Package Creation** → select **Internal**. New NuGet packages published by organization members then default to Internal. 2. **Existing packages already published while private** — org owner, per package: `https://github.com/orgs/purview-dev/packages/nuget/package/` → **Package settings** → **Danger Zone** → **Change visibility** → **Internal**. Or via the CLI/API for every package on the registry: ```shell gh api --method PATCH "/orgs/purview-dev/packages/nuget/Purview.Build" -f visibility=internal ``` Public packages cannot be made private again; private → internal is safe. NuGet versions are immutable; `--skip-duplicate` makes recovery safe if publication succeeded but tagging was interrupted. ## For local validation ```shell dotnet pack src/src/Build/Build.csproj -c Release -o artifacts -p:Version=0.2.4 -p:PackageVersion=0.2.4 dotnet tool install Purview.Build --tool-path ./.tools --add-source ./artifacts ./.tools/purview-build ``` To publish packages built by a consumer to a local feed for development: ```shell LOCAL_NUGET_FEED_PATH=p:/_sync-projects/.local-nuget/ ./.tools/purview-build --Release:Mode=LocalNuGet ``` ## See also - [Getting Started](../) - [Repository CI/CD](../repository-ci-cd/) --- # Local Development Local work uses the `just` recipes in the `Justfile` (which delegate to plain `dotnet`/`bun` commands) or the shared pipeline tool directly. ## Tool installation ```shell dotnet tool install Purview.Build --tool-path ./.tools ./.tools/purview-build ``` Omit `--version` to install the latest stable release. For a pinned local tool manifest, add it to `.config/dotnet-tools.json` and run `dotnet tool restore`. ## `just` recipes | Recipe | Purpose | | --- | --- | | `just build` | Build `src/Build.slnx` (Debug) | | `just test` / `just test-unit` | Run tests with a TUnit tree-node filter (unit filter: `/*/*/*/*[Category=Unit]`) | | `just lint-check` / `just lint-fix` | CSharpier check / format | | `just pack` | `dotnet pack` with the current `package.json` version | | `just pipeline-pr` | Run the shared tool (restore, build, lint, tests) | | `just pipeline-build` | Run the shared tool without tests | | `just pipeline-release` | Run the shared tool with `Release:Mode=NuGet` | | `just pipeline-tests` | Run the shared tool with tests enabled | | `just pipeline-local-release` | Lint-fix, then run the shared tool with `Release:Mode=LocalNuGet` | | `just clean-all` / `just scrub` | Clean build outputs | `just pipeline-*` installs the `Purview.Build` tool to `.tools/purview-build` from nuget.org when it is not already present. ## Local NuGet publishing `Release:Mode=LocalNuGet` pushes packages to a local feed and is only honoured when the tool runs **locally** — it is ignored in CI. ```shell LOCAL_NUGET_FEED_PATH=C:/local/nuget-feed/ ./.tools/purview-build --Release:Mode=LocalNuGet ``` The local feed path must be an absolute path. `just` runs recipes through the shell, which strips backslashes from unquoted arguments, so a backslash-based Windows path is mangled before the tool sees it and is rejected. Use the `LOCAL_NUGET_FEED_PATH` environment variable, or forward slashes: ```shell just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=C:/local/nuget-feed/ ``` By default the module overwrites existing packages, clears the NuGet global-packages and HTTP caches for the published packages, and shuts down the dotnet build server afterwards (all configurable under `PublishLocalNuGet`). ## Repository root resolution The tool locates the repository root by walking up from the current working directory to the nearest `package.json`. Run `purview-build` from within the repository. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json`. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) --- # Secrets and Environment Variables Secrets must never be committed. They are supplied at runtime via environment variables / CI secrets and read by the pipeline through the settings' lookup helpers. ## Precedence recap Command line > environment variables > `purview-build.json` > baked-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. Because env vars take precedence over `purview-build.json`, an empty forwarded env var can silently override a configured value — the reusable workflows only forward optional test settings when the caller actually provides them. ## Secrets | Secret | Where it is used | Environment-var bound alias | | --- | --- | --- | | `NUGET_APIKEY` | NuGet push | `NuGet__NUGET_APIKEY` (binds `EnvAPIKey`); also read directly from process env `NUGET_APIKEY`/`NUGET_API_KEY` | | `NuGet__ApiKey` | NuGet push | `APIKey` | | `GITHUB_TOKEN` | GitHub release creation | `GitHub__GITHUB_TOKEN` (binds `EnvAccessToken`); also read directly from process env `GITHUB_TOKEN` | | `LOCAL_NUGET_FEED_PATH` | Local NuGet publishing | `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` (binds `EnvLocalFeedPath`); also read directly from process env `LOCAL_NUGET_FEED_PATH` | The config binder does not map plain `NUGET_APIKEY`/`GITHUB_TOKEN`/`LOCAL_NUGET_FEED_PATH` process env vars under their settings sections, so the settings classes fall back to reading the process environment directly. ## Test filter forwarding The reusable workflows (`purview-build.yml`, `purview-release.yml`) forward the caller's `test-filter` and `test-projects` inputs as `Build__TestFilter`/`Build__TestProjects` **only when they are non-empty**. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently disable the filter — see commit `4d72bf7`. ## Diagnostics | Variable | Purpose | | --- | --- | | `PURVIEW_BUILD_STACKTRACE` | Set to `1` (or `true`) to include stack traces in failure reports. Unset, a failing run prints only the failing module and that module's output, then exits with code 1. | Pipeline verbosity is configured with `Build__LogLevel` (default `Information`, which reports each module's command output and progress); set `Build__LogLevel=Warning` for quiet CI logs. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) --- # Repository CI/CD This repository dogfoods the shared `Purview.Build` tool: it builds and packs the tool from source, installs the generated package, then runs `purview-build` against itself so the project builds and packs itself. ## CI (`ci.yml`) Runs on pull requests and pushes to `main`: 1. Check out the repository. 2. Restore `src/Build.slnx`. 3. **Build gate**: `dotnet build src/Build.slnx --configuration Release --no-restore --warnaserror`. 4. Read and SemVer-validate the `package.json` version. 5. Pack the tool from source (`dotnet pack src/Build.slnx --configuration Release --no-build --output artifacts -p:Version=… -p:PackageVersion=…`). 6. Install the packed tool from the `artifacts` source into a temp tool path. 7. **Dogfood**: run the freshly installed `purview-build` against this repository (with `GITHUB_TOKEN`). The tool restores, builds, lints, runs tests, packs, and validates itself. ## Release (`release.yml`) Runs on push to `main` and is serialized by its own `concurrency` group (`purview-build-release`, `cancel-in-progress: false`): 1. Read and SemVer-validate the `package.json` version; skip the whole job when `v{version}` is already tagged (the tag check makes re-merges safe). 2. Restore and build `src/Build.slnx` with `--warnaserror`. 3. Pack the tool from source with `-p:ContinuousIntegrationBuild=true`. 4. Verify the `NUGET__APIKEY` secret is set. 5. Install the packed tool. 6. **Run the release pipeline** with `Release__Mode=NuGet`, `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, and `Build__ValidatePack=true`, passing `GITHUB_TOKEN` and `NUGET_APIKEY`. The tool therefore publishes the immutable package to nuget.org and tags and releases itself (`v{version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. ## GitHub package visibility GitHub initially creates NuGet packages as private. An organization owner should set the org default to **Internal** (Purview-Dev → Settings → Packages → **Package Creation** → **Internal**) and change any already-published package's visibility in its **Package settings** → **Danger Zone**. See [Release Flow](../release-flow/) for the exact steps and the `gh api` alternative. ## See also - [Release Flow](../release-flow/) - [Getting Started](../) - [Architecture](../architecture/) --- # Migration: aspire-resourcekit This repository currently contains a vendored copy of `build/PipelineCLI`. Migrate it as follows. 1. Replace the vendored `build/PipelineCLI` with the shared `Purview.Build` tool pinned to the chosen released version. 2. Add this `purview-build.json`: ```json { "Build": { "Solution": "src/ResourceKit.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]" }, "Release": { "Mode": "None" } } ``` 3. Replace `pr.yml` and `release.yml` with thin callers of `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml`, passing `build-version`. Keep the `[Category=Unit]` filter in the release caller. 4. In the release caller set `release-mode: NuGet` and `secrets: inherit` (`NUGET_APIKEY` and `GITHUB_TOKEN` are read by the shared workflow). 5. Run the PR pipeline, then delete `build/PipelineCLI` and its pipeline-only central package declarations (`ModularPipelines*`, `NuGet.Packaging/Versioning`). The old solution path and unit-test filter are preserved exactly. Other repositories migrate by changing only the JSON paths/patterns; for example `dotnet-project-sdk` can list unit and integration project globs in `Build:TestPatterns`.