From c8e7702eac73c7ff5137119fa914cf35e887ed6a Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 16:17:34 +0200 Subject: [PATCH 01/13] Move the configuration file back to the repository root Co-Authored-By: Claude Opus 5 (1M context) --- .buildvana/buildvana.jsonc => buildvana.jsonc | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .buildvana/buildvana.jsonc => buildvana.jsonc (100%) diff --git a/.buildvana/buildvana.jsonc b/buildvana.jsonc similarity index 100% rename from .buildvana/buildvana.jsonc rename to buildvana.jsonc From baa756a6a23c9c8250122e9ce4b07085aba5db0a Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 16:26:11 +0200 Subject: [PATCH 02/13] Drop .buildvana as a configuration file location Hooks are file-based apps living under .buildvana, so home-directory discovery runs from there on every hook build. A configuration file in that directory satisfied the marker rule's clause for a configuration file sitting directly in the directory, so each hook discovered .buildvana as its own home directory: the repository's stylecop.json was rejected as external to the repository, leaving hooks to compile under StyleCop's defaults, and every other HomeDirectory-relative path was wrong by one level. The location was a nice-to-have for repositories grouping Buildvana files away from the root, and it shipped in one preview. Removing it makes the collision unrepresentable rather than guarding against it: every marker now sits in the directory it marks, so nothing under a subdirectory takes part in discovery, and the rule reads the same in the loader, in the canonical discovery implementation, and in Sdk.props. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- docs/DirectoryStructure.md | 8 +-- docs/Hooks.md | 2 +- docs/SdkDiagnostics.md | 2 +- .../BuildvanaConfigLoader.cs | 4 +- .../DiscoveredHomeDirectoryProvider.cs | 2 +- .../HomeDirectoryDiscovery.cs | 15 ++--- src/Buildvana.Runtime/BuildvanaConfig-Load.cs | 12 ++-- src/Buildvana.Sdk/Sdk/Sdk.props | 17 ++---- .../BuildvanaConfigLoaderTests.cs | 58 ++----------------- .../HomeDirectoryDiscoveryTests.cs | 16 ++--- .../BuildvanaConfigLoadTests.cs | 16 ----- tests/Buildvana.Sdk.Tests/SdkPropsTests.cs | 35 ++--------- 13 files changed, 43 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd418c16..03436eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,6 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features - Commands that forward extra arguments to `dotnet` (`restore`, `build`, `test`, `pack`) are marked as such in `bv`'s root help, and their per-command help (`bv --help`) includes a `FORWARDED ARGUMENTS` section. - Buildvana now recognizes a repository-root configuration file, `buildvana.json` (or its commented variant `buildvana.jsonc`). It is discovered, parsed, validated, and exposed to `bv`; the settings it currently drives are listed below, and more are wired in over subsequent releases. A committed JSON schema (`schemas/buildvana.schema.json`) is generated from the typed model, so editors can validate and document the file; unknown keys, an invalid file, or the presence of both `buildvana.json` and `buildvana.jsonc` in the same directory are reported as errors and will prevent `bv` from executing _any_ subcommand, even those that are not driven by any configuration setting (e.g., `clean`). - Both `bv` and the SDK now treat a `buildvana.json`/`buildvana.jsonc` file as a home-directory marker, alongside Git markers; home-directory discovery now stops at the nearest directory (the starting directory included) that contains any marker. -- The configuration file is now also recognized in a `.buildvana` directory directly under the home directory (`.buildvana/buildvana.json` or `.buildvana/buildvana.jsonc`), so that repositories accumulating more Buildvana-related files can group them there instead of cluttering the root. Exactly one configuration file may exist per home directory across the four candidate locations; any two coexisting is an error naming all offending files, generalizing the previous rule against having both `buildvana.json` and `buildvana.jsonc`. A configuration file inside `.buildvana` is a home-directory marker like one in the root, marking the directory that contains `.buildvana` as home; a `.buildvana` directory without a configuration file is not a marker. - `buildvana.json` now drives several build and release settings that were previously CLI-only or hardcoded. Each resolves as CLI flag (where one exists) → `buildvana.json` → built-in default: - the default build configuration (`dotnet.configuration`, default `Release`), used by `bv restore`/`build`/`test`/`pack` and as the base of `bv release`'s configuration chain (`--configuration` → `release.configuration` → `dotnet.configuration`); - extra arguments and environment variables for each `dotnet` invocation: `dotnet.all` (applied to every invocation) merged with the per-command `dotnet.restore`/`dotnet.build`/`dotnet.test`/`dotnet.pack`/`dotnet.nugetPush`, each carrying `args` and `env`. Arguments are appended in the order base → `dotnet.all` → per-command → forwarded command-line arguments (so a `--` argument still wins); environment variables apply `dotnet.all` then the per-command entries; @@ -49,7 +48,7 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features - **BREAKING CHANGE**: `bv` now defaults to `minimal` verbosity, for every command alike; it used to default to `normal`. The build pipeline commands wrap `dotnet restore`/`build`/`test`/`pack`, which default to `minimal` themselves and receive `bv`'s verbosity verbatim, so `bv build` used to produce a markedly noisier MSBuild log than plain `dotnet build`; it now produces a comparable one. Nothing fails and no migration is required: pass `-v normal` for the previous output. Note that `bv`'s own narration — activity start/finish lines and the `info:` lines describing what the tool is doing — is hidden at the new default, while the record of what a command _did_ survives it (see the new `notice:` level below), so `bv release` still logs its complete account of what it changed and published. - A new message level, rendered as `notice:`, sits between `warning:` and `info:` and is shown from `minimal` verbosity up. It carries the messages that record a fact — something changed, something was decided, something was deliberately skipped — as opposed to the narration of what a command is doing at a given moment, which stays at `info:`. This is also the level at which the version computed for a build is reported, so that line keeps appearing in `dotnet build` output at its default verbosity, as it did before. Buildvana SDK tasks and `bv` now agree on the verbosity at which each level becomes visible: previously a message logged by a task showed up one rung earlier than the same message printed by `bv`. -- **BREAKING CHANGE**: The `.buildvana-home` marker file is no longer recognized: home-directory discovery now only looks for a Buildvana configuration file (in the home directory root or in its `.buildvana` subdirectory) and Git markers. `.buildvana-home` predates the configuration file, and a `buildvana.json` containing an empty object (`{}`) does the same job — marking a directory as home without configuring anything — while being what one would naturally reach for. To migrate, replace `.buildvana-home` with a `buildvana.json` file containing `{}`. +- **BREAKING CHANGE**: The `.buildvana-home` marker file is no longer recognized: home-directory discovery now only looks for a Buildvana configuration file (in the home directory itself) and Git markers. `.buildvana-home` predates the configuration file, and a `buildvana.json` containing an empty object (`{}`) does the same job — marking a directory as home without configuring anything — while being what one would naturally reach for. To migrate, replace `.buildvana-home` with a `buildvana.json` file containing `{}`. - **BREAKING CHANGE**: The `JetBrainsAnnotations` module no longer adds any JetBrains annotations package to your project, and the `UseJetBrainsAnnotations` property has been removed. To export ReSharper external annotations, reference an annotations source yourself — the compiled `JetBrains.Annotations` package, `JetBrains.Annotations.Sources`, or your own attributes in the `JetBrains.Annotations` namespace — and set the new boolean `ExportJetBrainsAnnotations` property (default `false`) to `true`. When enabled, Buildvana SDK reads the annotations from source with Roslyn after each build and packs a `{AssemblyName}.ExternalAnnotations.xml` file next to the assembly in `lib/`, one per target framework. The export no longer depends on Mono.Cecil or on a second build pass; and, when the annotation attributes are `[Conditional("JETBRAINS_ANNOTATIONS")]` (as in the JetBrains packages), no JetBrains attribute metadata remains in the compiled assembly, leaving clean IL and AOT output. - **BREAKING CHANGE**: The `JetBrainsAnnotations` module no longer supports Visual Basic projects. `ExportJetBrainsAnnotations` is forced to `false` for any project that is not a C# (`.csproj`) project, because the exporter reads C# source directly. - `bv` may now be invoked from any subdirectory under the solution's directory (`HomeDirectory` property in Buildvana SDK). It will search upwards for the home directory, the same way the SDK does, and work from there. If it doesn't find the home directory, `bv` will exit with a non-zero exit code. As soon as the home directory is discovered, `bv` makes it the process's current directory: from that point on, every relative path — including relative paths in forwarded arguments — resolves against the home directory no matter where `bv` was invoked from, matching delegated runs, which are spawned from the home directory. Commands that never need the home directory (e.g. `bv --version`) leave the current directory untouched. diff --git a/docs/DirectoryStructure.md b/docs/DirectoryStructure.md index dd274d30..b03e008c 100644 --- a/docs/DirectoryStructure.md +++ b/docs/DirectoryStructure.md @@ -37,8 +37,6 @@ We will follow the MSBuild convention of a backslash (`\`) as a path separator. | | +--- release\ | | | | | +--- post-release.cs -| | -| +--- buildvana.jsonc <<< Buildvana configuration file, if not in the home directory root | +--- .buildvana-temp\ <<< bv's scratch directory (machine-generated; add to .gitignore) | @@ -63,7 +61,7 @@ We will follow the MSBuild convention of a backslash (`\`) as a path separator. | +--- Common.props <<< Portions of MSBuild code common to all projects in tests\ | +--- Common.targets | -+--- buildvana.jsonc <<< Buildvana configuration file (or buildvana.json), if not in .buildvana\ ++--- buildvana.jsonc <<< Buildvana configuration file (or buildvana.json) | +--- Common.props <<< Common parts of MSBuild projects +--- Common.targets @@ -108,11 +106,11 @@ The full path of the home directory, including a trailing path separator, is sto Buildvana SDK determines the location of the home directory by walking up the directory hierarchy, starting from the project's directory (included), and stopping at the nearest directory that contains any of these home markers: -- a Buildvana configuration file (`buildvana.json` or `buildvana.jsonc`), either directly in the directory or in a `.buildvana` subdirectory (a `.buildvana` directory without a configuration file is _not_ a marker); +- a Buildvana configuration file (`buildvana.json` or `buildvana.jsonc`); - a Git worktree or submodule (a file named `.git`); - a regular Git repository (a file named `HEAD` in a `.git` subdirectory). -The directory containing the marker becomes the home directory, and its full path becomes the value of `HomeDirectory`. Note that a configuration file inside `.buildvana` marks the directory containing `.buildvana`, not `.buildvana` itself. A configuration file does not have to actually configure anything: an empty JSON object (`{}`) is valid content, making the file usable as a pure home-directory marker. +The directory containing the marker becomes the home directory, and its full path becomes the value of `HomeDirectory`. Every marker sits in the directory it marks, so nothing under a subdirectory — the `.buildvana` directory included — takes part in discovery: hooks are projects living under `.buildvana\`, and a marker recognized in there would make each of them discover `.buildvana\` as its own home directory. A configuration file does not have to actually configure anything: an empty JSON object (`{}`) is valid content, making the file usable as a pure home-directory marker. If no marker is found, the build (or project loading in Visual Studio) stops with error [BVSDK1003](SdkDiagnostics.md#buildvana-sdk-core-1000-1049). diff --git a/docs/Hooks.md b/docs/Hooks.md index 327df16c..843a6fc8 100644 --- a/docs/Hooks.md +++ b/docs/Hooks.md @@ -94,7 +94,7 @@ In the JSON file, member names are camelCase (`runtimeInfo.homeDirectory`, `rele ## Loading the repository configuration -The args carry the facts of the run; for any standing repository setting, load the configuration file instead: `BuildvanaConfig.Load()` probes the four well-known candidates (`buildvana.json`, `buildvana.jsonc`, and the same names under `.buildvana/`), applies the usual exactly-one rule, tolerates comments and trailing commas, and returns the typed configuration (an empty instance when no configuration file exists): +The args carry the facts of the run; for any standing repository setting, load the configuration file instead: `BuildvanaConfig.Load()` probes the two well-known candidates (`buildvana.json` and `buildvana.jsonc`), applies the usual exactly-one rule, tolerates comments and trailing commas, and returns the typed configuration (an empty instance when no configuration file exists): ```csharp var config = BuildvanaConfig.Load(); diff --git a/docs/SdkDiagnostics.md b/docs/SdkDiagnostics.md index c58f1478..0818e18f 100644 --- a/docs/SdkDiagnostics.md +++ b/docs/SdkDiagnostics.md @@ -36,7 +36,7 @@ Each module is assigned a contiguous range of 100 diagnostics, as listed below. | BVSDK1002 | Error | Sdk.props and Sdk.targets are in different directories. | `Sdk.props` and `Sdk.targets` were imported from two different versions of Buildvana SDK; look for stray `Version` attributes in the `` directives. | | BVSDK1003 | Error | Home directory not defined. | No suitable value for the `HomeDirectory` property has been found. | | BVSDK1004 | Error | Buildvana SDK requires at least MSBuild v... | You are trying to use Buildvana SDK with an unsupported version of MSBuild. See [the README](../README.md#toolchain) for a list of supported MSBuild versions. | -| BVSDK1005 | Error | Multiple Buildvana configuration files found. | A home directory contains more than one configuration file (in the root and/or the .buildvana subdirectory); keep only one. | +| BVSDK1005 | Error | Multiple Buildvana configuration files found. | A home directory contains both `buildvana.json` and `buildvana.jsonc`; keep only one. | ## Buildvana SDK tasks (1050-1099) diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs index 133eacd4..42af0b07 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs @@ -36,8 +36,8 @@ public static class BuildvanaConfigLoader /// The home directory to search for a configuration file. /// The parsed configuration, or an empty if no file is present. /// - /// More than one configuration file is present (among buildvana.json, buildvana.jsonc, - /// .buildvana/buildvana.json, and .buildvana/buildvana.jsonc), or the file cannot be read. + /// Both configuration files (buildvana.json and buildvana.jsonc) are present, + /// or the file cannot be read. /// The file is present but not valid JSON, or does not conform to the schema; in that case /// lists each problem with its source location. /// diff --git a/src/Buildvana.Core.HomeDirectory/DiscoveredHomeDirectoryProvider.cs b/src/Buildvana.Core.HomeDirectory/DiscoveredHomeDirectoryProvider.cs index d869c9cf..e71301cb 100644 --- a/src/Buildvana.Core.HomeDirectory/DiscoveredHomeDirectoryProvider.cs +++ b/src/Buildvana.Core.HomeDirectory/DiscoveredHomeDirectoryProvider.cs @@ -32,5 +32,5 @@ public DiscoveredHomeDirectoryProvider(string startDirectory) protected override string Resolve() => HomeDirectoryDiscovery.TryDiscover(_startDirectory, out var homeDirectory) ? homeDirectory - : throw new BuildFailedException($"Home directory not defined (no buildvana.json[c], .buildvana/buildvana.json[c], .git, or .git/HEAD found at or above '{_startDirectory}')."); + : throw new BuildFailedException($"Home directory not defined (no buildvana.json[c], .git, or .git/HEAD found at or above '{_startDirectory}')."); } diff --git a/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs b/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs index 151f43a7..c112c5be 100644 --- a/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs +++ b/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs @@ -11,15 +11,14 @@ namespace Buildvana.Core.HomeDirectory; /// /// Canonical implementation of the Buildvana "home directory" discovery algorithm: /// the nearest directory, starting at a given directory and walking upward, that contains any home marker — -/// a buildvana.json or buildvana.jsonc configuration file (either directly in the directory -/// or in a .buildvana subdirectory), a .git file (worktree or submodule), -/// or a .git/HEAD file (regular repository). +/// a buildvana.json or buildvana.jsonc configuration file, a .git file +/// (worktree or submodule), or a .git/HEAD file (regular repository). /// /// /// The search stops at the first directory (the start directory included) that contains any marker; -/// a configuration file only counts when it sits at that directory or in its .buildvana subdirectory. -/// A .buildvana directory without a configuration file is not a marker. Whether a configuration file -/// is actually present there — and which one — is determined separately by BuildvanaConfigLoader. +/// every marker sits in the directory it marks, so nothing under a subdirectory — the .buildvana +/// directory included — takes part in discovery. Whether a configuration file is actually present at the +/// discovered directory — and which one — is determined separately by BuildvanaConfigLoader. /// This algorithm mirrors the discovery performed by the Buildvana SDK in /// src/Buildvana.Sdk/Sdk/Sdk.props. Any change made here MUST be applied to that file as well. /// @@ -57,9 +56,7 @@ public static bool TryDiscover(string startDirectory, [MaybeNullWhen(false)] out private static bool DirectoryContainsMarker(string directory) { var hasConfigFile = File.Exists(Path.Combine(directory, BuildvanaConfig.JsonFileName)) - || File.Exists(Path.Combine(directory, BuildvanaConfig.JsoncFileName)) - || File.Exists(Path.Combine(directory, WellKnownPaths.BuildvanaDirectory, BuildvanaConfig.JsonFileName)) - || File.Exists(Path.Combine(directory, WellKnownPaths.BuildvanaDirectory, BuildvanaConfig.JsoncFileName)); + || File.Exists(Path.Combine(directory, BuildvanaConfig.JsoncFileName)); // A regular repository has a .git directory containing HEAD; a worktree or submodule has a .git file. var hasGitMarker = File.Exists(Path.Combine(directory, ".git", "HEAD")) diff --git a/src/Buildvana.Runtime/BuildvanaConfig-Load.cs b/src/Buildvana.Runtime/BuildvanaConfig-Load.cs index 0fa3e6df..2cbab48e 100644 --- a/src/Buildvana.Runtime/BuildvanaConfig-Load.cs +++ b/src/Buildvana.Runtime/BuildvanaConfig-Load.cs @@ -10,20 +10,20 @@ namespace Buildvana.Runtime; public partial record BuildvanaConfig { /// - /// The name of the configuration file in plain JSON form. The file may live directly in the - /// home directory or in its subdirectory. + /// The name of the configuration file in plain JSON form. The file lives in the home directory itself: + /// a configuration file elsewhere, included, is not one. /// public const string JsonFileName = "buildvana.json"; /// /// The name of the configuration file in JSON-with-comments form, subject to the same - /// two candidate locations as . + /// single candidate location as . /// public const string JsoncFileName = "buildvana.jsonc"; /// - /// Finds the configuration file in a home directory, probing the four well-known candidates - /// (buildvana.json, buildvana.jsonc, and the same names under .buildvana/). + /// Finds the configuration file in a home directory, probing the two well-known candidates + /// (buildvana.json and buildvana.jsonc). /// /// The home directory to probe; the current directory when omitted. /// The path of the configuration file, or when none exists. @@ -35,8 +35,6 @@ public partial record BuildvanaConfig [ Path.Combine(baseDirectory, JsonFileName), Path.Combine(baseDirectory, JsoncFileName), - Path.Combine(baseDirectory, WellKnownPaths.BuildvanaDirectory, JsonFileName), - Path.Combine(baseDirectory, WellKnownPaths.BuildvanaDirectory, JsoncFileName), ]; var existingPaths = Array.FindAll(candidatePaths, File.Exists); if (existingPaths.Length > 1) diff --git a/src/Buildvana.Sdk/Sdk/Sdk.props b/src/Buildvana.Sdk/Sdk/Sdk.props index e972cbbb..8779b54f 100644 --- a/src/Buildvana.Sdk/Sdk/Sdk.props +++ b/src/Buildvana.Sdk/Sdk/Sdk.props @@ -20,12 +20,14 @@ Rules for HomeDirectory: Walking upward from the project's directory (the project's directory included), find the NEAREST directory containing any of these home markers, and use it as the home directory: - * a Buildvana configuration file (buildvana.json or buildvana.jsonc), either directly in the directory - or in a .buildvana subdirectory (a .buildvana directory without a configuration file is NOT a marker); + * a Buildvana configuration file (buildvana.json or buildvana.jsonc); * a Git worktree or submodule (a file named .git); * a regular Git repository (a file named HEAD in a .git subdirectory). "Nearest" means the deepest candidate: since every candidate is the project's directory or an ancestor of it, the candidate with the longest path is the closest one. + Every marker sits in the directory it marks, so nothing under a subdirectory — the .buildvana directory + included — takes part in discovery. That directory holds hooks, which are projects themselves: were a marker + to be recognized inside it, every hook would discover .buildvana as its own home directory. The configuration file is only a marker for the purpose of locating the home directory; the SDK does not parse it here. Whether a configuration file is actually present at the home directory is the loader's concern. @@ -38,15 +40,10 @@ NOTE: Check for ".git/HEAD", not ".git\HEAD", because the latter only works on Windows. MSBuild doesn't convert directory separators on the second argument to GetDirectoryNameOfFileAbove, probably due to https://github.com/microsoft/msbuild/issues/1024 - The same applies to the ".buildvana/buildvana.json" and ".buildvana/buildvana.jsonc" probes. - For subdirectory probes, GetDirectoryNameOfFileAbove returns the directory CONTAINING the subdirectory, - i.e. the probe result itself is the home directory. --> <_HomeDir_ByConfigJson>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','buildvana.json')) <_HomeDir_ByConfigJsonc>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','buildvana.jsonc')) - <_HomeDir_BySubdirConfigJson>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','.buildvana/buildvana.json')) - <_HomeDir_BySubdirConfigJsonc>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','.buildvana/buildvana.jsonc')) <_HomeDir_ByGitFile>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','.git')) <_HomeDir_ByGitHead>$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)','.git/HEAD')) @@ -56,8 +53,6 @@ $(_HomeDir_ByConfigJson) $(_HomeDir_ByConfigJsonc) - $(_HomeDir_BySubdirConfigJson) - $(_HomeDir_BySubdirConfigJsonc) $(_HomeDir_ByGitFile) $(_HomeDir_ByGitHead) @@ -74,12 +69,10 @@ Text="Home directory not defined." /> - + <_BV_FoundConfigFile Condition="Exists('$(HomeDirectory)buildvana.json')" Include="$(HomeDirectory)buildvana.json" /> <_BV_FoundConfigFile Condition="Exists('$(HomeDirectory)buildvana.jsonc')" Include="$(HomeDirectory)buildvana.jsonc" /> - <_BV_FoundConfigFile Condition="Exists('$(HomeDirectory).buildvana/buildvana.json')" Include="$(HomeDirectory).buildvana/buildvana.json" /> - <_BV_FoundConfigFile Condition="Exists('$(HomeDirectory).buildvana/buildvana.jsonc')" Include="$(HomeDirectory).buildvana/buildvana.jsonc" /> e.Code == "BVSDK1005"); - await Assert.That(error.Text).Contains("buildvana.jsonc"); - await Assert.That(error.Text).Contains(".buildvana"); - } - - [Test] - public async Task Evaluate_SingleConfigFile_ReportsNoError() - { - using var fixture = new SdkPropsFixture(); - fixture.WriteFile(".buildvana/buildvana.jsonc"); - var result = fixture.Evaluate(); - await Assert.That(result.Errors).IsEmpty(); - } } From 8712e0492106d8483f025773ce3f4b0f6789dc1f Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 17:20:34 +0200 Subject: [PATCH 03/13] Resolve the configuration file once per run The static loader found the file on every call, so a run could hold two answers to which file it was reading: the DI factory found it and threw the path away, bv update found it again to rewrite its schema reference, and the run-time information passed to hooks carried no path at all. BuildvanaConfigLoader becomes BuildvanaConfigProvider, an instance holding both facts and resolving each on first read, the way HomeDirectoryProvider resolves the home directory. Finding the file is now the provider's own business: the probe is private, so no caller can ask a second time and get a different answer. bv registers the provider and sources the parsed configuration from it, leaving every consumer of the data alone; the SDK task hands the provider the home-directory provider it was already building. LoadFile survives as a public static for the one caller that must bypass the cache: bv update re-reads the file it has just rewritten, and wants a parse that postdates the rewrite rather than the one the provider may already hold. Co-Authored-By: Claude Opus 5 (1M context) --- ...igLoader.cs => BuildvanaConfigProvider.cs} | 90 ++++++++++--- .../HomeDirectoryDiscovery.cs | 2 +- .../Tasks/ComputeVersion-caching.cs | 5 +- .../ServiceCollectionExtensions.cs | 10 +- .../Services/SelfVersionService.cs | 29 ++--- .../Buildvana.Core.Configuration.Tests.csproj | 1 + ...sts.cs => BuildvanaConfigProviderTests.cs} | 122 ++++++++++++++++-- .../SelfVersionServiceTests.cs | 2 + .../UpdateCommandTests.cs | 2 + 9 files changed, 207 insertions(+), 56 deletions(-) rename src/Buildvana.Core.Configuration/{BuildvanaConfigLoader.cs => BuildvanaConfigProvider.cs} (60%) rename tests/Buildvana.Core.Configuration.Tests/{BuildvanaConfigLoaderTests.cs => BuildvanaConfigProviderTests.cs} (55%) diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigProvider.cs similarity index 60% rename from src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs rename to src/Buildvana.Core.Configuration/BuildvanaConfigProvider.cs index 42af0b07..b7dcd1a4 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigProvider.cs @@ -1,11 +1,13 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using Buildvana.Core.HomeDirectory; using Buildvana.Core.IO; using Buildvana.Core.JsonSchema; using Buildvana.Runtime; @@ -14,15 +16,19 @@ namespace Buildvana.Core.Configuration; /// -/// Loads and validates the Buildvana configuration file found in a home directory. +/// Provides the Buildvana configuration of a home directory: which file holds it, and what it says. /// /// /// Unlike the lean loader shipped with Buildvana.Runtime (), this -/// loader validates the file against the configuration schema and reports each problem as a diagnostic with its -/// source location. It is the loader used by bv and by Buildvana SDK tasks; hooks, which read a file -/// bv has already validated, use the lean loader instead. +/// one validates the file against the configuration schema and reports each problem as a diagnostic with its +/// source location. It is used by bv and by Buildvana SDK tasks; hooks, which read a file bv has +/// already validated, use the lean loader instead. +/// and are each resolved on first read and cached — result and +/// exception alike — for the lifetime of the instance, as HomeDirectoryProvider does for the home +/// directory. Finding the file is this class's business alone, so the path a run reports and the file it parses +/// are the same by construction rather than by agreement between callers. /// -public static class BuildvanaConfigLoader +public sealed class BuildvanaConfigProvider { private static readonly JsonDocumentOptions DocumentOptions = new() { @@ -30,31 +36,59 @@ public static class BuildvanaConfigLoader AllowTrailingCommas = true, }; + private readonly Lazy _lazyPath; + private readonly Lazy _lazyConfig; + + /// + /// Initializes a new instance of the class. + /// + /// The provider of the home directory the configuration file is looked for in. + public BuildvanaConfigProvider(IHomeDirectoryProvider home) + { + Guard.IsNotNull(home); + _lazyPath = new Lazy(() => FindFile(home)); + + // Reads the path through its own Lazy, so that a run holding both facts has probed exactly once. + _lazyConfig = new Lazy(() => LoadFile(_lazyPath.Value)); + } + /// - /// Loads the configuration file found in . + /// Gets the absolute path of the configuration file, or when the home directory + /// holds none. + /// + /// + /// Both configuration files (buildvana.json and buildvana.jsonc) are present. + /// + public string? Path => _lazyPath.Value; + + /// + /// Gets the parsed configuration, or an empty when the home directory holds + /// no configuration file. /// - /// The home directory to search for a configuration file. - /// The parsed configuration, or an empty if no file is present. /// /// Both configuration files (buildvana.json and buildvana.jsonc) are present, /// or the file cannot be read. /// The file is present but not valid JSON, or does not conform to the schema; in that case /// lists each problem with its source location. /// - public static BuildvanaConfig Load(string homeDirectory) - { - Guard.IsNotNullOrEmpty(homeDirectory); - - string? path; - try - { - path = BuildvanaConfig.FindFile(homeDirectory); - } - catch (BuildvanaRuntimeException e) - { - throw new BuildFailedException(e.Message, e); - } + public BuildvanaConfig Config => _lazyConfig.Value; + /// + /// Loads the configuration file at an already-known path, bypassing both the search and the cache. + /// + /// The path of the configuration file, or for none. + /// The parsed configuration, or an empty if + /// is . + /// + /// The file cannot be read, is not valid JSON, or does not conform to the schema; in the latter cases + /// lists each problem with its source location. + /// + /// + /// For the caller that must re-read a file it has just rewritten, and therefore wants the parse this + /// instance has cached to be bypassed rather than reused. Everything else reads . + /// + public static BuildvanaConfig LoadFile(string? path) + { if (path is null) { return new BuildvanaConfig(); @@ -68,6 +102,20 @@ public static BuildvanaConfig Load(string homeDirectory) return node!.Deserialize(BuildvanaJsonContext.Default.BuildvanaConfig) ?? new BuildvanaConfig(); } + // The one probe for the configuration file: nothing outside this class asks which file a home directory + // holds, so no two callers can answer differently. + private static string? FindFile(IHomeDirectoryProvider home) + { + try + { + return BuildvanaConfig.FindFile(home.HomeDirectory); + } + catch (BuildvanaRuntimeException e) + { + throw new BuildFailedException(e.Message, e); + } + } + // Removes a leading UTF-8 byte order mark, if present, so the reader sees only JSON and positions start at 1. private static byte[] StripBom(byte[] bytes) => bytes is [0xEF, 0xBB, 0xBF, .. var rest] ? rest : bytes; diff --git a/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs b/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs index c112c5be..7fbbd173 100644 --- a/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs +++ b/src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs @@ -18,7 +18,7 @@ namespace Buildvana.Core.HomeDirectory; /// The search stops at the first directory (the start directory included) that contains any marker; /// every marker sits in the directory it marks, so nothing under a subdirectory — the .buildvana /// directory included — takes part in discovery. Whether a configuration file is actually present at the -/// discovered directory — and which one — is determined separately by BuildvanaConfigLoader. +/// discovered directory — and which one — is determined separately by BuildvanaConfigProvider. /// This algorithm mirrors the discovery performed by the Buildvana SDK in /// src/Buildvana.Sdk/Sdk/Sdk.props. Any change made here MUST be applied to that file as well. /// diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs index 04658ea5..489d47ce 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs @@ -38,10 +38,11 @@ private static CachedVersion GetOrComputeVersion(string homeDirectory, IReporter private static CachedVersion ComputeCore(string homeDirectory, IReporter reporter, string? fingerprint) { + var home = new FixedHomeDirectoryProvider(homeDirectory); var service = new VersioningService( reporter, - new FixedHomeDirectoryProvider(homeDirectory), - new VersioningSettings(BuildvanaConfigLoader.Load(homeDirectory)), + home, + new VersioningSettings(new BuildvanaConfigProvider(home).Config), new GitHeightCalculator(VersionFile.FileName)); return new CachedVersion( fingerprint, diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 8a4b0638..ef39fabf 100644 --- a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -66,9 +66,12 @@ public IServiceCollection AddBvServices() sp.GetRequiredService())) .AddSingleton(static sp => UpdateSettings.Parse(sp.GetRequiredService().Options)) - // Lazy by design: this factory (and thus discovery, parsing, and validation) runs on first resolve. - // A malformed buildvana.json stays inert until a consumer (e.g. DotNetSettings or ReleaseSettings) reads it. - .AddSingleton(static sp => BuildvanaConfigLoader.Load(sp.GetRequiredService().HomeDirectory)) + // Lazy by design: the provider finds, parses, and validates the file on first read of what is asked + // of it. A malformed buildvana.json stays inert until a consumer (e.g. DotNetSettings or + // ReleaseSettings) reads the configuration. Registering the parsed configuration separately keeps + // those consumers depending on the data alone, while the provider answers whoever needs the path. + .AddSingleton() + .AddSingleton(static sp => sp.GetRequiredService().Config) .AddSingleton() .AddSingleton() .AddSingleton() @@ -89,6 +92,7 @@ public IServiceCollection AddBvServices() .AddSingleton(static sp => new SelfVersionService( sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), OwnVersion.Value)); diff --git a/src/Buildvana.Tool/Services/SelfVersionService.cs b/src/Buildvana.Tool/Services/SelfVersionService.cs index 440ff0bf..d2b0286f 100644 --- a/src/Buildvana.Tool/Services/SelfVersionService.cs +++ b/src/Buildvana.Tool/Services/SelfVersionService.cs @@ -15,7 +15,6 @@ using Buildvana.Core.IO; using Buildvana.Core.Json; using Buildvana.Core.Process; -using Buildvana.Runtime; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using NuGet.Versioning; @@ -45,6 +44,7 @@ internal sealed partial class SelfVersionService private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; + private readonly BuildvanaConfigProvider _config; private readonly IJsonHelper _jsonHelper; private readonly IProcessRunner _processRunner; private readonly NuGetVersion _ownVersion; @@ -54,23 +54,27 @@ internal sealed partial class SelfVersionService /// /// The reporter to log to. /// The provider of the home directory, where global.json and the tool manifest live. + /// The provider of the configuration file whose schema reference is rewritten. /// The JSON helper used to read and rewrite pins. /// The process runner used to invoke dotnet tool update. /// The version of the running bv. public SelfVersionService( IReporter reporter, IHomeDirectoryProvider home, + BuildvanaConfigProvider config, IJsonHelper jsonHelper, IProcessRunner processRunner, NuGetVersion ownVersion) { Guard.IsNotNull(reporter); Guard.IsNotNull(home); + Guard.IsNotNull(config); Guard.IsNotNull(jsonHelper); Guard.IsNotNull(processRunner); Guard.IsNotNull(ownVersion); _reporter = reporter; _home = home; + _config = config; _jsonHelper = jsonHelper; _processRunner = processRunner; _ownVersion = ownVersion; @@ -287,20 +291,11 @@ private string UpdateGlobalJson(string? currentPinText, NuGetVersion? currentPin } // Rewrites the version segment of the configuration file's $schema URL in place, when the URL has the - // well-known shape; a hand-rolled or absent reference is reported, not touched. Runs on whichever of the - // four candidate locations holds the configuration file; no file at all means nothing to update or validate. + // well-known shape; a hand-rolled or absent reference is reported, not touched. Runs on whichever candidate + // the home directory holds; no file at all means nothing to update or validate. private string? UpdateConfigSchemaReference() { - string? path; - try - { - path = BuildvanaConfig.FindFile(_home.HomeDirectory); - } - catch (BuildvanaRuntimeException e) - { - throw new BuildFailedException(e.Message, e); - } - + var path = _config.Path; if (path is null) { return null; @@ -324,18 +319,20 @@ private string UpdateGlobalJson(string? currentPinText, NuGetVersion? currentPin : changed ? $"{fileName}: schema reference updated" : SchemaUrlRegex.IsMatch(schemaReference) ? $"{fileName}: schema reference unchanged" : $"{fileName}: schema reference not recognized, left unchanged"; - ReportConfigValidationProblems(fileName); + ReportConfigValidationProblems(path, fileName); return line; } // The configuration file's content may predate this bv's model; loading it with this version's validating // loader turns the drift into actionable diagnostics. Problems are warnings, not errors: the file keeps // working for the commands that do not read it, and the user decides how to migrate it. - private void ReportConfigValidationProblems(string fileName) + // The file has just been rewritten, so this reads it afresh rather than through the provider, whose parse + // (if a command in this run asked for one) predates the rewrite. + private void ReportConfigValidationProblems(string path, string fileName) { try { - _ = BuildvanaConfigLoader.Load(_home.HomeDirectory); + _ = BuildvanaConfigProvider.LoadFile(path); } catch (BuildFailedException e) { diff --git a/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj b/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj index 35eff46e..8046105f 100644 --- a/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj +++ b/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigProviderTests.cs similarity index 55% rename from tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs rename to tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigProviderTests.cs index 79dc46a0..bb6f2cd6 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigProviderTests.cs @@ -4,16 +4,17 @@ using System.Text; using Buildvana.Core; using Buildvana.Core.Configuration; +using Buildvana.Core.HomeDirectory; -internal sealed class BuildvanaConfigLoaderTests +internal sealed class BuildvanaConfigProviderTests { [Test] - public async Task Load_NoFile_ReturnsEmptyConfig() + public async Task Config_NoFile_IsEmptyConfig() { var dir = NewDir(); try { - var config = BuildvanaConfigLoader.Load(dir); + var config = NewProvider(dir).Config; await Assert.That(config.Release).IsNull(); } finally @@ -23,13 +24,13 @@ public async Task Load_NoFile_ReturnsEmptyConfig() } [Test] - public async Task Load_ValidConfig_Loads() + public async Task Config_ValidConfig_Loads() { var dir = NewDir(); try { Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); - var config = BuildvanaConfigLoader.Load(dir); + var config = NewProvider(dir).Config; await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); } finally @@ -41,13 +42,13 @@ public async Task Load_ValidConfig_Loads() // An empty JSON object is a valid configuration; it makes a configuration file usable // as a pure home-directory marker (the replacement for the retired .buildvana-home file). [Test] - public async Task Load_EmptyObject_Loads() + public async Task Config_EmptyObject_Loads() { var dir = NewDir(); try { Write(dir, "buildvana.json", "{}"); - var config = BuildvanaConfigLoader.Load(dir); + var config = NewProvider(dir).Config; await Assert.That(config.Release).IsNull(); } finally @@ -57,7 +58,100 @@ public async Task Load_EmptyObject_Loads() } [Test] - public async Task Load_BothFilesPresent_ThrowsNamingBothWithoutDiagnostics() + public async Task Path_NoFile_IsNull() + { + var dir = NewDir(); + try + { + await Assert.That(NewProvider(dir).Path).IsNull(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + [Arguments("buildvana.json")] + [Arguments("buildvana.jsonc")] + public async Task Path_OneFile_IsItsPath(string fileName) + { + var dir = NewDir(); + try + { + Write(dir, fileName, "{}"); + await Assert.That(NewProvider(dir).Path).IsEqualTo(Path.Combine(dir, fileName)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + // Deleting the file between the two reads is what proves the answer is cached rather than recomputed: + // one run gets one answer, whichever of the provider's two facts is asked for first. + [Test] + public async Task PathAndConfig_ReadAfterFileRemoval_KeepTheFirstAnswer() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); + var provider = NewProvider(dir); + var path = provider.Path; + var config = provider.Config; + + File.Delete(Path.Combine(dir, "buildvana.jsonc")); + + await Assert.That(provider.Path).IsEqualTo(path); + await Assert.That(provider.Config).IsSameReferenceAs(config); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + // Reading the configuration without having read the path first must find the file just the same: + // the two are resolved independently on demand, from a single probe. + [Test] + public async Task Config_ReadBeforePath_FindsTheFile() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); + var provider = NewProvider(dir); + + await Assert.That(provider.Config.Release!.Branches!.Count).IsEqualTo(1); + await Assert.That(provider.Path).IsEqualTo(Path.Combine(dir, "buildvana.jsonc")); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Path_BothFilesPresent_Throws() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.json", "{}"); + Write(dir, "buildvana.jsonc", "{}"); + var provider = NewProvider(dir); + + _ = await Assert.That(() => provider.Path).Throws(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Config_BothFilesPresent_ThrowsNamingBothWithoutDiagnostics() { var dir = NewDir(); try @@ -77,7 +171,7 @@ public async Task Load_BothFilesPresent_ThrowsNamingBothWithoutDiagnostics() } [Test] - public async Task Load_InvalidJson_ReportsBV1100() + public async Task Config_InvalidJson_ReportsBV1100() { var dir = NewDir(); try @@ -95,7 +189,7 @@ public async Task Load_InvalidJson_ReportsBV1100() } [Test] - public async Task Load_SchemaViolation_ReportsCodeAndPosition() + public async Task Config_SchemaViolation_ReportsCodeAndPosition() { var dir = NewDir(); try @@ -114,7 +208,7 @@ public async Task Load_SchemaViolation_ReportsCodeAndPosition() } [Test] - public async Task Load_UnknownProperty_ReportsBV1103() + public async Task Config_UnknownProperty_ReportsBV1103() { var dir = NewDir(); try @@ -131,7 +225,7 @@ public async Task Load_UnknownProperty_ReportsBV1103() } [Test] - public async Task Load_BomPrefixedFile_DoesNotOffsetPositions() + public async Task Config_BomPrefixedFile_DoesNotOffsetPositions() { var dir = NewDir(); try @@ -147,6 +241,8 @@ public async Task Load_BomPrefixedFile_DoesNotOffsetPositions() } } + private static BuildvanaConfigProvider NewProvider(string dir) => new(new FixedHomeDirectoryProvider(dir)); + private static string NewDir() { var dir = Path.Combine(Path.GetTempPath(), "bvtest_" + Guid.NewGuid().ToString("N")); @@ -165,7 +261,7 @@ private static void Write(string dir, string fileName, string content, bool bom { try { - _ = BuildvanaConfigLoader.Load(dir); + _ = NewProvider(dir).Config; return null; } catch (BuildFailedException exception) diff --git a/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs b/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs index 1a0c8694..f280d757 100644 --- a/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for full license information. using Buildvana.Core; +using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.Json; using Buildvana.Core.Process; @@ -528,6 +529,7 @@ private static SelfVersionService CreateService( => new( reporter ?? NullReporter.Instance, home.Provider, + new BuildvanaConfigProvider(home.Provider), new JsonHelper(), processRunner ?? new FakeProcessRunner(), NuGetVersion.Parse(ownVersion)); diff --git a/tests/Buildvana.Tool.Tests/UpdateCommandTests.cs b/tests/Buildvana.Tool.Tests/UpdateCommandTests.cs index ed896de6..eb6e05f7 100644 --- a/tests/Buildvana.Tool.Tests/UpdateCommandTests.cs +++ b/tests/Buildvana.Tool.Tests/UpdateCommandTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.Json; using Buildvana.Core.Testing; @@ -66,6 +67,7 @@ private static SelfVersionService CreateService(TempHome home, FakeProcessRunner => new( NullReporter.Instance, home.Provider, + new BuildvanaConfigProvider(home.Provider), new JsonHelper(), processRunner ?? new FakeProcessRunner(), NuGetVersion.Parse(OwnVersion)); From ee790a5c2fa4857926d1b6605aecb24819e75230 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 21:58:58 +0200 Subject: [PATCH 04/13] Tell hooks which configuration file the run read A hook had no way to ask which file bv read: the args carried the well-known directories but not the configuration file, so a hook working on that file had to name it, and one naming the wrong file failed at run time, mid-release. RuntimeInfo gains ConfigFile, the absolute path of the file this run read. It is a required member, so a run cannot omit the fact, and a nullable one, because a repository whose home directory is marked by Git alone legitimately has no configuration file at all. HookArgs.LoadConfig() reads that same file through the new BuildvanaConfig.LoadFile, so a hook after settings does not search either: which file to read comes from the args, what it says is read from disk at the moment of the call, and an earlier hook's rewrite is therefore visible rather than shadowed by a snapshot. The contract-evolution section is rewritten around what actually constrains this contract. A hook is compiled from source at every run against the Runtime version the SDK pins, which bv refuses to mismatch, and its args file is rewritten immediately before it runs: writer and reader are the same version by construction, and the JSON crosses no version boundary. What has to stay stable is the source surface a hook compiles against, which is why an addition may be required. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/Hooks.md | 23 ++++++++++-- src/Buildvana.Runtime/BuildvanaConfig-Load.cs | 18 ++++++++- src/Buildvana.Runtime/HookArgs.cs | 14 +++++++ src/Buildvana.Runtime/PublicAPI.Unshipped.txt | 4 ++ src/Buildvana.Runtime/RuntimeInfo.cs | 17 ++++++++- .../Services/Hooks/HookArgsFactory`1.cs | 10 ++++- .../Hooks/PostReleaseHookArgsFactory.cs | 5 ++- .../BuildvanaConfigLoadTests.cs | 23 ++++++++++++ .../PostReleaseHookArgsTests.cs | 37 +++++++++++++++++++ tests/Buildvana.Tool.Tests/HookRunnerTests.cs | 1 + .../PostReleaseHookArgsFactoryTests.cs | 28 +++++++++++++- 12 files changed, 170 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03436eb7..32ee7ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features With a `VERSION` file present, every project built with Buildvana SDK gets `$(Version)`, `$(PackageVersion)`, `$(AssemblyVersion)` (precision-controlled), `$(FileVersion)` (`MAJOR.MINOR.HEIGHT.0`), and `$(InformationalVersion)` computed at build time by a compiled task using LibGit2Sharp — no `git` executable and no external package required — under plain `dotnet build` as well as `MSBuild.exe` and `bv`. The `UseVersioning` property overrides the automatic module activation in both directions. `bv release` now computes versions the same way, in-process. - Versioned C# projects get the generated `ThisAssembly` class by default: when the `Versioning` module is active, `GenerateThisAssemblyClass` defaults to `true`, and the module contributes versioning constants alongside the default assembly-attribute constants: `SimpleVersion`, `SemVer`, `IsPublicRelease`, `IsPrerelease`, and `GitCommitId`. Compared with Nerdbank.GitVersioning's `ThisAssembly`, the `GitCommitDate`/`GitCommitAuthorDate` and `PublicKey`/`PublicKeyToken` constants are not provided, while `AssemblyDescription`, `SimpleVersion`, and `SemVer` are new. - `bv` now runs optional repository-owned hooks: [file-based apps](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/file-based-programs) at well-known paths of the form `.buildvana/hooks//.cs` — `` names the context the event belongs to (currently always the invoking command), `` the moment of execution that triggers the hook — run via `dotnet run` from the home directory when the event occurs, and skipped with an info message when absent. Args are passed through a JSON file at a per-hook well-known path, `.buildvana-temp/hook-args//.json`, (re)written before each hook run and left in place afterwards, so a hook can be re-run by hand against the args of the last run; `.buildvana-temp/` is bv's scratch directory for machine-generated temporary files, recommended for gitignoring and always excluded from bv's own working-tree change detection. `bv clean` clears the hooks' file-based-app build caches and deletes the scratch directory. The first event is `release/post-release`, raised by `bv release` at the moment the post-release commit is assembled: before the built-in self-reference rewrites (when dogfooding is enabled) and before anything is pushed — the hook is a file-based app inside the repository tree, so it builds against the version pins as they stand before the release and reads the version being released from its args. The hook runs whether or not dogfooding is enabled; files it changes join the post-release commit alongside the built-in rewrites, and a non-zero exit code aborts the release before anything is pushed. See [docs/Hooks.md](docs/Hooks.md) for the full contract. -- A new packaged library, `Buildvana.Runtime`, holds the typed model of the Buildvana configuration file and of the run-time information `bv` shares with repository-owned hooks: `BuildvanaConfig` (with `BuildvanaConfig.Load()`, a strict loader usable from file-based apps, where reflection-based JSON serialization is disabled, plus the well-known configuration file names as constants), `PostReleaseHookArgs` (with `PostReleaseHookArgs.Load()`, structured into a `RuntimeInfo` section — shared by the args of every hook through the `HookArgs` base record, and carrying the running `bv`'s version, the delegating `bv`'s version when the run was delegated, and the absolute paths of the run's well-known directories — plus a `Release` section and other hook-specific members; every args type also implements the `IHookEvent` interface, naming its hook's context and event as static properties, so that `bv` dispatches a hook from its args type alone and a hook's identity can never be mismatched with its args), and the well-known paths shared by both sides of the hook contract (directory constants and per-hook path helpers). A hook references the package with an unversioned `#:package Buildvana.Runtime` directive; Buildvana SDK pins the package to its own version for every file-based app built in the repository, so `bv`, the SDK, and hooks always agree on the shape of the data. +- A new packaged library, `Buildvana.Runtime`, holds the typed model of the Buildvana configuration file and of the run-time information `bv` shares with repository-owned hooks: `BuildvanaConfig` (with `BuildvanaConfig.Load()` and `BuildvanaConfig.LoadFile()`, strict loaders usable from file-based apps, where reflection-based JSON serialization is disabled, plus the well-known configuration file names as constants), `PostReleaseHookArgs` (with `PostReleaseHookArgs.Load()`, structured into a `RuntimeInfo` section — shared by the args of every hook through the `HookArgs` base record, and carrying the running `bv`'s version, the delegating `bv`'s version when the run was delegated, the absolute paths of the run's well-known directories, and the absolute path of the configuration file the run read, so that a hook working on that file acts on the one `bv` read instead of searching for it; `hookArgs.LoadConfig()` loads that same file, so a hook reading settings does not search either — plus a `Release` section and other hook-specific members; every args type also implements the `IHookEvent` interface, naming its hook's context and event as static properties, so that `bv` dispatches a hook from its args type alone and a hook's identity can never be mismatched with its args), and the well-known paths shared by both sides of the hook contract (directory constants and per-hook path helpers). A hook references the package with an unversioned `#:package Buildvana.Runtime` directive; Buildvana SDK pins the package to its own version for every file-based app built in the repository, so `bv`, the SDK, and hooks always agree on the shape of the data. - Before running any command that uses Buildvana SDK (`restore`, `build`, `test`, `pack`, and `release`), `bv` now verifies that the repository pins the SDK (the `Buildvana.Sdk` entry under `msbuild-sdks` in `global.json`) at its own version: `bv`, `Buildvana.Sdk`, and `Buildvana.Runtime` are released in lockstep, and a version mismatch — a half-updated repository, a newer globally-installed tool against an older pin — would otherwise produce silent behavior drift. A missing `global.json`, section, or entry counts as a mismatch. On mismatch, the command fails with a message naming both versions and the ways to align them. Versions are compared by SemVer precedence, ignoring build metadata. The new global option `--skip-sdk-check` skips the check, for scenarios that require a deliberate mismatch (e.g. bisecting an SDK regression in CI). - `bv` now delegates to the repository's pinned version: whenever the tool manifest (`.config/dotnet-tools.json`) pins `bv`, the pinned version is the one that runs, no matter which `bv` is invoked — like the Angular CLI, where the global `ng` always hands over to the project-local install. The invoked `bv` makes sure the pinned version is installed — probing the SDK's tool resolver cache the same way `dotnet tool run` does, and running `dotnet tool restore` only when needed; a failed restore is reported but does not block the attempt — and hands it the entire original command line (`dotnet tool run bv`) with inherited standard streams, forwarding its exit code. The delegated `bv` runs from the home directory, so a relative path inside forwarded arguments resolves against the home directory rather than the invocation directory; and `--version` answers for the pinned `bv` (pass `--skip-delegation` to ask the invoked binary). When the versions differ, an info line on standard error names the version that runs. A delegating `bv` does not judge the command line beyond the minimal split that finds the subcommand and the global options (only a value-bearing global option with no following value, such as a trailing `-v`, is rejected before delegation, with the same message in every version), and does not read the configuration file — both may be valid for the pinned version and not for the invoked one, and judging them is the pinned version's job. The new `update` subcommand is exempt (see below); the new global option `--skip-delegation` runs the exact binary invoked; and the `BV_DELEGATED` environment variable, set on the delegated child, guarantees that a delegated invocation never delegates again. The variable is removed from the environment of every other child process `bv` spawns, so a `bv` reached through a hook or a build makes its own delegation decision. A `bv` invoked outside a repository, or in a repository whose tool manifest does not pin `bv` (the entry is matched case-insensitively, like the dotnet CLI matches it; an entry with an unusable version is reported and treated as no pin), runs in place as before. - A new `bv update` command updates the repository's entire Buildvana surface to the running `bv`'s version in one operation: the `bv` pin in the tool manifest, via `dotnet tool update` (or `dotnet tool install --create-manifest-if-needed` when there is no entry yet), which also downloads the version; the `Buildvana.Sdk` pin in `global.json`, creating the file and/or the `msbuild-sdks` section if needed and preserving the file's formatting otherwise; and the version segment of the configuration file's `$schema` reference, when it points at the canonical `Tenacom/Buildvana//schemas/` URL. Afterwards, the configuration file is loaded with the new version's model, and any problems are reported as warnings for review. `update` is exempt from delegation — it updates the repository to the `bv` actually invoked ("bring this repository to me"), so the usual upgrade flow is `dotnet tool update -g bv` followed by `bv update`, and `dnx bv@ update` targets any specific version — and it refuses to downgrade a repository whose pins are newer than the running `bv`, unless `--force` is passed. A manifest whose `bv` entry pins an invalid version is beyond the dotnet CLI's reach entirely (the CLI cannot parse such a manifest), so `bv update` fails up front with a message naming the entry to fix. diff --git a/docs/Hooks.md b/docs/Hooks.md index 843a6fc8..bebd7e35 100644 --- a/docs/Hooks.md +++ b/docs/Hooks.md @@ -80,6 +80,7 @@ The well-known paths themselves ship in the package too: `WellKnownPaths` expose | `RuntimeInfo.HomeDirectory` | string | Absolute path of the home directory, without a trailing separator (also the hook's working directory). | | `RuntimeInfo.ArtifactsDirectory` | string | Absolute path of the directory containing the build artifacts. | | `RuntimeInfo.ScratchDirectory` | string | Absolute path of bv's scratch directory (`.buildvana-temp/`), where hooks can write temporary files without affecting working-tree change detection. | +| `RuntimeInfo.ConfigFile` | string or null | Absolute path of the configuration file this run read, or `null` when the repository has none. See [Loading the repository configuration](#loading-the-repository-configuration). | | `Release.Version` | string | The version being released, in simple `MAJOR.MINOR.PATCH` form, without any prerelease tag. | | `Release.SemVer` | string | The version being released, in full semantic version form. This is the form used by release tags and embedded in artifact names. | | `Release.PreviousVersion` | string or null | The previously released version (the latest release tag reachable from `HEAD`), or `null` when no previous release exists. | @@ -94,14 +95,26 @@ In the JSON file, member names are camelCase (`runtimeInfo.homeDirectory`, `rele ## Loading the repository configuration -The args carry the facts of the run; for any standing repository setting, load the configuration file instead: `BuildvanaConfig.Load()` probes the two well-known candidates (`buildvana.json` and `buildvana.jsonc`), applies the usual exactly-one rule, tolerates comments and trailing commas, and returns the typed configuration (an empty instance when no configuration file exists): +The args carry the facts of the run; for any standing repository setting, load the configuration instead. `hookArgs.LoadConfig()` reads the file `bv` itself read for this run, and returns the typed configuration (an empty instance when the repository has no configuration file): ```csharp -var config = BuildvanaConfig.Load(); +var config = hookArgs.LoadConfig(); var branches = config.Release?.Branches; ``` -The loader is strict — an unknown member fails the load — but does not re-validate what `bv` has already validated with schema-based diagnostics before running any hook. +Which file to read comes from the args, so a hook never searches for one; what it says is read at the moment of the call, so the hook sees the file as it stands even if an earlier hook in the same run rewrote it. The loader tolerates comments and trailing commas, and is strict about content — an unknown member fails the load — but it does not re-validate what `bv` has already validated with schema-based diagnostics before running any hook. + +`BuildvanaConfig.Load()`, which searches a directory for a configuration file, remains available for code that has no hook args to hand. + +A hook that works on the configuration file _itself_ — rewriting a value in it, say — needs the path rather than the settings, and must act on the file `bv` actually read. That path is in the args, as `runtimeInfo.configFile` (`null` when the repository has no configuration file); do not hardcode a file name, and do not search for one: + +```csharp +var configFile = hookArgs.RuntimeInfo.ConfigFile; +if (configFile is not null) +{ + File.WriteAllText(configFile, Rewrite(File.ReadAllText(configFile))); +} +``` ## Dependencies @@ -124,4 +137,6 @@ Local file-based-app caching may not notice implicit-build-file changes; CI is a ## Contract evolution -The args file is written by the installed `bv` and read through the `Buildvana.Runtime` version pinned by the repository's Buildvana SDK; `bv` and the SDK are released in lockstep and designed as a matched pair. The contract is nevertheless additive-only: new members may be added, but existing ones are never removed or repurposed, and additions ship as optional members with default values — so an args file written before an update stays loadable after it. +The args file is written by the installed `bv` and read through the `Buildvana.Runtime` version pinned by the repository's Buildvana SDK — the version of the SDK in use, which `bv` refuses to run against unless it matches its own. The hook is compiled from source at every run, and its args file is rewritten immediately before it. Writer and reader are therefore the same version by construction, and the JSON never has to survive a version boundary. + +What must stay stable is the _source_ surface a hook compiles against: members are never removed or repurposed, so that a hook written today still compiles after an update. Additions may be required members — every run then states every fact the args carry, and none can be left unset by mistake. (An args file left over from a run that predates such an addition no longer loads; re-run the command that raises the hook, and it is rewritten.) diff --git a/src/Buildvana.Runtime/BuildvanaConfig-Load.cs b/src/Buildvana.Runtime/BuildvanaConfig-Load.cs index 2cbab48e..c81a3c17 100644 --- a/src/Buildvana.Runtime/BuildvanaConfig-Load.cs +++ b/src/Buildvana.Runtime/BuildvanaConfig-Load.cs @@ -59,9 +59,23 @@ public partial record BuildvanaConfig /// This loader does not validate the file beyond deserialization: bv has already validated it, /// with schema-based diagnostics, before any hook runs. /// - public static BuildvanaConfig Load(string? homeDirectory = null) + public static BuildvanaConfig Load(string? homeDirectory = null) => LoadFile(FindFile(homeDirectory)); + + /// + /// Loads the configuration file at an already-known path. + /// + /// The path of the configuration file, or for none. + /// The parsed configuration, or an empty when + /// is . + /// + /// The file cannot be read, or its contents are invalid. + /// + /// + /// A hook reaches this through , which passes the path bv + /// itself read; call it directly only when the path comes from somewhere else. + /// + public static BuildvanaConfig LoadFile(string? path) { - var path = FindFile(homeDirectory); if (path is null) { return new BuildvanaConfig(); diff --git a/src/Buildvana.Runtime/HookArgs.cs b/src/Buildvana.Runtime/HookArgs.cs index 0f3b65d4..ab3b85d4 100644 --- a/src/Buildvana.Runtime/HookArgs.cs +++ b/src/Buildvana.Runtime/HookArgs.cs @@ -32,6 +32,20 @@ public abstract record HookArgs [JsonPropertyOrder(-1)] public required RuntimeInfo RuntimeInfo { get; init; } + /// + /// Loads the Buildvana configuration from the file bv read for this run + /// (). + /// + /// The parsed configuration, or an empty when the repository has + /// no configuration file. + /// The file cannot be read, or its contents are invalid. + /// + /// Which file to read comes from the args, so a hook never has to search for one; what the file says + /// is read from disk at the moment of the call, so a hook sees the file as it stands even after an earlier + /// hook in the same run has rewritten it. + /// + public BuildvanaConfig LoadConfig() => BuildvanaConfig.LoadFile(RuntimeInfo.ConfigFile); + /// /// Loads the args of the current run of the hook identified by /// from the hook's args file. diff --git a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt index 7f9b7e10..17a79b60 100644 --- a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt +++ b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt @@ -81,6 +81,7 @@ Buildvana.Runtime.GitIdentityConfig.Name.init -> void Buildvana.Runtime.HookArgs Buildvana.Runtime.HookArgs.HookArgs() -> void Buildvana.Runtime.HookArgs.HookArgs(Buildvana.Runtime.HookArgs! original) -> void +Buildvana.Runtime.HookArgs.LoadConfig() -> Buildvana.Runtime.BuildvanaConfig! Buildvana.Runtime.HookArgs.RuntimeInfo.get -> Buildvana.Runtime.RuntimeInfo! Buildvana.Runtime.HookArgs.RuntimeInfo.init -> void Buildvana.Runtime.IHookEvent @@ -151,6 +152,8 @@ Buildvana.Runtime.RuntimeInfo Buildvana.Runtime.RuntimeInfo.$() -> Buildvana.Runtime.RuntimeInfo! Buildvana.Runtime.RuntimeInfo.ArtifactsDirectory.get -> string! Buildvana.Runtime.RuntimeInfo.ArtifactsDirectory.init -> void +Buildvana.Runtime.RuntimeInfo.ConfigFile.get -> string? +Buildvana.Runtime.RuntimeInfo.ConfigFile.init -> void Buildvana.Runtime.RuntimeInfo.DelegatingVersion.get -> string? Buildvana.Runtime.RuntimeInfo.DelegatingVersion.init -> void Buildvana.Runtime.RuntimeInfo.Equals(Buildvana.Runtime.RuntimeInfo? other) -> bool @@ -229,6 +232,7 @@ override Buildvana.Runtime.VersioningConfig.ToString() -> string! override sealed Buildvana.Runtime.PostReleaseHookArgs.Equals(Buildvana.Runtime.HookArgs? other) -> bool static Buildvana.Runtime.BuildvanaConfig.FindFile(string? homeDirectory = null) -> string? static Buildvana.Runtime.BuildvanaConfig.Load(string? homeDirectory = null) -> Buildvana.Runtime.BuildvanaConfig! +static Buildvana.Runtime.BuildvanaConfig.LoadFile(string? path) -> Buildvana.Runtime.BuildvanaConfig! static Buildvana.Runtime.BuildvanaConfig.operator !=(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaConfig.operator ==(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaJsonContext.Default.get -> Buildvana.Runtime.BuildvanaJsonContext! diff --git a/src/Buildvana.Runtime/RuntimeInfo.cs b/src/Buildvana.Runtime/RuntimeInfo.cs index 9db21f02..84dc7b0f 100644 --- a/src/Buildvana.Runtime/RuntimeInfo.cs +++ b/src/Buildvana.Runtime/RuntimeInfo.cs @@ -7,7 +7,8 @@ namespace Buildvana.Runtime; /// /// Run-time information about the bv run a hook belongs to: the running version, how the run was -/// launched, and the absolute paths of the run's well-known directories. Shared by every hook's args. +/// launched, and the absolute paths of the run's well-known directories and configuration file. +/// Shared by every hook's args. /// [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record RuntimeInfo @@ -39,4 +40,18 @@ public sealed record RuntimeInfo /// where hooks can write temporary files without affecting working-tree change detection. /// public required string ScratchDirectory { get; init; } + + /// + /// Gets the absolute path of the configuration file this run read ( + /// or ), or when the repository has none. + /// + /// + /// A hook that reads settings has no use for this: finds the file + /// on its own. This is for a hook that works on the file itself — rewriting a value, checking it into the + /// post-release commit — and must act on the very file bv read rather than guess which one it was. + /// Required of whoever writes the args, so that a run always states which file it read; the value is + /// nonetheless when the repository has no configuration file, which a repository + /// whose home directory is marked by Git alone legitimately does not. + /// + public required string? ConfigFile { get; init; } } diff --git a/src/Buildvana.Tool/Services/Hooks/HookArgsFactory`1.cs b/src/Buildvana.Tool/Services/Hooks/HookArgsFactory`1.cs index 8c0f45ac..ec2b2dc3 100644 --- a/src/Buildvana.Tool/Services/Hooks/HookArgsFactory`1.cs +++ b/src/Buildvana.Tool/Services/Hooks/HookArgsFactory`1.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using Buildvana.Core.Configuration; using Buildvana.Core.HomeDirectory; using Buildvana.Runtime; using Buildvana.Tool.Infrastructure; @@ -19,13 +20,14 @@ namespace Buildvana.Tool.Services.Hooks; /// /// The type of the hook args the factory creates. /// The home directory provider. -internal abstract class HookArgsFactory(IHomeDirectoryProvider home) +/// The provider of the configuration file this run reads. +internal abstract class HookArgsFactory(IHomeDirectoryProvider home, BuildvanaConfigProvider config) where TArgs : HookArgs, IHookEvent { /// /// Creates the section shared by every hook's args: /// the running bv's version, the delegating bv's version when the run was delegated, and the - /// absolute paths of the run's well-known directories. + /// absolute paths of the run's well-known directories and configuration file. /// /// The path of the directory containing the build artifacts, /// either absolute or relative to the home directory. @@ -47,6 +49,10 @@ protected RuntimeInfo CreateRuntimeInfo(string artifactsPath) HomeDirectory = Path.TrimEndingDirectorySeparator(home.HomeDirectory), ArtifactsDirectory = home.GetFullPath(artifactsPath), ScratchDirectory = home.GetFullPath(CommonPaths.Scratch), + + // Already absolute, and already the file this run read: hooks are told which one it is instead of + // running their own search and possibly landing on a different answer. + ConfigFile = config.Path, }; } } diff --git a/src/Buildvana.Tool/Services/Hooks/PostReleaseHookArgsFactory.cs b/src/Buildvana.Tool/Services/Hooks/PostReleaseHookArgsFactory.cs index 58d2a0fc..dfe0f9dd 100644 --- a/src/Buildvana.Tool/Services/Hooks/PostReleaseHookArgsFactory.cs +++ b/src/Buildvana.Tool/Services/Hooks/PostReleaseHookArgsFactory.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.Generic; +using Buildvana.Core.Configuration; using Buildvana.Core.HomeDirectory; using Buildvana.Runtime; using CommunityToolkit.Diagnostics; @@ -14,7 +15,9 @@ namespace Buildvana.Tool.Services.Hooks; /// (see ). /// /// The home directory provider. -internal sealed class PostReleaseHookArgsFactory(IHomeDirectoryProvider home) : HookArgsFactory(home) +/// The provider of the configuration file this run reads. +internal sealed class PostReleaseHookArgsFactory(IHomeDirectoryProvider home, BuildvanaConfigProvider config) + : HookArgsFactory(home, config) { /// /// Creates the args for a release/post-release hook run. diff --git a/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs b/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs index ae386ee0..4c4e79fa 100644 --- a/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs +++ b/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs @@ -20,6 +20,29 @@ public async Task Load_NoFile_ReturnsEmptyConfig() } } + [Test] + public async Task LoadFile_NullPath_ReturnsEmptyConfig() + => await Assert.That(BuildvanaConfig.LoadFile(null).Release).IsNull(); + + // Loading a known path skips the search, so the exactly-one rule does not apply to it: a caller holding + // a path (a hook, from its args) reads that file, whatever else sits next to it. + [Test] + public async Task LoadFile_KnownPath_LoadsItRegardlessOfSiblings() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.json", """{ "release": { "branches": ["main"] } }"""); + Write(dir, "buildvana.jsonc", "{}"); + var config = BuildvanaConfig.LoadFile(Path.Combine(dir, "buildvana.json")); + await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + [Test] public async Task Load_ValidJsoncFile_LoadsTypedConfig() { diff --git a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs index 076a64ba..ca7307f7 100644 --- a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs +++ b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs @@ -120,6 +120,42 @@ public async Task Load_UnreadableArgsFile_Throws() } } + // A hook is told which file to read and reads it then and there, so it sees the file as it stands + // rather than a copy taken when the args were written. + [Test] + public async Task LoadConfig_ReadsTheFileNamedInTheArgs() + { + var dir = NewDir(); + try + { + await File.WriteAllTextAsync( + Path.Combine(dir, BuildvanaConfig.JsoncFileName), + """{ "release": { "branches": ["main"] } }""").ConfigureAwait(false); + var config = SampleArgs(dir).LoadConfig(); + await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task LoadConfig_WithoutConfigFile_ReturnsEmptyConfig() + { + var dir = NewDir(); + try + { + var args = SampleArgs(dir); + args = args with { RuntimeInfo = args.RuntimeInfo with { ConfigFile = null } }; + await Assert.That(args.LoadConfig().Release).IsNull(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + private static PostReleaseHookArgs SampleArgs(string home) => new() { RuntimeInfo = new() @@ -129,6 +165,7 @@ public async Task Load_UnreadableArgsFile_Throws() HomeDirectory = home, ArtifactsDirectory = Path.Combine(home, "artifacts", "Release"), ScratchDirectory = Path.Combine(home, WellKnownPaths.ScratchDirectory), + ConfigFile = Path.Combine(home, BuildvanaConfig.JsoncFileName), }, Release = new() { diff --git a/tests/Buildvana.Tool.Tests/HookRunnerTests.cs b/tests/Buildvana.Tool.Tests/HookRunnerTests.cs index f830e0f2..3f830468 100644 --- a/tests/Buildvana.Tool.Tests/HookRunnerTests.cs +++ b/tests/Buildvana.Tool.Tests/HookRunnerTests.cs @@ -195,6 +195,7 @@ private static string ArgsPath(TempHome home) HomeDirectory = home.RootPath, ArtifactsDirectory = Path.Combine(home.RootPath, "artifacts", "Release"), ScratchDirectory = Path.Combine(home.RootPath, WellKnownPaths.ScratchDirectory), + ConfigFile = null, }, Release = new() { diff --git a/tests/Buildvana.Tool.Tests/PostReleaseHookArgsFactoryTests.cs b/tests/Buildvana.Tool.Tests/PostReleaseHookArgsFactoryTests.cs index ed6b0ca6..e9f8813a 100644 --- a/tests/Buildvana.Tool.Tests/PostReleaseHookArgsFactoryTests.cs +++ b/tests/Buildvana.Tool.Tests/PostReleaseHookArgsFactoryTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using Buildvana.Core.Configuration; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Testing; using Buildvana.Runtime; @@ -71,6 +72,31 @@ public async Task Create_ReportsHomeDirectoryWithoutTrailingSeparator() await Assert.That(args.RuntimeInfo.HomeDirectory).IsEqualTo(home.RootPath); } + // A hook rewriting the configuration file must act on the file bv read, so the args name it outright + // rather than leaving the hook to search for it. + [Test] + [Arguments("buildvana.json")] + [Arguments("buildvana.jsonc")] + public async Task Create_ReportsTheConfigurationFileTheRunRead(string fileName) + { + using var home = new TempHome(); + home.WriteFile(fileName, "{}\n"); + + var args = Create(home.Provider); + + await Assert.That(args.RuntimeInfo.ConfigFile).IsEqualTo(Path.Combine(home.RootPath, fileName)); + } + + [Test] + public async Task Create_LeavesConfigFileNull_WhenRepositoryHasNone() + { + using var home = new TempHome(); + + var args = Create(home.Provider); + + await Assert.That(args.RuntimeInfo.ConfigFile).IsNull(); + } + [Test] public async Task Create_SetsOwnVersionAsRuntimeVersion() { @@ -156,7 +182,7 @@ private static PostReleaseHookArgs Create( bool isPublicRelease = false, IReadOnlyDictionary? producedPackages = null, bool dogfooding = false) - => new PostReleaseHookArgsFactory(home).Create( + => new PostReleaseHookArgsFactory(home, new BuildvanaConfigProvider(home)).Create( artifactsPath ?? Path.Combine("artifacts", "Release"), simpleVersion, semVer, From 4fb267e9d23efad099a092f05c885b217969c8bb Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 21:59:15 +0200 Subject: [PATCH 05/13] Stop hardcoding the configuration file name in the post-release hook The hook read buildvana.jsonc by name, relative to its working directory, which is why it crashed when the file moved: the name and the location were its own guesses rather than facts of the run. It now asks BuildvanaConfig.FindFile, and does nothing when the repository has no configuration file. RuntimeInfo.ConfigFile would be the better source, and a comment says so, together with what to replace and why it has to wait: the SDK pins Buildvana.Runtime to its own version, so a hook compiles against the last published release, and that member ships with the next one. Using it now would fail the hook's compilation during the very release that publishes it, aborting the release. Co-Authored-By: Claude Opus 5 (1M context) --- .buildvana/hooks/release/post-release.cs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.buildvana/hooks/release/post-release.cs b/.buildvana/hooks/release/post-release.cs index 3ecc6fab..e9a1f7f2 100644 --- a/.buildvana/hooks/release/post-release.cs +++ b/.buildvana/hooks/release/post-release.cs @@ -7,8 +7,8 @@ using System.Text.RegularExpressions; using Buildvana.Runtime; -// release/post-release hook: keeps the $schema URL in buildvana.jsonc pointing at the release tag -// of the version being released. The guard mirrors the built-in self-reference rewrites: the +// release/post-release hook: keeps the $schema URL in the configuration file pointing at the release +// tag of the version being released. The guard mirrors the built-in self-reference rewrites: the // $schema URL is itself a self-reference, so it moves only when dogfooding moves the rest. var hookArgs = PostReleaseHookArgs.Load(); if (!hookArgs.Dogfooding) @@ -16,8 +16,22 @@ return; } +// AFTER THE NEXT RELEASE: replace these four lines with +// var configFile = hookArgs.RuntimeInfo.ConfigFile; +// keeping the null check. That member names the file bv itself read, which is what a hook rewriting the +// configuration file should act on, and what Hooks.md tells hooks to use instead of searching for one. +// It cannot be used yet: the SDK pins Buildvana.Runtime to its own version, so this hook compiles against +// the last published release, and RuntimeInfo.ConfigFile ships with the next one. Searching is correct in +// the meantime — hooks run from the home directory, and this is the same search bv performs — it just +// answers on its own rather than being told. +var configFile = BuildvanaConfig.FindFile(); +if (configFile is null) +{ + return; +} + // Same expression as SelfVersionService.SchemaUrlRegex in src/Buildvana.Tool, which `bv update` applies to // consumer repositories' configuration files; keep the two copies identical. -var text = File.ReadAllText("buildvana.jsonc"); +var text = File.ReadAllText(configFile); text = Regex.Replace(text, "(Tenacom/Buildvana/)[^/]+(/schemas/)", $"${{1}}{hookArgs.Release.SemVer}$2"); -File.WriteAllText("buildvana.jsonc", text); +File.WriteAllText(configFile, text); From ca5783f359db11eadd9bfdeea687fa0a7ad6b6ca Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 22:18:37 +0200 Subject: [PATCH 06/13] Give the configuration model its own defaults Two settings had a built-in default that lived in whoever read them: bv resolved dotnet.configuration to Release, and the versioning code resolved versioning.assemblyVersionPrecision to Major. A hook asking the same questions of the same configuration got null for both, so the same file described two different builds depending on who read it. Each default moves next to the setting it belongs to, as a constant, with an Effective... accessor over it. The accessor extends the nullable section type, because an absent section says exactly what an absent setting says: with the receiver nullable, config.DotNet.EffectiveConfiguration answers in both cases and no consumer writes a fallback of its own to get wrong. bv, SDK tasks, and hooks now resolve a setting through one definition rather than agreeing to spell the same fallback the same way. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- .../VersioningSettings.cs | 2 +- src/Buildvana.Runtime/DotNetConfig.cs | 6 ++++ .../DotNetConfigExtensions.cs | 30 ++++++++++++++++ src/Buildvana.Runtime/PublicAPI.Unshipped.txt | 10 ++++++ src/Buildvana.Runtime/VersioningConfig.cs | 8 +++++ .../VersioningConfigExtensions.cs | 33 +++++++++++++++++ src/Buildvana.Tool/Services/DotNetSettings.cs | 7 +--- .../DotNetConfigExtensionsTests.cs | 33 +++++++++++++++++ .../VersioningConfigExtensionsTests.cs | 35 +++++++++++++++++++ 10 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/Buildvana.Runtime/DotNetConfigExtensions.cs create mode 100644 src/Buildvana.Runtime/VersioningConfigExtensions.cs create mode 100644 tests/Buildvana.Runtime.Tests/DotNetConfigExtensionsTests.cs create mode 100644 tests/Buildvana.Runtime.Tests/VersioningConfigExtensionsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ee7ce2..07ed73bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ See the Nerdbank.GitVersioning removal entry under _Changes to existing features With a `VERSION` file present, every project built with Buildvana SDK gets `$(Version)`, `$(PackageVersion)`, `$(AssemblyVersion)` (precision-controlled), `$(FileVersion)` (`MAJOR.MINOR.HEIGHT.0`), and `$(InformationalVersion)` computed at build time by a compiled task using LibGit2Sharp — no `git` executable and no external package required — under plain `dotnet build` as well as `MSBuild.exe` and `bv`. The `UseVersioning` property overrides the automatic module activation in both directions. `bv release` now computes versions the same way, in-process. - Versioned C# projects get the generated `ThisAssembly` class by default: when the `Versioning` module is active, `GenerateThisAssemblyClass` defaults to `true`, and the module contributes versioning constants alongside the default assembly-attribute constants: `SimpleVersion`, `SemVer`, `IsPublicRelease`, `IsPrerelease`, and `GitCommitId`. Compared with Nerdbank.GitVersioning's `ThisAssembly`, the `GitCommitDate`/`GitCommitAuthorDate` and `PublicKey`/`PublicKeyToken` constants are not provided, while `AssemblyDescription`, `SimpleVersion`, and `SemVer` are new. - `bv` now runs optional repository-owned hooks: [file-based apps](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/file-based-programs) at well-known paths of the form `.buildvana/hooks//.cs` — `` names the context the event belongs to (currently always the invoking command), `` the moment of execution that triggers the hook — run via `dotnet run` from the home directory when the event occurs, and skipped with an info message when absent. Args are passed through a JSON file at a per-hook well-known path, `.buildvana-temp/hook-args//.json`, (re)written before each hook run and left in place afterwards, so a hook can be re-run by hand against the args of the last run; `.buildvana-temp/` is bv's scratch directory for machine-generated temporary files, recommended for gitignoring and always excluded from bv's own working-tree change detection. `bv clean` clears the hooks' file-based-app build caches and deletes the scratch directory. The first event is `release/post-release`, raised by `bv release` at the moment the post-release commit is assembled: before the built-in self-reference rewrites (when dogfooding is enabled) and before anything is pushed — the hook is a file-based app inside the repository tree, so it builds against the version pins as they stand before the release and reads the version being released from its args. The hook runs whether or not dogfooding is enabled; files it changes join the post-release commit alongside the built-in rewrites, and a non-zero exit code aborts the release before anything is pushed. See [docs/Hooks.md](docs/Hooks.md) for the full contract. -- A new packaged library, `Buildvana.Runtime`, holds the typed model of the Buildvana configuration file and of the run-time information `bv` shares with repository-owned hooks: `BuildvanaConfig` (with `BuildvanaConfig.Load()` and `BuildvanaConfig.LoadFile()`, strict loaders usable from file-based apps, where reflection-based JSON serialization is disabled, plus the well-known configuration file names as constants), `PostReleaseHookArgs` (with `PostReleaseHookArgs.Load()`, structured into a `RuntimeInfo` section — shared by the args of every hook through the `HookArgs` base record, and carrying the running `bv`'s version, the delegating `bv`'s version when the run was delegated, the absolute paths of the run's well-known directories, and the absolute path of the configuration file the run read, so that a hook working on that file acts on the one `bv` read instead of searching for it; `hookArgs.LoadConfig()` loads that same file, so a hook reading settings does not search either — plus a `Release` section and other hook-specific members; every args type also implements the `IHookEvent` interface, naming its hook's context and event as static properties, so that `bv` dispatches a hook from its args type alone and a hook's identity can never be mismatched with its args), and the well-known paths shared by both sides of the hook contract (directory constants and per-hook path helpers). A hook references the package with an unversioned `#:package Buildvana.Runtime` directive; Buildvana SDK pins the package to its own version for every file-based app built in the repository, so `bv`, the SDK, and hooks always agree on the shape of the data. +- A new packaged library, `Buildvana.Runtime`, holds the typed model of the Buildvana configuration file and of the run-time information `bv` shares with repository-owned hooks: `BuildvanaConfig` (with `BuildvanaConfig.Load()` and `BuildvanaConfig.LoadFile()`, strict loaders usable from file-based apps, where reflection-based JSON serialization is disabled, plus the well-known configuration file names as constants), `PostReleaseHookArgs` (with `PostReleaseHookArgs.Load()`, structured into a `RuntimeInfo` section — shared by the args of every hook through the `HookArgs` base record, and carrying the running `bv`'s version, the delegating `bv`'s version when the run was delegated, the absolute paths of the run's well-known directories, and the absolute path of the configuration file the run read, so that a hook working on that file acts on the one `bv` read instead of searching for it; `hookArgs.LoadConfig()` loads that same file, so a hook reading settings does not search either — plus a `Release` section and other hook-specific members; every args type also implements the `IHookEvent` interface, naming its hook's context and event as static properties, so that `bv` dispatches a hook from its args type alone and a hook's identity can never be mismatched with its args), the well-known paths shared by both sides of the hook contract (directory constants and per-hook path helpers), and the built-in default of each setting that has one, as a constant next to the setting plus an `Effective…` accessor that applies it whether the setting or its whole section is absent — so `bv`, SDK tasks, and hooks resolve a setting to the same value instead of each spelling out its own fallback. A hook references the package with an unversioned `#:package Buildvana.Runtime` directive; Buildvana SDK pins the package to its own version for every file-based app built in the repository, so `bv`, the SDK, and hooks always agree on the shape of the data. - Before running any command that uses Buildvana SDK (`restore`, `build`, `test`, `pack`, and `release`), `bv` now verifies that the repository pins the SDK (the `Buildvana.Sdk` entry under `msbuild-sdks` in `global.json`) at its own version: `bv`, `Buildvana.Sdk`, and `Buildvana.Runtime` are released in lockstep, and a version mismatch — a half-updated repository, a newer globally-installed tool against an older pin — would otherwise produce silent behavior drift. A missing `global.json`, section, or entry counts as a mismatch. On mismatch, the command fails with a message naming both versions and the ways to align them. Versions are compared by SemVer precedence, ignoring build metadata. The new global option `--skip-sdk-check` skips the check, for scenarios that require a deliberate mismatch (e.g. bisecting an SDK regression in CI). - `bv` now delegates to the repository's pinned version: whenever the tool manifest (`.config/dotnet-tools.json`) pins `bv`, the pinned version is the one that runs, no matter which `bv` is invoked — like the Angular CLI, where the global `ng` always hands over to the project-local install. The invoked `bv` makes sure the pinned version is installed — probing the SDK's tool resolver cache the same way `dotnet tool run` does, and running `dotnet tool restore` only when needed; a failed restore is reported but does not block the attempt — and hands it the entire original command line (`dotnet tool run bv`) with inherited standard streams, forwarding its exit code. The delegated `bv` runs from the home directory, so a relative path inside forwarded arguments resolves against the home directory rather than the invocation directory; and `--version` answers for the pinned `bv` (pass `--skip-delegation` to ask the invoked binary). When the versions differ, an info line on standard error names the version that runs. A delegating `bv` does not judge the command line beyond the minimal split that finds the subcommand and the global options (only a value-bearing global option with no following value, such as a trailing `-v`, is rejected before delegation, with the same message in every version), and does not read the configuration file — both may be valid for the pinned version and not for the invoked one, and judging them is the pinned version's job. The new `update` subcommand is exempt (see below); the new global option `--skip-delegation` runs the exact binary invoked; and the `BV_DELEGATED` environment variable, set on the delegated child, guarantees that a delegated invocation never delegates again. The variable is removed from the environment of every other child process `bv` spawns, so a `bv` reached through a hook or a build makes its own delegation decision. A `bv` invoked outside a repository, or in a repository whose tool manifest does not pin `bv` (the entry is matched case-insensitively, like the dotnet CLI matches it; an entry with an unusable version is reported and treated as no pin), runs in place as before. - A new `bv update` command updates the repository's entire Buildvana surface to the running `bv`'s version in one operation: the `bv` pin in the tool manifest, via `dotnet tool update` (or `dotnet tool install --create-manifest-if-needed` when there is no entry yet), which also downloads the version; the `Buildvana.Sdk` pin in `global.json`, creating the file and/or the `msbuild-sdks` section if needed and preserving the file's formatting otherwise; and the version segment of the configuration file's `$schema` reference, when it points at the canonical `Tenacom/Buildvana//schemas/` URL. Afterwards, the configuration file is loaded with the new version's model, and any problems are reported as warnings for review. `update` is exempt from delegation — it updates the repository to the `bv` actually invoked ("bring this repository to me"), so the usual upgrade flow is `dotnet tool update -g bv` followed by `bv update`, and `dnx bv@ update` targets any specific version — and it refuses to downgrade a repository whose pins are newer than the running `bv`, unless `--force` is passed. A manifest whose `bv` entry pins an invalid version is beyond the dotnet CLI's reach entirely (the CLI cannot parse such a manifest), so `bv update` fails up front with a message naming the entry to fix. diff --git a/src/Buildvana.Core.Versioning/VersioningSettings.cs b/src/Buildvana.Core.Versioning/VersioningSettings.cs index 4912730c..0f927b83 100644 --- a/src/Buildvana.Core.Versioning/VersioningSettings.cs +++ b/src/Buildvana.Core.Versioning/VersioningSettings.cs @@ -27,7 +27,7 @@ public VersioningSettings(BuildvanaConfig config) Guard.IsNotNull(config); PublicReleaseBranchPatterns = config.Release?.Branches ?? []; PrereleaseTag = config.Versioning?.PrereleaseTag; - AssemblyVersionPrecision = config.Versioning?.AssemblyVersionPrecision ?? AssemblyVersionPrecision.Major; + AssemblyVersionPrecision = config.Versioning.EffectiveAssemblyVersionPrecision; } /// diff --git a/src/Buildvana.Runtime/DotNetConfig.cs b/src/Buildvana.Runtime/DotNetConfig.cs index 52f55689..227fca94 100644 --- a/src/Buildvana.Runtime/DotNetConfig.cs +++ b/src/Buildvana.Runtime/DotNetConfig.cs @@ -12,6 +12,12 @@ namespace Buildvana.Runtime; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record DotNetConfig { + /// + /// The build configuration used when is not set. Read it through + /// EffectiveConfiguration, which applies it whether the setting or the whole section is absent. + /// + public const string DefaultConfiguration = "Release"; + /// /// Gets the default build configuration passed to dotnet. /// diff --git a/src/Buildvana.Runtime/DotNetConfigExtensions.cs b/src/Buildvana.Runtime/DotNetConfigExtensions.cs new file mode 100644 index 00000000..625d204d --- /dev/null +++ b/src/Buildvana.Runtime/DotNetConfigExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Runtime; + +/// +/// Provides extension methods for `DotNetConfig` instances. +/// +/// +/// The receiver is nullable because a configuration file may omit the dotnet section entirely: +/// an absent section and an absent setting mean the same thing, so both resolve to the default here rather +/// than at each call site. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class DotNetConfigExtensions +{ + extension(DotNetConfig? @this) + { + /// + /// Gets the build configuration to pass to dotnet: , + /// or when the configuration file does not set it. + /// + /// + /// The default belongs to the model rather than to any one consumer, so that bv, SDK tasks, + /// and hooks answer this question identically. + /// + public string EffectiveConfiguration => @this?.Configuration ?? DotNetConfig.DefaultConfiguration; + } +} diff --git a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt index 17a79b60..aadb9591 100644 --- a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt +++ b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt @@ -50,6 +50,9 @@ Buildvana.Runtime.DotNetConfig.Restore.get -> Buildvana.Runtime.DotNetInvocation Buildvana.Runtime.DotNetConfig.Restore.init -> void Buildvana.Runtime.DotNetConfig.Test.get -> Buildvana.Runtime.DotNetInvocationConfig? Buildvana.Runtime.DotNetConfig.Test.init -> void +Buildvana.Runtime.DotNetConfigExtensions +Buildvana.Runtime.DotNetConfigExtensions.extension(Buildvana.Runtime.DotNetConfig?) +Buildvana.Runtime.DotNetConfigExtensions.extension(Buildvana.Runtime.DotNetConfig?).EffectiveConfiguration.get -> string! Buildvana.Runtime.DotNetInvocationConfig Buildvana.Runtime.DotNetInvocationConfig.$() -> Buildvana.Runtime.DotNetInvocationConfig! Buildvana.Runtime.DotNetInvocationConfig.Args.get -> System.Collections.Generic.IReadOnlyList? @@ -172,9 +175,14 @@ Buildvana.Runtime.VersioningConfig.Equals(Buildvana.Runtime.VersioningConfig? ot Buildvana.Runtime.VersioningConfig.PrereleaseTag.get -> string? Buildvana.Runtime.VersioningConfig.PrereleaseTag.init -> void Buildvana.Runtime.VersioningConfig.VersioningConfig() -> void +Buildvana.Runtime.VersioningConfigExtensions +Buildvana.Runtime.VersioningConfigExtensions.extension(Buildvana.Runtime.VersioningConfig?) +Buildvana.Runtime.VersioningConfigExtensions.extension(Buildvana.Runtime.VersioningConfig?).EffectiveAssemblyVersionPrecision.get -> Buildvana.Runtime.AssemblyVersionPrecision Buildvana.Runtime.WellKnownPaths abstract Buildvana.Runtime.HookArgs.$() -> Buildvana.Runtime.HookArgs! const Buildvana.Runtime.BuildvanaConfig.JsonFileName = "buildvana.json" -> string! +const Buildvana.Runtime.DotNetConfig.DefaultConfiguration = "Release" -> string! +const Buildvana.Runtime.VersioningConfig.DefaultAssemblyVersionPrecision = Buildvana.Runtime.AssemblyVersionPrecision.Major -> Buildvana.Runtime.AssemblyVersionPrecision const Buildvana.Runtime.BuildvanaConfig.JsoncFileName = "buildvana.jsonc" -> string! const Buildvana.Runtime.PostReleaseHookArgs.Context = "release" -> string! const Buildvana.Runtime.PostReleaseHookArgs.Event = "post-release" -> string! @@ -233,6 +241,7 @@ override sealed Buildvana.Runtime.PostReleaseHookArgs.Equals(Buildvana.Runtime.H static Buildvana.Runtime.BuildvanaConfig.FindFile(string? homeDirectory = null) -> string? static Buildvana.Runtime.BuildvanaConfig.Load(string? homeDirectory = null) -> Buildvana.Runtime.BuildvanaConfig! static Buildvana.Runtime.BuildvanaConfig.LoadFile(string? path) -> Buildvana.Runtime.BuildvanaConfig! +static Buildvana.Runtime.DotNetConfigExtensions.get_EffectiveConfiguration(Buildvana.Runtime.DotNetConfig? this) -> string! static Buildvana.Runtime.BuildvanaConfig.operator !=(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaConfig.operator ==(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaJsonContext.Default.get -> Buildvana.Runtime.BuildvanaJsonContext! @@ -266,6 +275,7 @@ static Buildvana.Runtime.RuntimeInfo.operator !=(Buildvana.Runtime.RuntimeInfo? static Buildvana.Runtime.RuntimeInfo.operator ==(Buildvana.Runtime.RuntimeInfo? left, Buildvana.Runtime.RuntimeInfo? right) -> bool static Buildvana.Runtime.VersioningConfig.operator !=(Buildvana.Runtime.VersioningConfig? left, Buildvana.Runtime.VersioningConfig? right) -> bool static Buildvana.Runtime.VersioningConfig.operator ==(Buildvana.Runtime.VersioningConfig? left, Buildvana.Runtime.VersioningConfig? right) -> bool +static Buildvana.Runtime.VersioningConfigExtensions.get_EffectiveAssemblyVersionPrecision(Buildvana.Runtime.VersioningConfig? this) -> Buildvana.Runtime.AssemblyVersionPrecision static Buildvana.Runtime.WellKnownPaths.GetHookArgsFile(string! context, string! event) -> string! static Buildvana.Runtime.WellKnownPaths.GetHookFile(string! context, string! event) -> string! virtual Buildvana.Runtime.HookArgs.EqualityContract.get -> System.Type! diff --git a/src/Buildvana.Runtime/VersioningConfig.cs b/src/Buildvana.Runtime/VersioningConfig.cs index 190f84b0..e029ac8e 100644 --- a/src/Buildvana.Runtime/VersioningConfig.cs +++ b/src/Buildvana.Runtime/VersioningConfig.cs @@ -12,6 +12,14 @@ namespace Buildvana.Runtime; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record VersioningConfig { + /// + /// The assembly-version precision used when is not set. Read it + /// through EffectiveAssemblyVersionPrecision, which applies it whether the setting or the whole + /// section is absent. + /// + public const AssemblyVersionPrecision DefaultAssemblyVersionPrecision + = Buildvana.Runtime.AssemblyVersionPrecision.Major; + /// Gets the prerelease tag applied to prerelease versions. [Description("Prerelease tag applied to prerelease versions. When omitted, prerelease versions are not allowed.")] public string? PrereleaseTag { get; init; } diff --git a/src/Buildvana.Runtime/VersioningConfigExtensions.cs b/src/Buildvana.Runtime/VersioningConfigExtensions.cs new file mode 100644 index 00000000..98b9f2e8 --- /dev/null +++ b/src/Buildvana.Runtime/VersioningConfigExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Runtime; + +/// +/// Provides extension methods for `VersioningConfig` instances. +/// +/// +/// The receiver is nullable because a configuration file may omit the versioning section entirely: +/// an absent section and an absent setting mean the same thing, so both resolve to the default here rather +/// than at each call site. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class VersioningConfigExtensions +{ + extension(VersioningConfig? @this) + { + /// + /// Gets how much of the computed version goes into the assembly version: + /// , or + /// when the configuration file + /// does not set it. + /// + /// + /// The default belongs to the model rather than to any one consumer, so that bv, SDK tasks, + /// and hooks answer this question identically. + /// + public AssemblyVersionPrecision EffectiveAssemblyVersionPrecision + => @this?.AssemblyVersionPrecision ?? VersioningConfig.DefaultAssemblyVersionPrecision; + } +} diff --git a/src/Buildvana.Tool/Services/DotNetSettings.cs b/src/Buildvana.Tool/Services/DotNetSettings.cs index 84567ef1..aeb1ace2 100644 --- a/src/Buildvana.Tool/Services/DotNetSettings.cs +++ b/src/Buildvana.Tool/Services/DotNetSettings.cs @@ -16,11 +16,6 @@ namespace Buildvana.Tool.Services; /// internal sealed class DotNetSettings { - /// - /// The build configuration used when neither the configuration chain nor the command line specifies one. - /// - public const string DefaultConfiguration = "Release"; - private readonly NuGetConfig? _nuget; /// @@ -30,7 +25,7 @@ internal sealed class DotNetSettings public DotNetSettings(BuildvanaConfig config) { Guard.IsNotNull(config); - Configuration = config.DotNet?.Configuration ?? DefaultConfiguration; + Configuration = config.DotNet.EffectiveConfiguration; Invocations = new DotNetInvocationsSettings(config.DotNet); _nuget = config.NuGet; } diff --git a/tests/Buildvana.Runtime.Tests/DotNetConfigExtensionsTests.cs b/tests/Buildvana.Runtime.Tests/DotNetConfigExtensionsTests.cs new file mode 100644 index 00000000..d852baf3 --- /dev/null +++ b/tests/Buildvana.Runtime.Tests/DotNetConfigExtensionsTests.cs @@ -0,0 +1,33 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Runtime; + +internal sealed class DotNetConfigExtensionsTests +{ + // An absent section and an absent setting are the same statement — "not configured" — and the default + // answers both, so that no consumer has to spell out the fallback and risk spelling it differently. + [Test] + public async Task EffectiveConfiguration_WithoutSection_IsTheDefault() + { + DotNetConfig? config = null; + + await Assert.That(config.EffectiveConfiguration).IsEqualTo(DotNetConfig.DefaultConfiguration); + } + + [Test] + public async Task EffectiveConfiguration_WithoutValue_IsTheDefault() + { + var config = new DotNetConfig(); + + await Assert.That(config.EffectiveConfiguration).IsEqualTo(DotNetConfig.DefaultConfiguration); + } + + [Test] + public async Task EffectiveConfiguration_WithValue_IsThatValue() + { + var config = new DotNetConfig { Configuration = "Debug" }; + + await Assert.That(config.EffectiveConfiguration).IsEqualTo("Debug"); + } +} diff --git a/tests/Buildvana.Runtime.Tests/VersioningConfigExtensionsTests.cs b/tests/Buildvana.Runtime.Tests/VersioningConfigExtensionsTests.cs new file mode 100644 index 00000000..edada011 --- /dev/null +++ b/tests/Buildvana.Runtime.Tests/VersioningConfigExtensionsTests.cs @@ -0,0 +1,35 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Runtime; + +internal sealed class VersioningConfigExtensionsTests +{ + // An absent section and an absent setting are the same statement — "not configured" — and the default + // answers both, so that no consumer has to spell out the fallback and risk spelling it differently. + [Test] + public async Task EffectiveAssemblyVersionPrecision_WithoutSection_IsTheDefault() + { + VersioningConfig? config = null; + + await Assert.That(config.EffectiveAssemblyVersionPrecision) + .IsEqualTo(VersioningConfig.DefaultAssemblyVersionPrecision); + } + + [Test] + public async Task EffectiveAssemblyVersionPrecision_WithoutValue_IsTheDefault() + { + var config = new VersioningConfig(); + + await Assert.That(config.EffectiveAssemblyVersionPrecision) + .IsEqualTo(VersioningConfig.DefaultAssemblyVersionPrecision); + } + + [Test] + public async Task EffectiveAssemblyVersionPrecision_WithValue_IsThatValue() + { + var config = new VersioningConfig { AssemblyVersionPrecision = AssemblyVersionPrecision.Build }; + + await Assert.That(config.EffectiveAssemblyVersionPrecision).IsEqualTo(AssemblyVersionPrecision.Build); + } +} From d7c9acedc23c458bfafaf4ab2b30003ab33d1eea Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:41:34 +0200 Subject: [PATCH 07/13] Point the ConfigFile remarks at LoadConfig The remarks told a settings-reading hook it had no use for the member and sent it to BuildvanaConfig.Load, which searches. LoadConfig, added in the same commit, is LoadFile(RuntimeInfo.ConfigFile), and Hooks.md tells hooks to call it precisely so they do not search, so the member documented itself as irrelevant to its own principal consumer. This is the copy a hook author reads in IntelliSense while deciding, so it now names LoadConfig as the way settings-readers reach the file, and keeps the rest: the member itself is for hooks that work on the file. Co-Authored-By: Claude Opus 5 (1M context) --- src/Buildvana.Runtime/RuntimeInfo.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Buildvana.Runtime/RuntimeInfo.cs b/src/Buildvana.Runtime/RuntimeInfo.cs index 84dc7b0f..4778675c 100644 --- a/src/Buildvana.Runtime/RuntimeInfo.cs +++ b/src/Buildvana.Runtime/RuntimeInfo.cs @@ -46,9 +46,10 @@ public sealed record RuntimeInfo /// or ), or when the repository has none. /// /// - /// A hook that reads settings has no use for this: finds the file - /// on its own. This is for a hook that works on the file itself — rewriting a value, checking it into the - /// post-release commit — and must act on the very file bv read rather than guess which one it was. + /// A hook that reads settings gets there through , which reads the + /// file named here and hands back the typed configuration. This member is for a hook that works on the file + /// itself — rewriting a value, checking it into the post-release commit — and must act on the very file + /// bv read rather than guess which one it was. /// Required of whoever writes the args, so that a run always states which file it read; the value is /// nonetheless when the repository has no configuration file, which a repository /// whose home directory is marked by Git alone legitimately does not. From 852fcaeff7a7a8efbe76b7a4e913a1451ecbc013 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:44:05 +0200 Subject: [PATCH 08/13] Pin the configuration file's one location with tests The tests renamed with this branch all pass with the .buildvana/ probes put back, so nothing failed if the location returned. That matters most for Sdk.props, which mirrors the C# algorithm by discipline alone and where a restored probe is silent. What the removed clause actually did was make a directory a home directory because a .buildvana subdirectory of it held a configuration file, so that is the fixture: .git/HEAD at the root, a configuration file in root/nested/.buildvana/, discovery starting at root/nested. The old rule answered nested, the current one answers root. One test per implementation, plus FindFile returning null for a file left in .buildvana/, which is the probe itself. Each was checked against the old behavior by restoring the probes: one failure per test project, each of them the new test. Co-Authored-By: Claude Opus 5 (1M context) --- .../HomeDirectoryDiscoveryTests.cs | 21 +++++++++++++++++++ .../BuildvanaConfigLoadTests.cs | 17 +++++++++++++++ tests/Buildvana.Sdk.Tests/SdkPropsTests.cs | 13 ++++++++++++ 3 files changed, 51 insertions(+) diff --git a/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs b/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs index f6db4795..8d293d7c 100644 --- a/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs +++ b/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs @@ -66,6 +66,27 @@ public async Task TryDiscover_BuildvanaDirectory_IsNotMarker() } } + // A configuration file marks the directory it sits in, and only that one. This is the rule that used to have + // an exception for .buildvana/, and the exception is what made every hook's own directory a home directory. + [Test] + public async Task TryDiscover_ConfigFileInBuildvanaSubdirectory_DoesNotMarkTheParent() + { + var root = NewDir(); + try + { + WriteFile(root, ".git/HEAD"); + var nested = Path.Combine(root, "nested"); + WriteFile(nested, ".buildvana/buildvana.jsonc"); + var found = HomeDirectoryDiscovery.TryDiscover(nested, out var home); + await Assert.That(found).IsTrue(); + await Assert.That(home).IsEqualTo(WithTrailingSeparator(root)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Test] public async Task TryDiscover_MultipleMarkers_NearestWins() { diff --git a/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs b/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs index 4c4e79fa..6228669e 100644 --- a/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs +++ b/tests/Buildvana.Runtime.Tests/BuildvanaConfigLoadTests.cs @@ -155,6 +155,23 @@ public async Task FindFile_OneFile_ReturnsItsPath() } } + // The .buildvana subdirectory was a candidate location until it turned every hook's directory into a home + // directory of its own. A file left there is not the repository's configuration, and is not found. + [Test] + public async Task FindFile_FileInBuildvanaSubdirectory_ReturnsNull() + { + var dir = NewDir(); + try + { + Write(dir, ".buildvana/buildvana.jsonc", "{}"); + await Assert.That(BuildvanaConfig.FindFile(dir)).IsNull(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + private static string NewDir() { var dir = Path.Combine(Path.GetTempPath(), "bvtest_" + Guid.NewGuid().ToString("N")); diff --git a/tests/Buildvana.Sdk.Tests/SdkPropsTests.cs b/tests/Buildvana.Sdk.Tests/SdkPropsTests.cs index 4ae29a0e..12cf4be2 100644 --- a/tests/Buildvana.Sdk.Tests/SdkPropsTests.cs +++ b/tests/Buildvana.Sdk.Tests/SdkPropsTests.cs @@ -33,6 +33,19 @@ public async Task Evaluate_ProjectUnderBuildvanaDirectory_HomeIsRepositoryRoot() await Assert.That(result.Errors).IsEmpty(); } + // The same rule HomeDirectoryDiscoveryTests pins for the C# implementation: a configuration file marks the + // directory it sits in, and .buildvana/ is not an exception. Nothing but this test checks the two agree. + [Test] + public async Task Evaluate_ConfigFileInBuildvanaSubdirectory_DoesNotMarkTheParent() + { + using var fixture = new SdkPropsFixture(); + fixture.WriteFile(".git/HEAD"); + fixture.WriteFile("nested/.buildvana/buildvana.jsonc"); + var result = fixture.Evaluate("nested"); + await Assert.That(result.HomeDirectory).IsEqualTo(fixture.RepoDirectory + Path.DirectorySeparatorChar); + await Assert.That(result.Errors).IsEmpty(); + } + [Test] public async Task Evaluate_BothVariantsInHomeDirectory_ReportsBVSDK1005() { From b4fd5201546c672a00211204a2480de97010663c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:46:31 +0200 Subject: [PATCH 09/13] Round-trip a run that read no configuration file ConfigFile is required and nullable, so System.Text.Json wants it present in the document, and it is there only because nothing tells the serializer to drop nulls. A JsonIgnore(WhenWritingNull) on the member, or the same default on the context, would make every hook run in a repository without a configuration file fail to load its args, at release time. The round-trip test only ever covered a run that had one, since the sample args always named a file. It now runs both ways, off one optional parameter that also spares LoadConfig_WithoutConfigFile_ReturnsEmptyConfig its two-level with-expression. Checked by attributing the member: one failure, the null case. Co-Authored-By: Claude Opus 5 (1M context) --- .../PostReleaseHookArgsTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs index ca7307f7..34dee89a 100644 --- a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs +++ b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs @@ -22,13 +22,18 @@ public async Task Load_NoArgsFile_Throws() // Serializes the way bv does (same serializer context, resolved by runtime type) and loads the result // back, proving the two sides of the hook contract agree. + // Both configuration-file cases run because ConfigFile is required and nullable: a repository with no + // configuration file round-trips only as long as the serializer writes the null, and a default ignore + // condition on the context would take that away. Here that costs a failing test, not a failing release. [Test] - public async Task Load_RoundTripsWhatBvWrites() + [Arguments(true)] + [Arguments(false)] + public async Task Load_RoundTripsWhatBvWrites(bool withConfigFile) { var dir = NewDir(); try { - var written = SampleArgs(dir); + var written = SampleArgs(dir, withConfigFile); var relativePath = WellKnownPaths.GetHookArgsFile(PostReleaseHookArgs.Context, PostReleaseHookArgs.Event); var path = Path.Combine(dir, relativePath); _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -146,9 +151,7 @@ public async Task LoadConfig_WithoutConfigFile_ReturnsEmptyConfig() var dir = NewDir(); try { - var args = SampleArgs(dir); - args = args with { RuntimeInfo = args.RuntimeInfo with { ConfigFile = null } }; - await Assert.That(args.LoadConfig().Release).IsNull(); + await Assert.That(SampleArgs(dir, withConfigFile: false).LoadConfig().Release).IsNull(); } finally { @@ -156,7 +159,7 @@ public async Task LoadConfig_WithoutConfigFile_ReturnsEmptyConfig() } } - private static PostReleaseHookArgs SampleArgs(string home) => new() + private static PostReleaseHookArgs SampleArgs(string home, bool withConfigFile = true) => new() { RuntimeInfo = new() { @@ -165,7 +168,7 @@ public async Task LoadConfig_WithoutConfigFile_ReturnsEmptyConfig() HomeDirectory = home, ArtifactsDirectory = Path.Combine(home, "artifacts", "Release"), ScratchDirectory = Path.Combine(home, WellKnownPaths.ScratchDirectory), - ConfigFile = Path.Combine(home, BuildvanaConfig.JsoncFileName), + ConfigFile = withConfigFile ? Path.Combine(home, BuildvanaConfig.JsoncFileName) : null, }, Release = new() { From 7faa374bbb6214ec60f11237754dfe6509b1a312 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:47:15 +0200 Subject: [PATCH 10/13] Search the home directory the hook was given The interim search called FindFile with no argument, which resolves against the current directory. That is the home directory, because bv runs hooks from there, but it makes the hook depend on process state to reach a fact its own args already carry. Passing RuntimeInfo.HomeDirectory says the same thing without the assumption, and leaves one expression to replace once RuntimeInfo.ConfigFile is published, which is what the comment above it promises. Co-Authored-By: Claude Opus 5 (1M context) --- .buildvana/hooks/release/post-release.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.buildvana/hooks/release/post-release.cs b/.buildvana/hooks/release/post-release.cs index e9a1f7f2..c50e08bb 100644 --- a/.buildvana/hooks/release/post-release.cs +++ b/.buildvana/hooks/release/post-release.cs @@ -16,15 +16,15 @@ return; } -// AFTER THE NEXT RELEASE: replace these four lines with +// AFTER THE NEXT RELEASE: replace the search below with // var configFile = hookArgs.RuntimeInfo.ConfigFile; // keeping the null check. That member names the file bv itself read, which is what a hook rewriting the // configuration file should act on, and what Hooks.md tells hooks to use instead of searching for one. // It cannot be used yet: the SDK pins Buildvana.Runtime to its own version, so this hook compiles against // the last published release, and RuntimeInfo.ConfigFile ships with the next one. Searching is correct in -// the meantime — hooks run from the home directory, and this is the same search bv performs — it just +// the meantime — it is the same search bv performs, over the directory bv reports as home — the hook just // answers on its own rather than being told. -var configFile = BuildvanaConfig.FindFile(); +var configFile = BuildvanaConfig.FindFile(hookArgs.RuntimeInfo.HomeDirectory); if (configFile is null) { return; From baa2528ac9c178f0030f540b06bcd7fc7ab828da Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:48:57 +0200 Subject: [PATCH 11/13] Sort the new public API entries The four entries added by this branch landed where they were written rather than where they belong: two constants between BuildvanaConfig's own two, and an extension accessor between LoadFile and the equality operators. The file is read as a diff at every API review, and it is sorted everywhere else. The whole file now matches its sorted order line for line. Co-Authored-By: Claude Opus 5 (1M context) --- src/Buildvana.Runtime/PublicAPI.Unshipped.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt index aadb9591..7f8344d0 100644 --- a/src/Buildvana.Runtime/PublicAPI.Unshipped.txt +++ b/src/Buildvana.Runtime/PublicAPI.Unshipped.txt @@ -181,11 +181,11 @@ Buildvana.Runtime.VersioningConfigExtensions.extension(Buildvana.Runtime.Version Buildvana.Runtime.WellKnownPaths abstract Buildvana.Runtime.HookArgs.$() -> Buildvana.Runtime.HookArgs! const Buildvana.Runtime.BuildvanaConfig.JsonFileName = "buildvana.json" -> string! -const Buildvana.Runtime.DotNetConfig.DefaultConfiguration = "Release" -> string! -const Buildvana.Runtime.VersioningConfig.DefaultAssemblyVersionPrecision = Buildvana.Runtime.AssemblyVersionPrecision.Major -> Buildvana.Runtime.AssemblyVersionPrecision const Buildvana.Runtime.BuildvanaConfig.JsoncFileName = "buildvana.jsonc" -> string! +const Buildvana.Runtime.DotNetConfig.DefaultConfiguration = "Release" -> string! const Buildvana.Runtime.PostReleaseHookArgs.Context = "release" -> string! const Buildvana.Runtime.PostReleaseHookArgs.Event = "post-release" -> string! +const Buildvana.Runtime.VersioningConfig.DefaultAssemblyVersionPrecision = Buildvana.Runtime.AssemblyVersionPrecision.Major -> Buildvana.Runtime.AssemblyVersionPrecision const Buildvana.Runtime.WellKnownPaths.BuildvanaDirectory = ".buildvana" -> string! const Buildvana.Runtime.WellKnownPaths.HookArgsDirectory = ".buildvana-temp/hook-args" -> string! const Buildvana.Runtime.WellKnownPaths.HooksDirectory = ".buildvana/hooks" -> string! @@ -241,12 +241,12 @@ override sealed Buildvana.Runtime.PostReleaseHookArgs.Equals(Buildvana.Runtime.H static Buildvana.Runtime.BuildvanaConfig.FindFile(string? homeDirectory = null) -> string? static Buildvana.Runtime.BuildvanaConfig.Load(string? homeDirectory = null) -> Buildvana.Runtime.BuildvanaConfig! static Buildvana.Runtime.BuildvanaConfig.LoadFile(string? path) -> Buildvana.Runtime.BuildvanaConfig! -static Buildvana.Runtime.DotNetConfigExtensions.get_EffectiveConfiguration(Buildvana.Runtime.DotNetConfig? this) -> string! static Buildvana.Runtime.BuildvanaConfig.operator !=(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaConfig.operator ==(Buildvana.Runtime.BuildvanaConfig? left, Buildvana.Runtime.BuildvanaConfig? right) -> bool static Buildvana.Runtime.BuildvanaJsonContext.Default.get -> Buildvana.Runtime.BuildvanaJsonContext! static Buildvana.Runtime.DotNetConfig.operator !=(Buildvana.Runtime.DotNetConfig? left, Buildvana.Runtime.DotNetConfig? right) -> bool static Buildvana.Runtime.DotNetConfig.operator ==(Buildvana.Runtime.DotNetConfig? left, Buildvana.Runtime.DotNetConfig? right) -> bool +static Buildvana.Runtime.DotNetConfigExtensions.get_EffectiveConfiguration(Buildvana.Runtime.DotNetConfig? this) -> string! static Buildvana.Runtime.DotNetInvocationConfig.operator !=(Buildvana.Runtime.DotNetInvocationConfig? left, Buildvana.Runtime.DotNetInvocationConfig? right) -> bool static Buildvana.Runtime.DotNetInvocationConfig.operator ==(Buildvana.Runtime.DotNetInvocationConfig? left, Buildvana.Runtime.DotNetInvocationConfig? right) -> bool static Buildvana.Runtime.GitConfig.operator !=(Buildvana.Runtime.GitConfig? left, Buildvana.Runtime.GitConfig? right) -> bool From 19e00d167fd50cc87eb6e5fd952055309a6e5a32 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:49:38 +0200 Subject: [PATCH 12/13] Name the default instead of spelling it out Moving the defaults into the configuration model left the settings classes documenting them by value: VersioningSettings named AssemblyVersionPrecision .Major, DotNetSettings said "Release". Those are the last copies that can drift from the constants, which is the thing that commit set out to remove. Both now point at the constant, so a changed default reads correctly everywhere without anyone remembering to look. Co-Authored-By: Claude Opus 5 (1M context) --- src/Buildvana.Core.Versioning/VersioningSettings.cs | 2 +- src/Buildvana.Tool/Services/DotNetSettings.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Buildvana.Core.Versioning/VersioningSettings.cs b/src/Buildvana.Core.Versioning/VersioningSettings.cs index 0f927b83..92aaee38 100644 --- a/src/Buildvana.Core.Versioning/VersioningSettings.cs +++ b/src/Buildvana.Core.Versioning/VersioningSettings.cs @@ -44,7 +44,7 @@ public VersioningSettings(BuildvanaConfig config) /// /// Gets the assembly-version precision (versioning.assemblyVersionPrecision, or - /// when unset). + /// when unset). /// public AssemblyVersionPrecision AssemblyVersionPrecision { get; } diff --git a/src/Buildvana.Tool/Services/DotNetSettings.cs b/src/Buildvana.Tool/Services/DotNetSettings.cs index aeb1ace2..dff9d84a 100644 --- a/src/Buildvana.Tool/Services/DotNetSettings.cs +++ b/src/Buildvana.Tool/Services/DotNetSettings.cs @@ -30,7 +30,10 @@ public DotNetSettings(BuildvanaConfig config) _nuget = config.NuGet; } - /// Gets the default build configuration (dotnet.configuration, or "Release"). + /// + /// Gets the default build configuration (dotnet.configuration, or + /// when unset). + /// public string Configuration { get; } /// Gets the per-command dotnet invocation settings (the dotnet section). From ca00901c7bd0ddbf39250f2b6d0666354f6fa481 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 14 Aug 2026 23:49:58 +0200 Subject: [PATCH 13/13] Name the C# member in C# prose The paragraph on rewriting the configuration file introduced the member in its JSON spelling, then showed C# using it. The table above it, and the code block below it, both say RuntimeInfo.ConfigFile; the camelCase form belongs where the document describes the args file's own syntax. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Hooks.md b/docs/Hooks.md index bebd7e35..67aaa254 100644 --- a/docs/Hooks.md +++ b/docs/Hooks.md @@ -106,7 +106,7 @@ Which file to read comes from the args, so a hook never searches for one; what i `BuildvanaConfig.Load()`, which searches a directory for a configuration file, remains available for code that has no hook args to hand. -A hook that works on the configuration file _itself_ — rewriting a value in it, say — needs the path rather than the settings, and must act on the file `bv` actually read. That path is in the args, as `runtimeInfo.configFile` (`null` when the repository has no configuration file); do not hardcode a file name, and do not search for one: +A hook that works on the configuration file _itself_ — rewriting a value in it, say — needs the path rather than the settings, and must act on the file `bv` actually read. That path is in the args, as `RuntimeInfo.ConfigFile` (`null` when the repository has no configuration file); do not hardcode a file name, and do not search for one: ```csharp var configFile = hookArgs.RuntimeInfo.ConfigFile;