diff --git a/.editorconfig b/.editorconfig index fd9e596..2464924 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/README.md b/README.md index f1ed099..7357eb9 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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 diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index eba890d..915d1cb 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -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`. diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index b30c3b9..2f6c3ee 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -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` | diff --git a/docs/wiki/Pack-Validation.md b/docs/wiki/Pack-Validation.md index 8cd4227..5c73257 100644 --- a/docs/wiki/Pack-Validation.md +++ b/docs/wiki/Pack-Validation.md @@ -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. diff --git a/docs/wiki/Pipeline-Modules.md b/docs/wiki/Pipeline-Modules.md index 2efb93e..0d35531 100644 --- a/docs/wiki/Pipeline-Modules.md +++ b/docs/wiki/Pipeline-Modules.md @@ -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). @@ -46,6 +51,8 @@ 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 `, 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. @@ -53,7 +60,7 @@ Depends on `RunTestsModule` and `VersionModule`. Skip condition: skipped when `B 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 diff --git a/docs/wiki/Secrets-and-Environment-Variables.md b/docs/wiki/Secrets-and-Environment-Variables.md index d13c18e..0b54f69 100644 --- a/docs/wiki/Secrets-and-Environment-Variables.md +++ b/docs/wiki/Secrets-and-Environment-Variables.md @@ -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) diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..0be5689 --- /dev/null +++ b/docs/wiki/index.md @@ -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 } diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d6583a5 --- /dev/null +++ b/mkdocs.yml @@ -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] diff --git a/package.json b/package.json index 1974360..9298aa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build", - "version": "0.3.3", + "version": "0.3.4", "private": true, "homepage": "https://purview.dev/projects/build/", "bugs": { diff --git a/src/src/Build/Helpers/BuildPipeline.cs b/src/src/Build/Helpers/BuildPipeline.cs new file mode 100644 index 0000000..e3deab9 --- /dev/null +++ b/src/src/Build/Helpers/BuildPipeline.cs @@ -0,0 +1,110 @@ +using ModularPipelines.Models; + +namespace Purview.Build.Helpers; + +/// +/// Builds and runs the tool's pipeline: configuration binding, shared services, module registration, and +/// failure collection. +/// +/// +/// Module failures are reported by the caller (CLI style) rather than thrown, which is why the pipeline is +/// configured with PipelineOptions.ThrowOnPipelineFailure disabled. +/// +static class BuildPipeline +{ + /// + /// Runs the pipeline and returns the failed module results (empty when every module succeeded or skipped). + /// + public static async Task> 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); + + /// + /// Applies Build:LogLevel to the pipeline logger, defaulting to so + /// a run reports each module's command output and progress (set it to Warning for quiet output). + /// + static void ApplyLogLevel(PipelineBuilder builder) + { + var configured = builder.Configuration[$"{BuildSettings.SectionName}:{nameof(BuildSettings.LogLevel)}"]; + + builder.SetLogLevel( + Enum.TryParse(configured, ignoreCase: true, out var logLevel) + ? logLevel + : LogLevel.Information + ); + } + + static void BindSettings(PipelineBuilder builder) + { + builder.Services.Configure(builder.Configuration.GetSection(BuildSettings.SectionName)); + builder.Services.Configure(builder.Configuration.GetSection(NuGetSettings.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(PackValidationSettings.SectionName) + ); + builder.Services.Configure( + builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName) + ); + builder.Services.Configure(builder.Configuration.GetSection(GitHubSettings.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(ReleaseSettings.SectionName) + ); + } + + static void AddGitHubClient(PipelineBuilder builder) => + builder.Services.AddSingleton(serviceProvider => + { + var settings = serviceProvider.GetRequiredService>(); + + return new GitHubClient( + new(settings.Value.ProductHeader), + new InMemoryCredentialStore(new(settings.Value.GetGitHubToken())) + ); + }); + + static void AddModules(PipelineBuilder builder) => + builder + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule(); +} \ No newline at end of file diff --git a/src/src/Build/Helpers/CLIConsole.cs b/src/src/Build/Helpers/CLIConsole.cs new file mode 100644 index 0000000..1bed2bc --- /dev/null +++ b/src/src/Build/Helpers/CLIConsole.cs @@ -0,0 +1,18 @@ +using Spectre.Console; + +namespace Purview.Build.Helpers; + +/// +/// Writes the tool's own CLI output. +/// +/// +/// The pipeline's analyzers forbid direct use (module and step output belongs to the +/// pipeline logger), so CLI-boundary output - the version banner, --help, and failure reports - goes +/// through the same console abstraction the pipeline itself renders with. +/// +static class CLIConsole +{ + public static void WriteLine(string text) => AnsiConsole.WriteLine(text); + + public static void WriteLine() => AnsiConsole.WriteLine(); +} \ No newline at end of file diff --git a/src/src/Build/Helpers/FailureReport.cs b/src/src/Build/Helpers/FailureReport.cs new file mode 100644 index 0000000..533e09e --- /dev/null +++ b/src/src/Build/Helpers/FailureReport.cs @@ -0,0 +1,111 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Purview.Build.Helpers; + +/// +/// Renders failures the way a CLI build tool reports them: the actual error, never a .NET stack trace. +/// +static partial class FailureReport +{ + /// + /// Flattens an exception into its message chain (outermost first), skipping blank and repeated messages. + /// + public static IReadOnlyList Describe(Exception exception) + { + List messages = []; + + for (var current = (Exception?)exception; current is not null; current = current.InnerException) + { + if ( + !string.IsNullOrWhiteSpace(current.Message) + && !messages.Contains(current.Message, StringComparer.Ordinal) + ) + messages.Add(current.Message); + } + + return messages; + } + + /// + /// Formats a failure: a heading, the details indented beneath it, and - only when + /// PURVIEW_BUILD_STACKTRACE asks for them - stack traces. + /// + public static string Format(string summary, Exception exception) + { + var messages = Describe(exception); + + return TryDescribeModuleFailure(messages, out var moduleName, out var detailLines) + ? Render($"{summary}: {moduleName}", detailLines, exception, inlineFirstLine: false) + : Render(summary, SplitLines(messages), exception, inlineFirstLine: true); + } + + /// + /// Recognizes the pipeline's module-failure wrapper ("The module {Module} has failed." plus the failing + /// command's output) and reports the module by name with its output beneath it. + /// + static bool TryDescribeModuleFailure( + IReadOnlyList messages, + out string moduleName, + out IReadOnlyList detailLines + ) + { + moduleName = string.Empty; + detailLines = []; + + if (messages.Count == 0) + return false; + + var match = ModuleFailurePattern().Match(messages[0]); + + if (!match.Success) + return false; + + moduleName = match.Groups["module"].Value; + detailLines = SplitLines([messages[0][match.Length..], .. messages.Skip(1)]); + + return true; + } + + static string Render(string heading, IReadOnlyList lines, Exception exception, bool inlineFirstLine) + { + StringBuilder builder = new(heading); + + foreach (var (line, index) in lines.Select(static (line, index) => (line, index))) + { + if (inlineFirstLine && index == 0) + builder.Append(": ").Append(line); + else + builder.AppendLine().Append(" ").Append(line); + } + + if (Debugging.StackTraceRequested) + builder.AppendLine().Append(exception); + + return builder.ToString(); + } + + /// + /// Splits messages into trimmed, non-empty lines, dropping lines that a previous message already + /// contributed (nested pipeline exceptions repeat the failing command's output). + /// + static string[] SplitLines(IEnumerable messages) + { + List lines = []; + + var candidates = messages.SelectMany(static message => + message.Split('\n').Select(static line => line.TrimEnd('\r').TrimEnd()) + ); + + foreach (var line in candidates) + { + if (line.Length > 0 && !lines.Contains(line, StringComparer.Ordinal)) + lines.Add(line); + } + + return [.. lines]; + } + + [GeneratedRegex(@"^The module (?\S+) has failed\.")] + private static partial Regex ModuleFailurePattern(); +} \ No newline at end of file diff --git a/src/src/Build/Helpers/InformationalFlags.cs b/src/src/Build/Helpers/InformationalFlags.cs new file mode 100644 index 0000000..62e6d01 --- /dev/null +++ b/src/src/Build/Helpers/InformationalFlags.cs @@ -0,0 +1,34 @@ +namespace Purview.Build.Helpers; + +/// +/// Informational command-line flags that print something about the tool and exit without running a pipeline. +/// +enum InformationalFlag +{ + None, + + Version, + + Help, +} + +/// +/// Recognizes the tool's informational flags before the pipeline is created, so they never start a run. +/// +static class InformationalFlags +{ + static readonly string[] VersionFlags = ["-v", "--version"]; + + static readonly string[] HelpFlags = ["-h", "--help", "-?"]; + + public static InformationalFlag Parse(IReadOnlyList args) + { + if (Matches(args, VersionFlags)) + return InformationalFlag.Version; + + return Matches(args, HelpFlags) ? InformationalFlag.Help : InformationalFlag.None; + } + + static bool Matches(IReadOnlyList args, string[] flags) => + args.Any(arg => flags.Contains(arg, StringComparer.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/src/src/Build/Helpers/ModuleProgress.cs b/src/src/Build/Helpers/ModuleProgress.cs new file mode 100644 index 0000000..0aa97f8 --- /dev/null +++ b/src/src/Build/Helpers/ModuleProgress.cs @@ -0,0 +1,39 @@ +using ModularPipelines.Context; + +namespace Purview.Build.Helpers; + +/// +/// Emits the per-module progress lines a CLI build tool is expected to show. +/// +/// +/// The pipeline only logs a module when it completes, so a long-running step stays silent until it finishes, +/// which reads as "nothing is happening" in CI job logs. Modules log this line as they start, and the +/// framework logs the completion and duration. +/// +static class ModuleProgress +{ + public static void Starting(IModuleContext context, string moduleName) => + context.Logger.LogInformation("Running {Module}...", moduleName); +} + +/// +/// Switches for diagnosing the tool itself, read from environment variables so they can be enabled in CI +/// without changing repository configuration. +/// +static class Debugging +{ + const string StackTraceVariable = "PURVIEW_BUILD_STACKTRACE"; + + /// + /// True when PURVIEW_BUILD_STACKTRACE is set to 1 or true. + /// + public static bool StackTraceRequested { get; } = IsEnabled(StackTraceVariable); + + static bool IsEnabled(string variable) + { + var value = Environment.GetEnvironmentVariable(variable); + + return !string.IsNullOrWhiteSpace(value) + && (value.Trim() == "1" || string.Equals(value.Trim(), "true", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/src/src/Build/Helpers/PathHelpers.cs b/src/src/Build/Helpers/PathHelpers.cs index dd02e79..ca7f26c 100644 --- a/src/src/Build/Helpers/PathHelpers.cs +++ b/src/src/Build/Helpers/PathHelpers.cs @@ -20,4 +20,21 @@ public static string FindRepositoryRoot(string? startDirectory = null) "Could not locate the repository root (no package.json found). Run the tool from within the repository." ); } + + /// + /// Deletes a directory and its contents when it exists and recreates it empty. + /// + /// + /// Used to reset the artifacts folder before a pipeline run produces anything, so a later validation, + /// publish, or release upload can never observe output from an earlier run. + /// + public static void ResetDirectory(string directory) + { + var fullPath = Path.GetFullPath(directory); + + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, recursive: true); + + Directory.CreateDirectory(fullPath); + } } diff --git a/src/src/Build/Helpers/ToolInfo.cs b/src/src/Build/Helpers/ToolInfo.cs new file mode 100644 index 0000000..eac1bdb --- /dev/null +++ b/src/src/Build/Helpers/ToolInfo.cs @@ -0,0 +1,63 @@ +using System.Reflection; + +namespace Purview.Build.Helpers; + +/// +/// Identity of the build tool itself, as opposed to the repository it operates on. +/// +static class ToolInfo +{ + public const string Name = "Purview.Build"; + + /// + /// The package version of the running tool, without any build metadata (+sha) suffix. + /// + public static string Version { get; } = ResolveVersion(); + + public static string VersionLine => $"{Name} {Version}"; + + /// + /// The --help text. Treated like any other CLI build tool: usage and options first, then the + /// configuration keys the tool accepts. + /// + public static string HelpText => + $$""" + {{VersionLine}} - shared build, test, pack, and release pipeline. + + Usage: + purview-build [configuration overrides] + + Options: + -v, --version Print the {{Name}} version and exit. + -h, --help Print this help and exit. + + Configuration precedence: command line > environment variables > purview-build.json > defaults. + --Build:RunPack=false command-line override + Build__RunPack=false environment variable (nested keys use "__") + { "Build": { "RunPack": false } } purview-build.json at the repository root + + Modules run in dependency order: CleanArtifacts, Version, Restore, Build, Lint, RunTests, Pack, + ValidatePack, PublishNuGet, PublishLocalNuGet, CreateGitHubRelease. Each logs a line when it starts and + when it finishes; a failing run reports the failed module's error and exits with code 1. + + Set PURVIEW_BUILD_STACKTRACE=1 to include stack traces when an unexpected failure is reported. + Documentation: https://purview.dev/docs/build/ + """; + + static string ResolveVersion() + { + var assembly = typeof(ToolInfo).Assembly; + + var informationalVersion = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informationalVersion)) + { + var buildMetadata = informationalVersion.IndexOf('+', StringComparison.Ordinal); + return buildMetadata < 0 ? informationalVersion : informationalVersion[..buildMetadata]; + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } +} \ No newline at end of file diff --git a/src/src/Build/Modules/BuildModule.cs b/src/src/Build/Modules/BuildModule.cs index 182fe4c..083b2c4 100644 --- a/src/src/Build/Modules/BuildModule.cs +++ b/src/src/Build/Modules/BuildModule.cs @@ -16,6 +16,8 @@ public sealed class BuildModule(IOptions settings) : Module +/// Resets before any other module runs. +/// +/// +/// The artifacts folder is shared output: the pack step writes it, validation inspects every package in it, +/// publishing moves packages out of it, and the release step can upload its contents. Clearing it first keeps +/// a run deterministic: a stale package from an earlier (or differently configured) build can no longer be +/// validated, published, or uploaded. Skipped when is false or when +/// packing is disabled, so a run that only inspects existing artifacts keeps them. +/// +[ModuleCategory("Build")] +public sealed class CleanArtifactsModule(IOptions settings) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + !settings.Value.CleanArtifacts || !settings.Value.RunPack + ? SkipDecision.Skip( + "Clearing the artifacts folder is disabled. Set Build__CleanArtifacts=true and Build__RunPack=true to enable it." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override Task ExecuteAsync( + [NotNull] IModuleContext context, + CancellationToken cancellationToken + ) + { + ModuleProgress.Starting(context, nameof(CleanArtifactsModule)); + + var artifactsFolder = Path.GetFullPath(settings.Value.ArtifactsFolder); + + if (Directory.Exists(artifactsFolder)) + context.Logger.LogInformation( + "Clearing the artifacts folder '{ArtifactsFolder}'.", + settings.Value.ArtifactsFolder + ); + + PathHelpers.ResetDirectory(artifactsFolder); + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/src/Build/Modules/CreateGitHubReleaseModule.cs b/src/src/Build/Modules/CreateGitHubReleaseModule.cs index 07a6285..20d1c76 100644 --- a/src/src/Build/Modules/CreateGitHubReleaseModule.cs +++ b/src/src/Build/Modules/CreateGitHubReleaseModule.cs @@ -36,6 +36,8 @@ releaseSettings.Value.Mode is not (ReleaseMode.NuGet or ReleaseMode.GitHubReleas CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(CreateGitHubReleaseModule)); + var versionResult = await context.GetModule(); var version = versionResult.ValueOrDefault diff --git a/src/src/Build/Modules/LintModule.cs b/src/src/Build/Modules/LintModule.cs index 1d4fa60..317c17d 100644 --- a/src/src/Build/Modules/LintModule.cs +++ b/src/src/Build/Modules/LintModule.cs @@ -29,6 +29,8 @@ protected override ModuleConfiguration Configure() => CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(LintModule)); + var repositoryRoot = PathHelpers.FindRepositoryRoot(); if (settings.Value.ProjectType == ProjectType.Web) diff --git a/src/src/Build/Modules/PackModule.cs b/src/src/Build/Modules/PackModule.cs index de15c35..9fe135f 100644 --- a/src/src/Build/Modules/PackModule.cs +++ b/src/src/Build/Modules/PackModule.cs @@ -32,6 +32,8 @@ protected override ModuleConfiguration Configure() => CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(PackModule)); + var versionResult = await context.GetModule(); var nugetVersion = versionResult.ValueOrDefault diff --git a/src/src/Build/Modules/PublishLocalNuGetModule.cs b/src/src/Build/Modules/PublishLocalNuGetModule.cs index ef1dbde..e20e60f 100644 --- a/src/src/Build/Modules/PublishLocalNuGetModule.cs +++ b/src/src/Build/Modules/PublishLocalNuGetModule.cs @@ -37,6 +37,8 @@ protected override ModuleConfiguration Configure() => CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(PublishLocalNuGetModule)); + var settings = localNuGetFeedSettings.Value; var localFeedPath = settings.GetLocalFeedPath(); diff --git a/src/src/Build/Modules/PublishNuGetModule.cs b/src/src/Build/Modules/PublishNuGetModule.cs index 465e179..cddf149 100644 --- a/src/src/Build/Modules/PublishNuGetModule.cs +++ b/src/src/Build/Modules/PublishNuGetModule.cs @@ -40,6 +40,8 @@ protected override ModuleConfiguration Configure() => CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(PublishNuGetModule)); + var artifactsFolder = buildSettings.Value.ArtifactsFolder; if (!Directory.Exists(artifactsFolder)) { diff --git a/src/src/Build/Modules/RestoreModule.cs b/src/src/Build/Modules/RestoreModule.cs index b084c75..d050795 100644 --- a/src/src/Build/Modules/RestoreModule.cs +++ b/src/src/Build/Modules/RestoreModule.cs @@ -9,6 +9,7 @@ namespace Purview.Build.Modules; [ModuleCategory("Build")] +[DependsOn] public sealed class RestoreModule(IOptions settings) : Module { protected override async Task ExecuteAsync( @@ -16,6 +17,8 @@ public sealed class RestoreModule(IOptions settings) : Module CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(RunTestsModule)); + if (settings.Value.ProjectType == ProjectType.Web) { var repositoryRoot = PathHelpers.FindRepositoryRoot(); diff --git a/src/src/Build/Modules/ValidatePackModule.cs b/src/src/Build/Modules/ValidatePackModule.cs index 975ab27..a2d11ce 100644 --- a/src/src/Build/Modules/ValidatePackModule.cs +++ b/src/src/Build/Modules/ValidatePackModule.cs @@ -34,6 +34,8 @@ protected override ModuleConfiguration Configure() => CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(ValidatePackModule)); + var artifactsFolder = Path.GetFullPath(buildSettings.Value.ArtifactsFolder); if (!Directory.Exists(artifactsFolder)) { diff --git a/src/src/Build/Modules/VersionModule.cs b/src/src/Build/Modules/VersionModule.cs index 19a4a57..4584fa0 100644 --- a/src/src/Build/Modules/VersionModule.cs +++ b/src/src/Build/Modules/VersionModule.cs @@ -8,6 +8,7 @@ namespace Purview.Build.Modules; [ModuleCategory("Build")] +[DependsOn] public sealed class VersionModule : Module { protected override async Task ExecuteAsync( @@ -15,6 +16,8 @@ public sealed class VersionModule : Module CancellationToken cancellationToken ) { + ModuleProgress.Starting(context, nameof(VersionModule)); + var packageJsonPath = Path.Combine(Environment.CurrentDirectory, "package.json"); if (!File.Exists(packageJsonPath)) diff --git a/src/src/Build/Program.cs b/src/src/Build/Program.cs index 01fdc0b..8d90c25 100644 --- a/src/src/Build/Program.cs +++ b/src/src/Build/Program.cs @@ -1,58 +1,42 @@ -var pipelineDirectory = PipelineProjectDirectory.Find(); -var repositoryRoot = PathHelpers.FindRepositoryRoot(Environment.CurrentDirectory); - -var builder = Pipeline.CreateBuilder(args); - -builder - .Configuration.AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false) - .AddJsonFile(Path.Combine(repositoryRoot, "purview-build.json"), optional: true) - .AddEnvironmentVariables() - .AddCommandLine(args); - -builder.Services.Configure( - builder.Configuration.GetSection(BuildSettings.SectionName) -); -builder.Services.Configure( - builder.Configuration.GetSection(NuGetSettings.SectionName) -); -builder.Services.Configure( - builder.Configuration.GetSection(PackValidationSettings.SectionName) -); -builder.Services.Configure( - builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName) -); -builder.Services.Configure( - builder.Configuration.GetSection(GitHubSettings.SectionName) -); -builder.Services.Configure( - builder.Configuration.GetSection(ReleaseSettings.SectionName) -); - -builder.Services.AddSingleton(serviceProvider => +var informationalFlag = InformationalFlags.Parse(args); + +if (informationalFlag == InformationalFlag.Version) +{ + CLIConsole.WriteLine(ToolInfo.VersionLine); + return 0; +} + +if (informationalFlag == InformationalFlag.Help) +{ + CLIConsole.WriteLine(ToolInfo.HelpText); + return 0; +} + +// Identify the tool up front, so CI job logs and local runs show which build tool executed the pipeline. +CLIConsole.WriteLine(ToolInfo.VersionLine); +CLIConsole.WriteLine(); + +try +{ + var failures = await BuildPipeline.RunAsync(args); + + if (failures.Count == 0) + return 0; + + CLIConsole.WriteLine(); + CLIConsole.WriteLine($"{ToolInfo.Name} failed: {failures.Count} module(s) failed."); + + foreach (var failure in failures) + { + CLIConsole.WriteLine(); + CLIConsole.WriteLine(FailureReport.Format(failure.ModuleName, failure.ExceptionOrDefault!)); + } + + return 1; +} +catch (Exception exception) { - var settings = serviceProvider.GetRequiredService>(); - var accessToken = settings.Value.GetGitHubToken(); - - return new GitHubClient( - new(settings.Value.ProductHeader), - new InMemoryCredentialStore(new(accessToken)) - ); -}); - -Environment.CurrentDirectory = repositoryRoot; - -builder - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule() - .AddModule(); - -await using var pipeline = await builder.BuildAsync(); - -await pipeline.RunAsync(); + CLIConsole.WriteLine(); + CLIConsole.WriteLine(FailureReport.Format($"{ToolInfo.Name} failed", exception)); + return 1; +} \ No newline at end of file diff --git a/src/src/Build/Settings/BuildSettings.cs b/src/src/Build/Settings/BuildSettings.cs index 9a6ee80..0752423 100644 --- a/src/src/Build/Settings/BuildSettings.cs +++ b/src/src/Build/Settings/BuildSettings.cs @@ -6,7 +6,7 @@ public sealed class BuildSettings { public const string SectionName = "Build"; - public LogLevel LogLevel { get; init; } = LogLevel.Warning; + public LogLevel LogLevel { get; init; } = LogLevel.Information; /// /// The kind of repository the pipeline operates on. runs the @@ -24,6 +24,14 @@ public sealed class BuildSettings [Required(AllowEmptyStrings = false)] public string ArtifactsFolder { get; init; } = "artifacts"; + /// + /// When true, deletes and recreates before + /// the pipeline produces any output, so validation, publishing, and release uploads only ever see the + /// artifacts from the current run. Ignored when is false (nothing will be packed, so + /// existing artifacts, such as a folder being inspected ahead of a publish, are left untouched). + /// + public bool CleanArtifacts { get; init; } = true; + public bool RunTests { get; init; } = true; /// diff --git a/src/src/Build/appsettings.json b/src/src/Build/appsettings.json index aa28d03..6ba802b 100644 --- a/src/src/Build/appsettings.json +++ b/src/src/Build/appsettings.json @@ -1,9 +1,11 @@ { "Build": { + "LogLevel": "Information", "ProjectType": "DotNet", "Solution": "src/Product.slnx", "Configuration": "Release", "ArtifactsFolder": "artifacts", + "CleanArtifacts": true, "RunTests": true, "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", diff --git a/src/tests/Build.IntegrationTests/ArtifactsCleanupTests.cs b/src/tests/Build.IntegrationTests/ArtifactsCleanupTests.cs new file mode 100644 index 0000000..57527cf --- /dev/null +++ b/src/tests/Build.IntegrationTests/ArtifactsCleanupTests.cs @@ -0,0 +1,66 @@ +using Purview.Build.Helpers; + +namespace Purview.Build; + +public class ArtifactsCleanupTests +{ + static string CreateTempArtifactsFolder(params string[] relativeFiles) + { + var directory = Path.Combine(Path.GetTempPath(), "purview-build-artifacts-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + foreach (var relativeFile in relativeFiles) + { + var filePath = Path.Combine(directory, relativeFile); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, "stale"); + } + + return directory; + } + + [Test] + public async Task ResetDirectory_RemovesStaleArtifactsAndRecreatesTheFolder() + { + var artifactsFolder = CreateTempArtifactsFolder( + "Purview.ValueObjects.1.0.0-prerelease.6.nupkg", + "Purview.ValueObjects.1.0.0-prerelease.6.snupkg", + "nested/Purview.ValueObjects.1.0.0-prerelease.7.nupkg" + ); + + try + { + PathHelpers.ResetDirectory(artifactsFolder); + + await Assert.That(Directory.Exists(artifactsFolder)).IsTrue(); + await Assert.That(Directory.EnumerateFileSystemEntries(artifactsFolder).Any()).IsFalse(); + } + finally + { + if (Directory.Exists(artifactsFolder)) + Directory.Delete(artifactsFolder, recursive: true); + } + } + + [Test] + public async Task ResetDirectory_GivenMissingFolder_CreatesIt() + { + var artifactsFolder = Path.Combine( + Path.GetTempPath(), + "purview-build-artifacts-" + Guid.NewGuid().ToString("N") + ); + + try + { + PathHelpers.ResetDirectory(artifactsFolder); + + await Assert.That(Directory.Exists(artifactsFolder)).IsTrue(); + await Assert.That(Directory.EnumerateFileSystemEntries(artifactsFolder).Any()).IsFalse(); + } + finally + { + if (Directory.Exists(artifactsFolder)) + Directory.Delete(artifactsFolder, recursive: true); + } + } +} diff --git a/src/tests/Build.IntegrationTests/ToolCLITests.cs b/src/tests/Build.IntegrationTests/ToolCLITests.cs new file mode 100644 index 0000000..1215bcb --- /dev/null +++ b/src/tests/Build.IntegrationTests/ToolCLITests.cs @@ -0,0 +1,129 @@ +using Purview.Build.Helpers; + +namespace Purview.Build; + +public class ToolCLITests +{ + [Test] + public async Task ToolInfo_ReportsVersionWithoutBuildMetadata() + { + await Assert.That(ToolInfo.Version).IsNotEmpty(); + await Assert.That(ToolInfo.Version).DoesNotContain("+"); + await Assert.That(ToolInfo.VersionLine).IsEqualTo($"{ToolInfo.Name} {ToolInfo.Version}"); + } + + [Test] + public async Task ToolInfo_HelpTextDocumentsOptionsAndConfiguration() + { + await Assert.That(ToolInfo.HelpText).Contains("--version"); + await Assert.That(ToolInfo.HelpText).Contains("--help"); + await Assert.That(ToolInfo.HelpText).Contains("Build__RunPack=false"); + await Assert.That(ToolInfo.HelpText).Contains("PURVIEW_BUILD_STACKTRACE"); + } + + [Test] + public async Task InformationalFlags_GivenVersionFlags_ReturnsVersion() + { + await Assert.That(InformationalFlags.Parse(["--version"])).IsEqualTo(InformationalFlag.Version); + await Assert.That(InformationalFlags.Parse(["-v"])).IsEqualTo(InformationalFlag.Version); + await Assert.That(InformationalFlags.Parse(["--VERSION"])).IsEqualTo(InformationalFlag.Version); + } + + [Test] + public async Task InformationalFlags_GivenHelpFlags_ReturnsHelp() + { + await Assert.That(InformationalFlags.Parse(["--help"])).IsEqualTo(InformationalFlag.Help); + await Assert.That(InformationalFlags.Parse(["-h"])).IsEqualTo(InformationalFlag.Help); + await Assert.That(InformationalFlags.Parse(["-?"])).IsEqualTo(InformationalFlag.Help); + } + + [Test] + public async Task InformationalFlags_GivenConfigurationOverrides_ReturnsNone() + { + var flag = InformationalFlags.Parse(["--Build:RunPack=false", "--Release:Mode=NuGet"]); + + await Assert.That(flag).IsEqualTo(InformationalFlag.None); + } + + [Test] + public async Task InformationalFlags_GivenVersionAndHelp_PrefersVersion() + { + await Assert.That(InformationalFlags.Parse(["--help", "--version"])).IsEqualTo(InformationalFlag.Version); + } + + [Test] + public async Task FailureReport_Describe_FlattensMessagesOutermostFirst() + { + Exception exception = new InvalidOperationException( + "Pack validation failed.", + new IOException("Stale package.") + ); + + var messages = FailureReport.Describe(exception); + + await Assert.That(messages).Count().IsEqualTo(2); + await Assert.That(messages[0]).IsEqualTo("Pack validation failed."); + await Assert.That(messages[1]).IsEqualTo("Stale package."); + } + + [Test] + public async Task FailureReport_Describe_SkipsRepeatedMessages() + { + Exception exception = new InvalidOperationException( + "Same message.", + new InvalidOperationException("Same message.") + ); + + await Assert.That(FailureReport.Describe(exception)).Count().IsEqualTo(1); + } + + [Test] + public async Task FailureReport_Format_IndentsNestedMessagesUnderTheModuleName() + { + Exception exception = new InvalidOperationException( + "Pack validation failed.", + new IOException("Stale package.") + ); + + var formatted = FailureReport.Format("ValidatePackModule", exception); + + await Assert.That(formatted).Contains("ValidatePackModule: Pack validation failed."); + await Assert.That(formatted).Contains(" Stale package."); + await Assert.That(formatted.Split('\n')).Count().IsEqualTo(2); + } + + [Test] + public async Task FailureReport_Format_ReportsPipelineModuleFailuresByModuleName() + { + Exception exception = new InvalidOperationException( + """ + The module RestoreModule has failed. + + Input: dotnet restore src/DoesNotExist.slnx + + Error: MSBUILD : error MSB1009: Project file does not exist. + Exit Code: 1 + """ + ); + + var formatted = FailureReport.Format("Purview.Build failed", exception); + + await Assert.That(formatted).Contains("Purview.Build failed: RestoreModule"); + await Assert.That(formatted).Contains(" Input: dotnet restore src/DoesNotExist.slnx"); + await Assert.That(formatted).Contains(" Error: MSBUILD : error MSB1009: Project file does not exist."); + await Assert.That(formatted).DoesNotContain("has failed."); + } + + [Test] + public async Task FailureReport_Format_DeduplicatesRepeatedDetailLines() + { + Exception exception = new InvalidOperationException( + "The module RestoreModule has failed.\n\nInput: dotnet restore src/DoesNotExist.slnx\nExit Code: 1", + new InvalidOperationException("Input: dotnet restore src/DoesNotExist.slnx\nExit Code: 1") + ); + + var formatted = FailureReport.Format("Purview.Build failed", exception); + + await Assert.That(formatted.Split('\n')).Count().IsEqualTo(3); + } +}