Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2694,3 +2694,8 @@ generated_code = true
[**/bin/**/*.cs]
# Compiler/build output (generated AssemblyInfo, GlobalUsings, etc.)
generated_code = true

# The CLI entry point is a process boundary: it turns any failure into a concise message and a non-zero exit
# code rather than letting .NET report an unhandled exception and stack trace.
[src/src/Build/Program.cs]
dotnet_diagnostic.CA1031.severity = none
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ dotnet tool install Purview.Build --tool-path ./.tools

Omit `--version` to install the latest stable release.

### Command line

The tool behaves like any other CLI build tool:

```shell
purview-build --version # print the tool version and exit
purview-build -v # same
purview-build --help # usage, options, and configuration keys
```

Every run prints the tool version first, then a line for each module as it starts (`Running BuildModule...`) followed by
its completion and duration. A failing run reports the failed module and that module's output, then exits with code 1
instead of dumping a .NET stack trace; set `PURVIEW_BUILD_STACKTRACE=1` when you need the stack trace for diagnosing the
tool itself. Verbosity is controlled by `Build:LogLevel` (default `Information`, which includes each module's command
output and progress).

## Configuration

Add `purview-build.json` at the repository root. Everything is optional; defaults are baked into the tool. Configuration precedence is command line, environment variables, `purview-build.json`, then defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`.
Expand Down Expand Up @@ -130,13 +146,10 @@ See the [Documentation](#documentation) section below for the architecture, conf
## Pipeline

```text
Version ───────────────┐
Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release
└→ Lint │
Version ───────────────┘
CleanArtifacts → { Version, Restore → Build → Test, Restore → Lint } → Pack → Validate → Publish → GitHub release
```

`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow).
`CleanArtifacts` deletes and recreates `Build:ArtifactsFolder` before anything else runs, so pack, validation, publishing, and release uploads only ever see the packages from the current run (set `Build:CleanArtifacts=false` to keep existing artifacts). `Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow).

## Repository CI/CD

Expand Down
10 changes: 5 additions & 5 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ The package owns module implementation, dependency ordering, safe defaults, secr

## Module ordering

The pipeline is registered in `Program.cs` in this order, with explicit `[DependsOn]` edges defining the graph:
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
VersionModule ──────────────┐
RestoreModule → BuildModule ├→ RunTestsModule → PackModule → ValidatePackModule
RestoreModule → LintModule │
VersionModule ──────────────┘
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`.
Expand Down
13 changes: 12 additions & 1 deletion docs/wiki/Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,26 @@ Command line > environment variables > `purview-build.json` > baked-in defaults
- 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.md).

### 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` | `Warning` | `Trace`/`Debug`/`Information`/`Warning`/`Error`/`Critical`/`None`; used by the pipeline logger |
| `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` |
Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/Pack-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

`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.md)), 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.
Expand Down
19 changes: 13 additions & 6 deletions docs/wiki/Pipeline-Modules.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# Pipeline Modules

The pipeline is a Modular Pipelines orchestration. Modules are registered in `Program.cs`; explicit `[DependsOn]` edges define ordering, while `ModuleConfiguration` skip conditions gate opt-in behavior. Module categories are `Build` and `Release`.
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
Version ───────────────┐
Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release
└→ Lint │
Version ───────────────┘
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).
Expand Down Expand Up @@ -46,14 +51,16 @@ Per-project timings are logged, ordered by elapsed time.

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 <ArtifactsFolder>`, and `-p:PackageVersion=<version> -p:Version=<version>` where the version comes from `VersionModule`.
- **Web**: creates `Build:ArtifactsFolder` and zips `Build:WebBuildOutput` (default `src/dist`) into `<package-name>-<version>.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`. Fails the run if any package has errors. Produces a summary of valid/invalid package counts. See [Pack Validation](Pack-Validation.md) for the full rule set.
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.md) for the full rule set.

## PublishNuGetModule

Expand Down
8 changes: 8 additions & 0 deletions docs/wiki/Secrets-and-Environment-Variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ The config binder does not map plain `NUGET_APIKEY`/`GITHUB_TOKEN`/`LOCAL_NUGET_

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.md)
Expand Down
6 changes: 6 additions & 0 deletions docs/wiki/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Purview Build

Shared build, validation, packaging, and release automation for Purview repositories.

[Documentation overview](Home.md){ .md-button .md-button--primary }
[Get started](Getting-Started.md){ .md-button }
21 changes: 21 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
site_name: Purview Build
site_description: Developer documentation for Purview Build
repo_url: https://github.com/purview-dev/build
edit_uri: edit/main/docs/wiki/
docs_dir: docs/wiki

theme:
name: material
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: cyan
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: cyan
features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy]

plugins: [search, techdocs-core]
markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-build",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"homepage": "https://purview.dev/projects/build/",
"bugs": {
Expand Down
110 changes: 110 additions & 0 deletions src/src/Build/Helpers/BuildPipeline.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using ModularPipelines.Models;

namespace Purview.Build.Helpers;

/// <summary>
/// Builds and runs the tool's pipeline: configuration binding, shared services, module registration, and
/// failure collection.
/// </summary>
/// <remarks>
/// Module failures are reported by the caller (CLI style) rather than thrown, which is why the pipeline is
/// configured with <c>PipelineOptions.ThrowOnPipelineFailure</c> disabled.
/// </remarks>
static class BuildPipeline
{
/// <summary>
/// Runs the pipeline and returns the failed module results (empty when every module succeeded or skipped).
/// </summary>
public static async Task<IReadOnlyList<IModuleResult>> RunAsync(string[] args)
{
var pipelineDirectory = PipelineProjectDirectory.Find();
var repositoryRoot = PathHelpers.FindRepositoryRoot(Environment.CurrentDirectory);

var builder = Pipeline.CreateBuilder(args);

AddConfiguration(builder, args, pipelineDirectory, repositoryRoot);
ApplyLogLevel(builder);
BindSettings(builder);
AddGitHubClient(builder);
AddModules(builder);

builder.ConfigurePipelineOptions(options => options.ThrowOnPipelineFailure = false);

// Modules resolve every configured path relative to the repository root.
Environment.CurrentDirectory = repositoryRoot;

await using var pipeline = await builder.BuildAsync();

var summary = await pipeline.RunAsync();

return summary.GetFailedModuleResults();
}

static void AddConfiguration(
PipelineBuilder builder,
string[] args,
string pipelineDirectory,
string repositoryRoot
) =>
builder
.Configuration.AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false)
.AddJsonFile(Path.Combine(repositoryRoot, "purview-build.json"), optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args);

/// <summary>
/// Applies <c>Build:LogLevel</c> to the pipeline logger, defaulting to <see cref="LogLevel.Information"/> so
/// a run reports each module's command output and progress (set it to <c>Warning</c> for quiet output).
/// </summary>
static void ApplyLogLevel(PipelineBuilder builder)
{
var configured = builder.Configuration[$"{BuildSettings.SectionName}:{nameof(BuildSettings.LogLevel)}"];

builder.SetLogLevel(
Enum.TryParse<LogLevel>(configured, ignoreCase: true, out var logLevel)
? logLevel
: LogLevel.Information
);
}

static void BindSettings(PipelineBuilder builder)
{
builder.Services.Configure<BuildSettings>(builder.Configuration.GetSection(BuildSettings.SectionName));
builder.Services.Configure<NuGetSettings>(builder.Configuration.GetSection(NuGetSettings.SectionName));
builder.Services.Configure<PackValidationSettings>(
builder.Configuration.GetSection(PackValidationSettings.SectionName)
);
builder.Services.Configure<PublishLocalNuGetSettings>(
builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName)
);
builder.Services.Configure<GitHubSettings>(builder.Configuration.GetSection(GitHubSettings.SectionName));
builder.Services.Configure<ReleaseSettings>(
builder.Configuration.GetSection(ReleaseSettings.SectionName)
);
}

static void AddGitHubClient(PipelineBuilder builder) =>
builder.Services.AddSingleton<IGitHubClient>(serviceProvider =>
{
var settings = serviceProvider.GetRequiredService<IOptions<GitHubSettings>>();

return new GitHubClient(
new(settings.Value.ProductHeader),
new InMemoryCredentialStore(new(settings.Value.GetGitHubToken()))
);
});

static void AddModules(PipelineBuilder builder) =>
builder
.AddModule<CleanArtifactsModule>()
.AddModule<VersionModule>()
.AddModule<RestoreModule>()
.AddModule<BuildModule>()
.AddModule<LintModule>()
.AddModule<RunTestsModule>()
.AddModule<PackModule>()
.AddModule<ValidatePackModule>()
.AddModule<PublishNuGetModule>()
.AddModule<PublishLocalNuGetModule>()
.AddModule<CreateGitHubReleaseModule>();
}
18 changes: 18 additions & 0 deletions src/src/Build/Helpers/CLIConsole.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Spectre.Console;

namespace Purview.Build.Helpers;

/// <summary>
/// Writes the tool's own CLI output.
/// </summary>
/// <remarks>
/// The pipeline's analyzers forbid direct <see cref="Console"/> use (module and step output belongs to the
/// pipeline logger), so CLI-boundary output - the version banner, <c>--help</c>, and failure reports - goes
/// through the same console abstraction the pipeline itself renders with.
/// </remarks>
static class CLIConsole
{
public static void WriteLine(string text) => AnsiConsole.WriteLine(text);

public static void WriteLine() => AnsiConsole.WriteLine();
}
Loading
Loading