diff --git a/Buildvana.slnx b/Buildvana.slnx index 1109e4fe..21087ba9 100644 --- a/Buildvana.slnx +++ b/Buildvana.slnx @@ -44,6 +44,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7d32c8..129c2a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - the GitHub token (`github.tokenEnv`, default `GITHUB_TOKEN`): names the environment variable that holds the token used for release operations. - `buildvana.json` accepts a `git.identity` (`{ name, email }`) section describing the author/committer for automated commits. It is validated and exposed, but not yet wired to release commits. - The `ThisAssemblyClass` SDK module, removed along with code generation tasks after v1.0.0-alpha.20, has been reintroduced. Setting the `GenerateThisAssemblyClass` property to `true` (default: `false`) in a C# project generates a `ThisAssembly` static class containing constants defined via `ThisAssemblyConstant` items, using the syntax documented in [docs/ConstantsSyntax.md](docs/ConstantsSyntax.md). A set of default constants (assembly version, company, product, etc.) is defined unless the `EnableDefaultThisAssemblyConstants` property is set to `false`; the class name and namespace can be customized via the `ThisAssemblyClassName` and `ThisAssemblyClassNamespace` properties. Unlike its previous incarnation, the feature is implemented as a Roslyn incremental source generator, and supports C# projects only: setting `GenerateThisAssemblyClass` to `true` in a project in any other language raises warning BVSDK2300. +- Buildvana now computes project versions natively, replacing Nerdbank.GitVersioning end-to-end (see the corresponding breaking change below). The single source of truth for the version is a plain-text `VERSION` file at the repository root, holding a `MAJOR.MINOR[-[tag]]` specification; the patch number is the Git height of the version line, i.e. the number of commits since the last change of `MAJOR.MINOR`, computed with the same rules as Nerdbank.GitVersioning (counting from 1 at the bump commit, longest path across merges; prerelease-only edits to `VERSION` do not reset the height). Versioning policy lives in `buildvana.json`: + - `release.branches`: regular expressions (matched against the short branch name) identifying branches that produce public releases. On other branches, and in detached-HEAD state, the build is a non-public release, and the informational version carries a short commit ID (`1.2.3-preview.g0123456789` on prereleases, `1.2.3-g0123456789` on stable versions); + - `versioning.prereleaseTag`: the effective prerelease tag (e.g. `preview`), required when `VERSION` marks a prerelease line (the tag text in `VERSION` itself is informational only); + - `versioning.assemblyVersionPrecision` (`major` | `minor` | `build`, default `major`): how much of the computed version goes into `AssemblyVersion`. + + 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. ### Changes to existing features @@ -69,6 +76,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING CHANGE**: The `--main-branch` global option has been removed, along with `bv`'s main-branch discovery. Documentation generation on release is now gated by `release.generateDocsFrom` in `buildvana.json`: a list of regular expressions matched against the current short branch name (default `["^main$", "^master$"]`, reproducing the previous main/master discovery). The human-curated changelog permalink in generated release notes now points at the release branch itself rather than the discovered main branch. - **BREAKING CHANGE**: The `--unstable-changelog` and `--require-changelog` options of `bv release` have been removed with no CLI replacement; changelog policy is repository-stable, not per-invocation. Configure it in `buildvana.json` instead: `release.changelogUpdates` (`none` | `stable` | `all`, default `stable`) selects which releases update the changelog, and `release.emptyChangelog` provides substitute text for an empty "Unreleased changes" section (when unset, an empty section fails the release, matching the previous `--require-changelog` default of `true`). - `bv`'s build commands (`clean`, `restore`, `build`, `test`, `pack`) and `release` now observe cancellation. Pressing Ctrl-C (or a host cancelling the operation) stops the pipeline promptly: it stops launching further steps and terminates the running `dotnet` child process instead of waiting for it to finish, then `bv` exits with code 130. Partial build output may be left behind on cancellation; `bv clean` recovers. +- **BREAKING CHANGE**: Nerdbank.GitVersioning has been removed from Buildvana SDK and `bv`. Versions are now computed natively from a `VERSION` file and `buildvana.json` keys (see _New features_ above): the `NerdbankGitVersioning` SDK module is gone, the `Nerdbank.GitVersioning` package is no longer injected into projects, `version.json` is no longer read, and `bv` no longer invokes the `nbgv` CLI. The `GetBuildVersion` target name is retained (as a real target or a stub) for targets that depend on it. To migrate a repository: + - create a `VERSION` file at the repository root holding the `version` value from `version.json` (e.g. `2.0-preview`); Git heights remain comparable across the migration, so the computed version does not regress within a `MAJOR.MINOR` line; + - move `publicReleaseRefSpec` to `release.branches` in `buildvana.json`, converting refspec patterns to short branch-name regular expressions (e.g. `^refs/heads/main$` → `^main$`); + - move `release.firstUnstableTag` to `versioning.prereleaseTag`, and `assemblyVersion.precision` to `versioning.assemblyVersionPrecision`; + - delete `version.json` and remove `nbgv` from your tool manifest, if present. +- **BREAKING CHANGE**: The `UseNerdbankGitVersioning` property has been renamed to `UseVersioning`. The old name still works as an alias (when `UseVersioning` itself is not set), but raises warning BVSDK2001 and will be removed in a future version. +- **BREAKING CHANGE**: `version.json` features without a native equivalent are dropped: `pathFilters` is not supported (the version height is always computed from the whole history of the version line), nested `version.json` files are not supported (`VERSION` lives at the repository root only), and the NuGet package version scheme is fixed at SemVer 2.0. ### Bugs fixed in this release diff --git a/README.md b/README.md index 6b0bb143..6405d64a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ However, Buildvana SDK has already been used successfully in production, for bot ### Benefits - Helps you keep your project files clean and concise - even better than "plain" MSBuild SDKs -- Single source of truth for assembly versions (via [`Nerdbank.GitVersioning`(https://github.com/dotnet/Nerdbank.GitVersioning)]) +- Single source of truth for assembly versions (a plain-text `VERSION` file, with the patch number computed from Git height) - Single source of truth for package licenses and copyright notices - More auto-generated assembly information (`ClsCompliant`, `COMVisible`) - Automatic configuration of commonly-used code analyzers diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..6fc53064 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0-preview diff --git a/buildvana.json b/buildvana.json new file mode 100644 index 00000000..e6f12306 --- /dev/null +++ b/buildvana.json @@ -0,0 +1,9 @@ +{ + "release": { + "branches": ["^main$", "^v\\d+\\.\\d+$"] + }, + "versioning": { + "prereleaseTag": "preview", + "assemblyVersionPrecision": "major" + } +} diff --git a/docs/DirectoryStructure.md b/docs/DirectoryStructure.md index d2ebef87..bb106520 100644 --- a/docs/DirectoryStructure.md +++ b/docs/DirectoryStructure.md @@ -268,4 +268,12 @@ TODO ## `VERSION` -TODO +The single source of truth for the version of your product: a plain-text file, in the home directory, holding a single `MAJOR.MINOR[-[tag]]` version specification, for example: + +```text +2.0-preview +``` + +The presence of `-` after the minor version marks a prerelease line; the tag text after it is optional and informational (the effective prerelease tag comes from the `versioning.prereleaseTag` key of `buildvana.json`). The patch number is not stored in the file: it is the Git height of the version line, i.e. the number of commits since the last change of `MAJOR.MINOR`. + +When a `VERSION` file is present, the Buildvana SDK computes `Version`, `AssemblyVersion`, `FileVersion`, and `InformationalVersion` for all projects in the repository; `bv` uses the same computation for releases and rewrites the file when advancing the version. diff --git a/docs/SdkDiagnostics.md b/docs/SdkDiagnostics.md index c746df99..da07ec70 100644 --- a/docs/SdkDiagnostics.md +++ b/docs/SdkDiagnostics.md @@ -16,7 +16,7 @@ - [StandardAnalyzers module (1700-1799)](#standardanalyzers-module-1700-1799) - [XmlDocumentation module (1800-1899)](#xmldocumentation-module-1800-1899) - [AlternatePack module (1900-1999)](#alternatepack-module-1900-1999) -- [NerdbankGitVersioning module (2000-2099)](#nerdbankgitversioning-module-2000-2099) +- [Versioning module (2000-2099)](#versioning-module-2000-2099) - [ReleaseAssetList module (2100-2199)](#releaseassetlist-module-2100-2199) - [Wine module (2200-2299)](#wine-module-2200-2299) - [ThisAssemblyClass module (2300-2399)](#thisassemblyclass-module-2300-2399) @@ -109,11 +109,12 @@ This module has no associated diagnostics. | BVSDK1901 | Error | InnoSetup script '...' referenced by '...' does not exist. | An `InnoSetup` item's `Script` metadata refers to a non-existing file. | | BVSDK1902 | Error | InnoSetup item '...' refers to non-existent PublishFolder '...'. | An `InnoSetup` item has a `SourcePublishFolder` metadata, but no `PublishFolder` item exists with the specified name. | -## NerdbankGitVersioning module (2000-2099) +## Versioning module (2000-2099) -| Code | Severity | Message | Description | -| --------- | :------: | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| BVSDK2000 | Error | Version specification JSON file not found. | A `version.json` or `.version.json` file for the project was not found within the repository root. | +| Code | Severity | Message | Description | +| --------- | :------: | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| BVSDK2000 | Error | Version file (VERSION) not found in home directory. | The `UseVersioning` property was explicitly set to `true`, but no `VERSION` file was found in the home directory. | +| BVSDK2001 | Warning | The UseNerdbankGitVersioning property is deprecated; use UseVersioning instead. | The project sets the `UseNerdbankGitVersioning` property, which is a deprecated alias for `UseVersioning` kept for compatibility with older projects. | ## ReleaseAssetList module (2100-2199) diff --git a/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs b/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs index ac2605ed..973ffd31 100644 --- a/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs +++ b/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs @@ -3,6 +3,9 @@ namespace Buildvana.Core.JsonSchema; +// ReSharper disable once ClassCannotBeInstantiated -- false positive: ReSharper analyzes this partial part +// in isolation, missing the instantiation in JsonSourceMap.cs (the class has a private constructor and is +// created by its static Build method). partial class JsonSourceMap { // Tracks one open container while walking the document, so a value's pointer can be built from its parent. diff --git a/src/Buildvana.Core.Testing/Buildvana.Core.Testing.csproj b/src/Buildvana.Core.Testing/Buildvana.Core.Testing.csproj new file mode 100644 index 00000000..d5b03336 --- /dev/null +++ b/src/Buildvana.Core.Testing/Buildvana.Core.Testing.csproj @@ -0,0 +1,17 @@ + + + + Buildvana testing helpers + Shared test harnesses for Buildvana test projects, e.g. temporary Git repositories and home directories. + $(StandardTfm) + + + + + + + + + + + diff --git a/tests/Buildvana.Core.Versioning.Tests/TempGitRepo.cs b/src/Buildvana.Core.Testing/TempGitRepo.cs similarity index 55% rename from tests/Buildvana.Core.Versioning.Tests/TempGitRepo.cs rename to src/Buildvana.Core.Testing/TempGitRepo.cs index 509c07e4..084a8398 100644 --- a/tests/Buildvana.Core.Versioning.Tests/TempGitRepo.cs +++ b/src/Buildvana.Core.Testing/TempGitRepo.cs @@ -1,15 +1,26 @@ // 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.IO; using System.Text; using LibGit2Sharp; -internal sealed class TempGitRepo : IDisposable +namespace Buildvana.Core.Testing; + +/// +/// A disposable real Git repository in a temporary directory, for tests exercising Git-dependent code. +/// +public sealed class TempGitRepo : IDisposable { private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly Repository _repository; + /// + /// Initializes a new instance of the class, creating and initializing + /// a Git repository in a newly-created temporary directory. + /// public TempGitRepo() { RootPath = Directory.CreateTempSubdirectory("bv-test-repo-").FullName; @@ -17,15 +28,34 @@ public TempGitRepo() _repository = new Repository(RootPath); } + /// + /// Gets the full path of the repository's root directory. + /// public string RootPath { get; } + /// + /// Gets the friendly name of the current branch. + /// public string CurrentBranchName => _repository.Head.FriendlyName; + /// + /// Gets the SHA of the current HEAD commit. + /// public string HeadSha => _repository.Head.Tip.Sha; + /// + /// Writes a file in the repository's root directory. + /// + /// The name of the file, relative to the root directory. + /// The content of the file. + /// The file encoding; UTF-8 without BOM if unspecified. public void WriteFile(string name, string content, Encoding? encoding = null) => File.WriteAllText(Path.Combine(RootPath, name), content, encoding ?? Utf8NoBom); + /// + /// Stages all changes and creates a commit, which may be empty. + /// + /// The commit message. public void CommitAll(string message = "Test commit") { Commands.Stage(_repository, "*"); @@ -33,16 +63,31 @@ public void CommitAll(string message = "Test commit") _ = _repository.Commit(message, signature, signature, new CommitOptions { AllowEmptyCommit = true }); } + /// + /// Creates a new branch at the current HEAD commit and checks it out. + /// + /// The name of the new branch. public void CheckoutNewBranch(string name) { var branch = _repository.CreateBranch(name); _ = Commands.Checkout(_repository, branch); } + /// + /// Checks out an existing branch. + /// + /// The name of the branch. public void Checkout(string name) => _ = Commands.Checkout(_repository, _repository.Branches[name]); + /// + /// Checks out the current HEAD commit directly, detaching HEAD from any branch. + /// public void CheckoutDetached() => _ = Commands.Checkout(_repository, _repository.Head.Tip); + /// + /// Merges a branch into the current branch, always creating a merge commit. + /// + /// The name of the branch to merge. public void Merge(string branchName) { var signature = new Signature("Test", "test@example.com", DateTimeOffset.Now); @@ -50,6 +95,7 @@ public void Merge(string branchName) _ = _repository.Merge(_repository.Branches[branchName], signature, options); } + /// public void Dispose() { _repository.Dispose(); diff --git a/src/Buildvana.Core.Testing/TempHome.cs b/src/Buildvana.Core.Testing/TempHome.cs new file mode 100644 index 00000000..79218d6a --- /dev/null +++ b/src/Buildvana.Core.Testing/TempHome.cs @@ -0,0 +1,44 @@ +// 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.IO; +using Buildvana.Core.HomeDirectory; + +namespace Buildvana.Core.Testing; + +/// +/// A disposable temporary home directory (with no Git repository), for tests exercising file-based code +/// that resolves paths against a home directory. +/// +public sealed class TempHome : IDisposable +{ + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("bv-test-home-"); + + /// + /// Gets the full path of the home directory. + /// + public string RootPath => _directory.FullName; + + /// + /// Gets a home directory provider resolving to . + /// + public FixedHomeDirectoryProvider Provider => new(RootPath); + + /// + /// Writes a file in the home directory. + /// + /// The name of the file, relative to the home directory. + /// The content of the file. + public void WriteFile(string name, string content) => File.WriteAllText(Path.Combine(RootPath, name), content); + + /// + /// Reads a file in the home directory. + /// + /// The name of the file, relative to the home directory. + /// The content of the file. + public string ReadFile(string name) => File.ReadAllText(Path.Combine(RootPath, name)); + + /// + public void Dispose() => _directory.Delete(recursive: true); +} diff --git a/src/Buildvana.Core.Versioning/GitHeightCalculator-nativeLibrary.cs b/src/Buildvana.Core.Versioning/GitHeightCalculator-nativeLibrary.cs new file mode 100644 index 00000000..b09ec6ad --- /dev/null +++ b/src/Buildvana.Core.Versioning/GitHeightCalculator-nativeLibrary.cs @@ -0,0 +1,40 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; +using System.Runtime.InteropServices; +using LibGit2Sharp; + +namespace Buildvana.Core.Versioning; + +partial class GitHeightCalculator +{ + // LibGit2Sharp normally locates its native library through the host's dependency context (deps.json). + // MSBuild task hosts load task assemblies without one, so when the conventional runtimes/{rid}/native + // directory exists next to the managed assembly, point LibGit2Sharp at it explicitly. + // This must happen before the first native call in the process, hence the static constructor: it is + // guaranteed to run before any member of the library's only Git touchpoint is used. + static GitHeightCalculator() + { + var baseDirectory = Path.GetDirectoryName(typeof(Repository).Assembly.Location); + if (string.IsNullOrEmpty(baseDirectory)) + { + return; + } + + var nativeDirectory = Path.Combine(baseDirectory, "runtimes", RuntimeInformation.RuntimeIdentifier, "native"); + if (!Directory.Exists(nativeDirectory)) + { + return; + } + + try + { + GlobalSettings.NativeLibraryPath = nativeDirectory; + } + catch (LibGit2SharpException) + { + // The native library is already loaded, so resolution has evidently succeeded some other way. + } + } +} diff --git a/src/Buildvana.Core.Versioning/GitHeightCalculator.cs b/src/Buildvana.Core.Versioning/GitHeightCalculator.cs index d2fb2d33..1389503d 100644 --- a/src/Buildvana.Core.Versioning/GitHeightCalculator.cs +++ b/src/Buildvana.Core.Versioning/GitHeightCalculator.cs @@ -26,7 +26,7 @@ namespace Buildvana.Core.Versioning; /// This class is the library's only Git touchpoint; its public surface deliberately exposes no /// LibGit2Sharp types. /// -public sealed class GitHeightCalculator +public sealed partial class GitHeightCalculator { private readonly string _versionFileName; @@ -40,6 +40,28 @@ public GitHeightCalculator(string versionFileName) _versionFileName = versionFileName; } + /// + /// Gets an opaque token describing the repository state that depends on: + /// the current HEAD reference and the commit it points to. For a given version file name and + /// version specification, equal tokens guarantee equal results, because the + /// commit graph reachable from a commit is immutable. + /// + /// The directory of the Git repository. + /// A state token, or if there is no Git repository at + /// . + public static string? TryGetRepositoryStateToken(string repositoryDirectory) + { + Guard.IsNotNullOrEmpty(repositoryDirectory); + if (!Repository.IsValid(repositoryDirectory)) + { + return null; + } + + using var repository = new Repository(repositoryDirectory); + var head = repository.Head; + return FormattableString.Invariant($"{head.CanonicalName}\0{head.Tip?.Sha}"); + } + /// /// Calculates the Git height of the version line identified by in the /// repository at , along with the repository facts needed to diff --git a/src/Buildvana.Core.Versioning/VersionFile.cs b/src/Buildvana.Core.Versioning/VersionFile.cs new file mode 100644 index 00000000..70ebbb30 --- /dev/null +++ b/src/Buildvana.Core.Versioning/VersionFile.cs @@ -0,0 +1,127 @@ +// 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.IO; +using System.Security; +using Buildvana.Core.HomeDirectory; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Core.Versioning; + +/// +/// Represents the repository's version file, for reading the version specification and applying +/// version advances. +/// +/// +/// The version file (, in the home directory) is plain text holding a single +/// MAJOR.MINOR[-[tag]] version specification; see the Parse method in +/// for the accepted format. +/// +public sealed class VersionFile +{ + /// + /// The name of the version file, relative to the home directory. + /// + public const string FileName = "VERSION"; + + private VersionFile(string absolutePath, VersionSpec spec) + { + Path = absolutePath; + Spec = spec; + } + + /// + /// Gets the absolute path of the version file. + /// + public string Path { get; } + + /// + /// Gets the version specification read from the version file, as possibly modified by + /// . + /// + public VersionSpec Spec { get; private set; } + + /// + /// Constructs a instance by loading the repository's version file. + /// + /// The home directory provider used to locate the version file. + /// A newly-created , representing the loaded data. + /// The version file is absent, cannot be read, or does not + /// contain a valid version specification. + public static VersionFile Load(IHomeDirectoryProvider home) + { + Guard.IsNotNull(home); + var path = System.IO.Path.Combine(home.HomeDirectory, FileName); + return new(path, ReadSpec(path)); + } + + /// + /// Applies a version specification change to this instance. + /// + /// A constant representing the kind of change to apply. + /// If the property actually changed as a result of + /// , ; otherwise, . + /// + /// This method does not save the modified version file; call for that. + /// + public bool ApplyChange(VersionSpecChange change) + { + (Spec, var changed) = Spec.ApplyChange(change); + return changed; + } + + /// + /// Saves the version file, possibly with a modified , back to the repository. + /// + /// The informational tag text to write after the prerelease marker, + /// usually the configured . It is only written when + /// is a prerelease and the tag fits the version file grammar; otherwise a bare + /// - marks the prerelease. + /// The version file cannot be written. + public void Save(string? prereleaseTag = null) + { + var text = Format(Spec, prereleaseTag); + try + { + File.WriteAllText(Path, text); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or SecurityException) + { + throw new BuildFailedException($"Could not write to {Path}: {e.Message}", e); + } + } + + private static VersionSpec ReadSpec(string path) + { + BuildFailedException.ThrowIfNot(File.Exists(path), $"Version file {path} not found."); + string text; + try + { + text = File.ReadAllText(path); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or SecurityException) + { + throw new BuildFailedException($"Could not read from {path}: {e.Message}", e); + } + + text = text.Trim(); + BuildFailedException.ThrowIfNot( + VersionSpec.TryParse(text, out var spec), + $"{path} contains an invalid version specification '{text}'."); + return spec; + } + + private static string Format(VersionSpec spec, string? prereleaseTag) + { + var stable = FormattableString.Invariant($"{spec.Major}.{spec.Minor}"); + if (!spec.Prerelease) + { + return stable + "\n"; + } + + var tagged = stable + "-" + prereleaseTag; + var tagFits = prereleaseTag is not null && VersionSpec.TryParse(tagged, out _); + return (tagFits ? tagged : stable + "-") + "\n"; + } +} diff --git a/src/Buildvana.Core.Versioning/VersionSpecExtensions-private.cs b/src/Buildvana.Core.Versioning/VersionSpecExtensions-private.cs index 04ce94ce..34469d15 100644 --- a/src/Buildvana.Core.Versioning/VersionSpecExtensions-private.cs +++ b/src/Buildvana.Core.Versioning/VersionSpecExtensions-private.cs @@ -5,6 +5,7 @@ namespace Buildvana.Core.Versioning; +#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 partial class VersionSpecExtensions { private static readonly Regex VersionSpecRegex = GetVersionSpecRegex(); diff --git a/src/Buildvana.Core.Versioning/VersionSpecExtensions.cs b/src/Buildvana.Core.Versioning/VersionSpecExtensions.cs index 7090e9e2..9e8a763b 100644 --- a/src/Buildvana.Core.Versioning/VersionSpecExtensions.cs +++ b/src/Buildvana.Core.Versioning/VersionSpecExtensions.cs @@ -75,4 +75,53 @@ public static bool TryParse([NotNullWhen(true)] string? str, [MaybeNullWhen(fals return true; } } + + extension(VersionSpec @this) + { + /// + /// Gets a that represents the same version as this instance and is not + /// a prerelease. + /// + /// This instance if it is not a prerelease; otherwise, a newly-created + /// . + public VersionSpec Stable() => @this.Prerelease ? @this with { Prerelease = false } : @this; + + /// + /// Gets a that represents the same version as this instance and is + /// a prerelease. + /// + /// This instance if it is a prerelease; otherwise, a newly-created + /// . + public VersionSpec Unstable() => @this.Prerelease ? @this : @this with { Prerelease = true }; + + /// + /// Gets a that represents the next minor version with respect to this + /// instance. The new version line starts as a prerelease. + /// + /// A newly-created . + public VersionSpec NextMinor() => new(@this.Major, @this.Minor + 1, true); + + /// + /// Gets a that represents the next major version with respect to this + /// instance. The new version line starts as a prerelease. + /// + /// A newly-created . + public VersionSpec NextMajor() => new(@this.Major + 1, 0, true); + + /// + /// Gets a that represents the result of applying the specified change + /// to this instance. + /// + /// A constant representing the kind of change to apply. + /// A tuple of the result of applying to this instance, and a + /// value indicating whether the result differs from this instance. + public (VersionSpec Result, bool Changed) ApplyChange(VersionSpecChange change) => change switch + { + VersionSpecChange.Unstable => @this.Prerelease ? (@this, false) : (@this.Unstable(), true), + VersionSpecChange.Stable => @this.Prerelease ? (@this.Stable(), true) : (@this, false), + VersionSpecChange.Minor => (@this.NextMinor(), true), + VersionSpecChange.Major => (@this.NextMajor(), true), + _ => (@this, false), + }; + } } diff --git a/src/Buildvana.Core.Versioning/VersioningService.cs b/src/Buildvana.Core.Versioning/VersioningService.cs index f6ef459e..f63677c1 100644 --- a/src/Buildvana.Core.Versioning/VersioningService.cs +++ b/src/Buildvana.Core.Versioning/VersioningService.cs @@ -2,8 +2,6 @@ // See the LICENSE file in the project root for full license information. using System; -using System.IO; -using System.Security; using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; @@ -17,17 +15,12 @@ namespace Buildvana.Core.Versioning; /// and exposes it in the string forms needed by builds and releases. /// /// -/// The version file (, in the home directory) holds a +/// The version file (, in the home directory) holds a /// MAJOR.MINOR[-[tag]] specification; the patch number is the Git height of the version line /// (see ). All values are computed once, on construction. /// public sealed class VersioningService { - /// - /// The name of the version file, relative to the home directory. - /// - public const string VersionFileName = "VERSION"; - private const int ShortCommitIdLength = 10; /// @@ -54,7 +47,7 @@ public VersioningService( Guard.IsNotNull(heightCalculator); var homeDirectory = home.HomeDirectory; - Spec = ReadVersionFile(Path.Combine(homeDirectory, VersionFileName)); + Spec = VersionFile.Load(home).Spec; var prereleaseTag = Spec.Prerelease ? GetPrereleaseTag(settings) : null; var facts = heightCalculator.Calculate(homeDirectory, Spec); Height = facts.Height; @@ -63,6 +56,7 @@ public VersioningService( SimpleVersion = FormattableString.Invariant($"{Spec.Major}.{Spec.Minor}.{Height}"); SemVer = prereleaseTag is null ? SimpleVersion : $"{SimpleVersion}-{prereleaseTag}"; AssemblyVersion = ComputeAssemblyVersion(Spec, Height, settings.AssemblyVersionPrecision); + FileVersion = FormattableString.Invariant($"{SimpleVersion}.0"); InformationalVersion = ComputeInformationalVersion(SemVer, Spec.Prerelease, IsPublicRelease, CommitId); var publicity = IsPublicRelease ? "public release" : "not a public release"; reporter.Info(FormattableString.Invariant($"Version {SemVer} (height {Height}, {publicity})")); @@ -112,38 +106,24 @@ public VersioningService( /// public string AssemblyVersion { get; } + /// + /// Gets the file version: at full precision, plus a fourth component + /// that is always 0. + /// + public string FileVersion { get; } + /// /// Gets the informational version: , plus a g-prefixed short commit ID /// appended to the prerelease part when the build is not a public release. /// public string InformationalVersion { get; } - private static VersionSpec ReadVersionFile(string path) - { - BuildFailedException.ThrowIfNot(File.Exists(path), $"Version file {path} not found."); - string text; - try - { - text = File.ReadAllText(path); - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException or SecurityException) - { - throw new BuildFailedException($"Could not read from {path}: {e.Message}", e); - } - - text = text.Trim(); - BuildFailedException.ThrowIfNot( - VersionSpec.TryParse(text, out var spec), - $"{path} contains an invalid version specification '{text}'."); - return spec; - } - private static string GetPrereleaseTag(VersioningSettings settings) { var tag = settings.PrereleaseTag; BuildFailedException.ThrowIf( string.IsNullOrEmpty(tag), - $"{VersionFileName} specifies a prerelease version, but versioning.prereleaseTag is not set in the configuration file."); + $"{VersionFile.FileName} specifies a prerelease version, but versioning.prereleaseTag is not set in the configuration file."); var isValid = SemanticVersion.TryParse(FormattableString.Invariant($"0.0.0-{tag}"), out _); BuildFailedException.ThrowIfNot( isValid, diff --git a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj index fe394ad0..302b4112 100644 --- a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj +++ b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj @@ -19,7 +19,10 @@ + + + diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs new file mode 100644 index 00000000..f1e8fc36 --- /dev/null +++ b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion-caching.cs @@ -0,0 +1,93 @@ +// 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.Concurrent; +using System.IO; +using Buildvana.Core.Configuration; +using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.HomeDirectory; +using Buildvana.Core.Versioning; +using Buildvana.Sdk.Internal; + +namespace Buildvana.Sdk.Tasks; + +partial class ComputeVersion +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + private static CachedVersion GetOrComputeVersion(string homeDirectory, IReporter reporter) + { + homeDirectory = Path.GetFullPath(homeDirectory); + var fingerprint = ComputeFingerprint(homeDirectory); + if (fingerprint is null) + { + return ComputeCore(homeDirectory, reporter, null); + } + + if (Cache.TryGetValue(homeDirectory, out var cached) && cached.Fingerprint == fingerprint) + { + return cached; + } + + var computed = ComputeCore(homeDirectory, reporter, fingerprint); + Cache[homeDirectory] = computed; + return computed; + } + + private static CachedVersion ComputeCore(string homeDirectory, IReporter reporter, string? fingerprint) + { + var service = new VersioningService( + reporter, + new FixedHomeDirectoryProvider(homeDirectory), + new VersioningSettings(BuildvanaConfigLoader.Load(homeDirectory)), + new GitHeightCalculator(VersionFile.FileName)); + return new CachedVersion( + fingerprint, + service.SimpleVersion, + service.SemVer, + service.AssemblyVersion, + service.FileVersion, + service.InformationalVersion, + service.IsPublicRelease, + service.IsPrerelease, + service.CommitId ?? string.Empty, + service.Height); + } + + // The fingerprint captures everything the computed version depends on: the working-tree VERSION file, + // the configuration file, and the repository state (the height walk only sees commits reachable from HEAD). + // A null fingerprint disables caching, letting VersioningService surface the proper error. + private static string? ComputeFingerprint(string homeDirectory) + { + try + { + var versionPath = Path.Combine(homeDirectory, VersionFile.FileName); + if (!File.Exists(versionPath)) + { + return null; + } + + var repositoryStateToken = GitHeightCalculator.TryGetRepositoryStateToken(homeDirectory); + if (repositoryStateToken is null) + { + return null; + } + + string?[] parts = + [ + repositoryStateToken, + File.ReadAllText(versionPath), + ReadOptionalFile(Path.Combine(homeDirectory, "buildvana.json")), + ReadOptionalFile(Path.Combine(homeDirectory, "buildvana.jsonc")), + ]; + return string.Join('\0', parts); + } + catch (Exception e) when (!e.IsFatalException()) + { + return null; + } + } + + private static string? ReadOptionalFile(string path) => File.Exists(path) ? File.ReadAllText(path) : null; +} diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.CachedVersion.cs b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.CachedVersion.cs new file mode 100644 index 00000000..041f8a1a --- /dev/null +++ b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.CachedVersion.cs @@ -0,0 +1,23 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Sdk.Tasks; + +partial class ComputeVersion +{ + /// + /// The computed version values for a home directory, together with the repository-state fingerprint + /// they were computed from ( if the state could not be fingerprinted). + /// + private sealed record CachedVersion( + string? Fingerprint, + string SimpleVersion, + string SemVer, + string AssemblyVersion, + string FileVersion, + string InformationalVersion, + bool IsPublicRelease, + bool IsPrerelease, + string CommitId, + int Height); +} diff --git a/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs new file mode 100644 index 00000000..9e3b458b --- /dev/null +++ b/src/Buildvana.Sdk.Tasks/Tasks/ComputeVersion.cs @@ -0,0 +1,67 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Globalization; +using Buildvana.Core; +using Buildvana.Sdk.Resources; +using Microsoft.Build.Framework; + +namespace Buildvana.Sdk.Tasks; + +/// +/// Computes the version being built from the repository's VERSION file, Git height, and versioning +/// settings, exposing it in the string forms consumed by builds. +/// Results are cached per home directory for the lifetime of the task host process, keyed on the repository +/// state, so that multi-project builds pay for a single Git graph walk. +/// +public sealed partial class ComputeVersion : BuildvanaSdkTask +{ + [Required] + public string HomeDirectory { get; set; } = string.Empty; + + [Output] + public string SimpleVersion { get; private set; } = string.Empty; + + [Output] + public string SemVer { get; private set; } = string.Empty; + + [Output] + public string AssemblyVersion { get; private set; } = string.Empty; + + [Output] + public string FileVersion { get; private set; } = string.Empty; + + [Output] + public string InformationalVersion { get; private set; } = string.Empty; + + [Output] + public bool IsPublicRelease { get; private set; } + + [Output] + public bool IsPrerelease { get; private set; } + + [Output] + public string CommitId { get; private set; } = string.Empty; + + [Output] + public int Height { get; private set; } + + protected override Undefined Run() + { + BuildFailedException.ThrowIfNot( + !string.IsNullOrEmpty(HomeDirectory), + string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(HomeDirectory))); + + var version = GetOrComputeVersion(HomeDirectory, Reporter); + SimpleVersion = version.SimpleVersion; + SemVer = version.SemVer; + AssemblyVersion = version.AssemblyVersion; + FileVersion = version.FileVersion; + InformationalVersion = version.InformationalVersion; + IsPublicRelease = version.IsPublicRelease; + IsPrerelease = version.IsPrerelease; + CommitId = version.CommitId; + Height = version.Height; + return Undefined.Value; + } +} diff --git a/src/Buildvana.Sdk/Buildvana.Sdk.csproj b/src/Buildvana.Sdk/Buildvana.Sdk.csproj index ab27a01a..042efbfa 100644 --- a/src/Buildvana.Sdk/Buildvana.Sdk.csproj +++ b/src/Buildvana.Sdk/Buildvana.Sdk.csproj @@ -67,7 +67,7 @@ <_SdkVersionPropsXml> - $(NuGetPackageVersion) + $(PackageVersion) $(BV_MinRoslynVersion) $(BV_MinRoslynVersionHint) @@ -272,8 +272,19 @@ <_PublishedBinaries Remove="$(OutputPath)\**\*.pdb" Condition="'$(Configuration)' != 'Debug'" /> + + <_PublishedLibGit2NativeBinaries Include="$(OutputPath)\**\libgit2-*.so;$(OutputPath)\**\libgit2-*.dylib" /> + <_PublishedBinaries Remove="@(_PublishedLibGit2NativeBinaries)" /> + + <_PublishedBinaries Remove="@(_PublishedBinaries)" /> + <_PublishedLibGit2NativeBinaries Remove="@(_PublishedLibGit2NativeBinaries)" /> diff --git a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/BeforeModules.targets b/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/BeforeModules.targets deleted file mode 100644 index 025b2464..00000000 --- a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/BeforeModules.targets +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - $([MSBuild]::GetPathOfFileAbove('version.json', '$(MSBuildProjectDirectory)')) - $([MSBuild]::GetPathOfFileAbove('.version.json', '$(MSBuildProjectDirectory)')) - - - - - - false - true - - - - - $([System.IO.Path]::GetDirectoryName('$(BV_VersionJsonPath)')) - - - - - - - - - - false - - - diff --git a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Core.targets b/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Core.targets deleted file mode 100644 index e15d8244..00000000 --- a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Core.targets +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - $(NpmPackageVersion) - - - - - diff --git a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.targets b/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.targets deleted file mode 100644 index 64a0dadd..00000000 --- a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.targets +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/src/Buildvana.Sdk/Modules/Versioning/BeforeModules.targets b/src/Buildvana.Sdk/Modules/Versioning/BeforeModules.targets new file mode 100644 index 00000000..79fc0863 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/Versioning/BeforeModules.targets @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + true + false + + + + + $(HomeDirectory)VERSION + + + + + false + true + + + + + + + + + + false + + + + + true + + + diff --git a/src/Buildvana.Sdk/Modules/Versioning/Module.Core.targets b/src/Buildvana.Sdk/Modules/Versioning/Module.Core.targets new file mode 100644 index 00000000..be504a85 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/Versioning/Module.Core.targets @@ -0,0 +1,72 @@ + + + + + false + + + + + + + + + + + + + + + + + + + $(BV_SemVer) + $(BV_SemVer) + $(BV_AssemblyVersion) + $(BV_FileVersion) + $(BV_InformationalVersion) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Stubs.targets b/src/Buildvana.Sdk/Modules/Versioning/Module.Stubs.targets similarity index 67% rename from src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Stubs.targets rename to src/Buildvana.Sdk/Modules/Versioning/Module.Stubs.targets index 409a7886..b6c735cb 100644 --- a/src/Buildvana.Sdk/Modules/NerdbankGitVersioning/Module.Stubs.targets +++ b/src/Buildvana.Sdk/Modules/Versioning/Module.Stubs.targets @@ -1,7 +1,7 @@ diff --git a/src/Buildvana.Sdk/Modules/Versioning/Module.targets b/src/Buildvana.Sdk/Modules/Versioning/Module.targets new file mode 100644 index 00000000..3c7aa7f8 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/Versioning/Module.targets @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/Buildvana.Sdk/Sdk/PackageVersions.props b/src/Buildvana.Sdk/Sdk/PackageVersions.props index 474bfc2e..51de1dc5 100644 --- a/src/Buildvana.Sdk/Sdk/PackageVersions.props +++ b/src/Buildvana.Sdk/Sdk/PackageVersions.props @@ -9,7 +9,6 @@ - diff --git a/src/Buildvana.Sdk/Sdk/Sdk.props b/src/Buildvana.Sdk/Sdk/Sdk.props index 54ee1c73..7116c677 100644 --- a/src/Buildvana.Sdk/Sdk/Sdk.props +++ b/src/Buildvana.Sdk/Sdk/Sdk.props @@ -113,6 +113,7 @@ $(BV_BinariesDir)Buildvana.Sdk.Tasks.dll + diff --git a/src/Buildvana.Tool/Buildvana.Tool.csproj b/src/Buildvana.Tool/Buildvana.Tool.csproj index 0548890d..94355c2b 100644 --- a/src/Buildvana.Tool/Buildvana.Tool.csproj +++ b/src/Buildvana.Tool/Buildvana.Tool.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index 34fb76d0..0ef34ca6 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -10,6 +10,7 @@ using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Core.Process; +using Buildvana.Core.Versioning; using Buildvana.Tool.Build; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Infrastructure.DependencyInjection; @@ -190,6 +191,7 @@ private static ServiceProvider BuildServiceProvider( .AddSingleton() .AddSingleton() .AddSingleton(ServerAdapter.Create) + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Buildvana.Tool/Services/Versioning/VersionFile.cs b/src/Buildvana.Tool/Services/Versioning/VersionFile.cs deleted file mode 100644 index 9a9a4af6..00000000 --- a/src/Buildvana.Tool/Services/Versioning/VersionFile.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Text.Json.Nodes; -using Buildvana.Core; -using Buildvana.Core.HomeDirectory; -using Buildvana.Core.Json; -using CommunityToolkit.Diagnostics; - -namespace Buildvana.Tool.Services.Versioning; - -/// -/// Represents the version.json file, for the purpose of applying version advances. -/// -internal sealed class VersionFile -{ - private const string VersionJsonPath = "version.json"; - private const string VersionPropertyName = "version"; - private const string DefaultFirstUnstableTag = "preview"; - - private readonly IJsonHelper _jsonHelper; - - private VersionFile( - IJsonHelper jsonHelper, - string absolutePath, - VersionSpec versionSpec, - string firstUnstableTag) - { - _jsonHelper = jsonHelper; - Path = absolutePath; - VersionSpec = versionSpec; - FirstUnstableTag = firstUnstableTag; - } - - /// - /// Gets the absolute path of the version.json file. - /// - public string Path { get; } - - /// - /// Gets a representing the "version" value in the version.json file. - /// - public VersionSpec VersionSpec { get; private set; } - - /// - /// Gets the unstable tag to use for version advances. - /// - /// Either the "release.firstUnstableTag" value read from version.json, or "preview" as a default value. - public string FirstUnstableTag { get; } - - /// - /// Constructs a instance by loading the repository's version.json file. - /// - /// The home directory provider used to resolve the path of version.json. - /// The JSON helper used to load and rewrite the file. - /// A newly-created , representing the loaded data. - public static VersionFile Load(IHomeDirectoryProvider home, IJsonHelper jsonHelper) - { - Guard.IsNotNull(home); - Guard.IsNotNull(jsonHelper); - - var path = System.IO.Path.GetFullPath(VersionJsonPath, home.HomeDirectory); - var json = jsonHelper.LoadObject(path); - var versionStr = jsonHelper.GetPropertyValue(json, VersionPropertyName, path + " file"); - BuildFailedException.ThrowIfNot(VersionSpec.TryParse(versionStr, out var versionSpec), $"{VersionJsonPath} contains invalid version specification '{versionStr}'."); - var firstUnstableTag = DefaultFirstUnstableTag; - var release = json["release"]; - if (release is not null) - { - var firstUnstableTagNode = release["firstUnstableTag"]; - if (firstUnstableTagNode is JsonValue firstUnstableTagValue && firstUnstableTagValue.TryGetValue(out var firstUnstableTagStr) && !string.IsNullOrEmpty(firstUnstableTagStr)) - { - firstUnstableTag = firstUnstableTagStr; - } - } - - return new(jsonHelper, path, versionSpec, firstUnstableTag); - } - - /// - /// Applies a version spec change to this instance. - /// - /// A constant representing the kind of change to apply. - /// If the property is actually changed as a result of , ; - /// otherwise, . - /// - /// This method does not save the modified version.json file; you will have to call - /// the method if this method returns . - /// - public bool ApplyVersionSpecChange(VersionSpecChange change) - { - (VersionSpec, var changed) = VersionSpec.ApplyChange(change, FirstUnstableTag); - return changed; - } - - /// - /// Saves the version.json file, possibly with a modified , back to the repository. - /// - public void Save() - { - var newVersion = VersionSpec.ToString(); - var rewritten = _jsonHelper.RewriteStringValues( - Path, - (propertyPath, _) => propertyPath is [VersionPropertyName] ? newVersion : null); - - // Load already validated that a top-level string "version" property exists, so a no-op here - // means either the on-disk file changed underneath us or VersionSpec.ToString() produced the - // same string the file already held — both cases would let the release flow stage stale data. - BuildFailedException.ThrowIfNot(rewritten, $"Could not update {VersionJsonPath}: expected a top-level string '{VersionPropertyName}' property to rewrite."); - } -} diff --git a/src/Buildvana.Tool/Services/Versioning/VersionIncrement.cs b/src/Buildvana.Tool/Services/Versioning/VersionIncrement.cs deleted file mode 100644 index 79dbc640..00000000 --- a/src/Buildvana.Tool/Services/Versioning/VersionIncrement.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace Buildvana.Tool.Services.Versioning; - -/// -/// Specifies a kind of version increment. -/// -/// -/// The values of this enum are sorted in ascending order of importance, so that they may be compared. -/// -internal enum VersionIncrement -{ - /// - /// Represents no version advancement. - /// - None, - - /// - /// Represents an increment of minor version. - /// - Minor, - - /// - /// Represents an increment of major version and reset of minor version. - /// - Major, -} diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.VersionIncrement.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.VersionIncrement.cs new file mode 100644 index 00000000..3974a692 --- /dev/null +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.VersionIncrement.cs @@ -0,0 +1,26 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services.Versioning; + +partial class VersionService +{ + /// + /// Represents a version increment. + /// + /// + /// Constants are defined in ascending order of importance, so that they can be compared + /// with relational operators. + /// + private enum VersionIncrement + { + /// No version increment. + None, + + /// Increment of the minor version. + Minor, + + /// Increment of the major version. + Major, + } +} diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.cs index 4228b6f7..b3d6af34 100644 --- a/src/Buildvana.Tool/Services/Versioning/VersionService.cs +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.cs @@ -3,8 +3,8 @@ using Buildvana.Core; using Buildvana.Core.ConsoleOutput; -using Buildvana.Core.Json; -using Buildvana.Core.Process; +using Buildvana.Core.HomeDirectory; +using Buildvana.Core.Versioning; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.PublicApiFiles; using CommunityToolkit.Diagnostics; @@ -13,42 +13,49 @@ namespace Buildvana.Tool.Services.Versioning; /// -/// Provides methods to manage project versioning. +/// Exposes the version being built, computed by , alongside +/// release-flow version policy: consistency checks against published versions and computation +/// of the version specification change to apply upon release. /// -internal sealed class VersionService +internal sealed partial class VersionService { private readonly IReporter _reporter; - private readonly IJsonHelper _jsonHelper; - private readonly IProcessRunner _processRunner; + private readonly IHomeDirectoryProvider _home; + private readonly VersioningSettings _settings; + private readonly GitHeightCalculator _heightCalculator; private readonly PublicApiFilesService _publicApiFiles; + private VersioningService _current; + /// /// Initializes a new instance of the class. /// public VersionService( IReporter reporter, - IJsonHelper jsonHelper, - IProcessRunner processRunner, + IHomeDirectoryProvider home, + VersioningSettings settings, GitService git, PublicApiFilesService publicApiFiles) { Guard.IsNotNull(reporter); - Guard.IsNotNull(jsonHelper); - Guard.IsNotNull(processRunner); + Guard.IsNotNull(home); + Guard.IsNotNull(settings); Guard.IsNotNull(git); Guard.IsNotNull(publicApiFiles); _reporter = reporter; - _jsonHelper = jsonHelper; - _processRunner = processRunner; + _home = home; + _settings = settings; _publicApiFiles = publicApiFiles; - (CurrentStr, Current, IsPublicRelease, IsPrerelease) = GetVersionInformationFromNbgv(); + _heightCalculator = new(VersionFile.FileName); + _current = new(reporter, home, settings, _heightCalculator); + Current = SemanticVersion.Parse(_current.SemVer); (Latest, LatestStable) = git.GetLatestVersions(); } /// - /// Gets the version to build, as a string computed by Nerdbank.GitVersioning. + /// Gets the version to build, as a string. /// - public string CurrentStr { get; private set; } + public string CurrentStr => _current.SemVer; /// /// Gets the version to build, as a SemanticVersion object. @@ -58,14 +65,14 @@ public VersionService( /// /// Gets a value indicating whether a public release can be built. /// - /// If Git's HEAD is on a public release branch, as indicated in version.json, ; - /// otherwise, . - public bool IsPublicRelease { get; private set; } + /// If Git's HEAD is on a public release branch, as configured in release.branches, + /// ; otherwise, . + public bool IsPublicRelease => _current.IsPublicRelease; /// /// Gets a value indicating whether the version to build is a prerelease. /// - public bool IsPrerelease { get; private set; } + public bool IsPrerelease => _current.IsPrerelease; /// /// Gets the latest published version, if any, as a SemanticVersion object. @@ -118,9 +125,9 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha { // Determine how we are currently already incrementing version var currentVersionIncrement = LatestStable == null ? VersionIncrement.None - : Current.Major > LatestStable.Major ? VersionIncrement.Major - : Current.Minor > LatestStable.Minor ? VersionIncrement.Minor - : VersionIncrement.None; + : Current.Major > LatestStable.Major ? VersionIncrement.Major + : Current.Minor > LatestStable.Minor ? VersionIncrement.Minor + : VersionIncrement.None; _reporter.Info($"Current version increment: {currentVersionIncrement}"); // Determine the kind of change in public API @@ -132,7 +139,8 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha // When the major version is 0, "anything MAY change" according to SemVer; // by convention, we increment the minor version for breaking changes (0.x -> 0.(x+1)) var isMajorVersionZero = LatestStable is { Major: 0 }; - var semanticVersionIncrement = publicApiChangeKind switch { + var semanticVersionIncrement = publicApiChangeKind switch + { ApiChangeKind.Breaking => isMajorVersionZero ? VersionIncrement.Minor : VersionIncrement.Major, ApiChangeKind.Additive => isMajorVersionZero ? VersionIncrement.None : VersionIncrement.Minor, _ => VersionIncrement.None, @@ -141,7 +149,8 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha // Determine the requested version increment, if any. _reporter.Info($"Requested version spec change: {requestedChange}"); - var requestedVersionIncrement = requestedChange switch { + var requestedVersionIncrement = requestedChange switch + { VersionSpecChange.Major => VersionIncrement.Major, VersionSpecChange.Minor => VersionIncrement.Minor, _ => VersionIncrement.None, @@ -161,11 +170,13 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha // Determine the actual version spec change to apply: // - forget any increment-related change (already accounted for via requestedVersionIncrement) // - set the change to the required increment if any, otherwise leave it as is (None, Unstable, Stable) - var actualChange = requestedChange switch { + var actualChange = requestedChange switch + { VersionSpecChange.Major or VersionSpecChange.Minor => VersionSpecChange.None, _ => requestedChange, }; - actualChange = actualVersionIncrement switch { + actualChange = actualVersionIncrement switch + { VersionIncrement.Major => VersionSpecChange.Major, VersionIncrement.Minor => VersionSpecChange.Minor, _ => actualChange, @@ -177,22 +188,9 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha /// /// Update version information, typically after a commit. /// - public void Update() => (CurrentStr, Current, IsPublicRelease, IsPrerelease) = GetVersionInformationFromNbgv(); - - private (string CurrentStr, SemanticVersion Current, bool IsPublicRelease, bool IsPrerelease) GetVersionInformationFromNbgv() + public void Update() { - // Block synchronously: this method runs from the constructor (and Update()), neither of which is async. - var result = _processRunner - .RunAsync("dotnet", ["nbgv", "get-version", "--format", "json"]) - .GetAwaiter() - .GetResult(); - - var json = _jsonHelper.ParseObject(result.StandardOutput, "The output of nbgv"); - var currentStr = _jsonHelper.GetPropertyValue(json, "SemVer2", "the output of nbgv"); - return ( - currentStr, - SemanticVersion.Parse(currentStr), - _jsonHelper.GetPropertyValue(json, "PublicRelease", "the output of nbgv"), - !string.IsNullOrEmpty(_jsonHelper.GetPropertyValue(json, "PrereleaseVersion", "the output of nbgv"))); + _current = new(_reporter, _home, _settings, _heightCalculator); + Current = SemanticVersion.Parse(_current.SemVer); } } diff --git a/src/Buildvana.Tool/Services/Versioning/VersionSpec.cs b/src/Buildvana.Tool/Services/Versioning/VersionSpec.cs deleted file mode 100644 index 31ff2e45..00000000 --- a/src/Buildvana.Tool/Services/Versioning/VersionSpec.cs +++ /dev/null @@ -1,131 +0,0 @@ -// 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.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Text.RegularExpressions; - -namespace Buildvana.Tool.Services.Versioning; - -/// -/// Represents a Major.Minor[-Tag] version as found in version.json. -/// -internal sealed partial record VersionSpec -{ - private static readonly Regex VersionSpecRegex = GetVersionSpecRegex(); - - private VersionSpec(int major, int minor, string tag) - { - Major = major; - Minor = minor; - Tag = tag; - } - - /// - /// Gets the major version. - /// - public int Major { get; } - - /// - /// Gets the minor version. - /// - public int Minor { get; } - - /// - /// Gets the current unstable tag. - /// - /// The current unstable tag, or the empty string if the current version is stable. - public string Tag { get; } - - /// - /// Gets a value indicating whether this instance has an unstable tag. - /// - public bool HasTag => !string.IsNullOrEmpty(Tag); - - /// - /// Attempts to parse a from the specified string. - /// - /// The string to parse. - /// When this method returns , a newly-created . - /// This parameter is passed uninitialized. - /// if successful, otherwise. - public static bool TryParse(string str, [MaybeNullWhen(false)] out VersionSpec result) - { - var match = VersionSpecRegex.Match(str); - if (!match.Success) - { - result = null; - return false; - } - - result = new( - int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), - match.Groups["tag"].Value); - - return true; - } - - /// - public override string ToString() => $"{Major}.{Minor}{(HasTag ? "-" + Tag : null)}"; - - /// - /// Gets an instance of that represents the same version as the current instance and has no unstable tag. - /// - /// If this instance has no unstable tag, this instance; otherwise, a newly-created - /// that represents the same version as the current instance and has no unstable tag. - public VersionSpec Stable() => HasTag ? new(Major, Minor, string.Empty) : this; - - /// - /// Gets an instance of that represents the same version as the current instance and has the specified unstable tag. - /// - /// The unstable tag of the returned instance. - /// If this instance's property is equal to the given , this instance; - /// otherwise, a newly-created that represents the same version as the current instance and has the specified unstable tag. - public VersionSpec Unstable(string tag) => string.Equals(Tag, tag, StringComparison.Ordinal) ? this : new(Major, Minor, tag); - - /// - /// Gets an instance of that represents the next minor version with respect to the current instance and has the specified unstable tag. - /// - /// The unstable tag of the returned instance. - /// A newly-created . - public VersionSpec NextMinor(string tag) => new(Major, Minor + 1, tag); - - /// - /// Gets an instance of that represents the next major version with respect to the current instance and has the specified unstable tag. - /// - /// The unstable tag of the returned instance. - /// A newly-created . - public VersionSpec NextMajor(string tag) => new(Major + 1, 0, tag); - - /// - /// Gets an instance of that represents the result of applying the specified change to the current instance. - /// - /// A constant representing the kind of change to apply. - /// If the returned instance has an unstable tag, the unstable tag of the returned instance; otherwise, this parameter is ignored. - /// - /// A tuple of the following values: - /// - /// - /// Result - /// The result of applying to the current instance. - /// - /// - /// Changed - /// If Result is equal to the current instance, ; otherwise, . - /// - /// - /// - public (VersionSpec Result, bool Changed) ApplyChange(VersionSpecChange change, string tag) - => change switch { - VersionSpecChange.Unstable => HasTag ? (this, false) : (Unstable(tag), true), - VersionSpecChange.Stable => HasTag ? (Stable(), true) : (this, false), - VersionSpecChange.Minor => (NextMinor(tag), true), - VersionSpecChange.Major => (NextMajor(tag), true), - _ => (this, false), - }; - - [GeneratedRegex(@"(?-imsx)^v?(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(-(?.*))?$", RegexOptions.ExplicitCapture | RegexOptions.Compiled | RegexOptions.CultureInvariant)] - private static partial Regex GetVersionSpecRegex(); -} diff --git a/src/Buildvana.Tool/Services/Versioning/VersionSpecChange.cs b/src/Buildvana.Tool/Services/Versioning/VersionSpecChange.cs deleted file mode 100644 index 8c3eeb35..00000000 --- a/src/Buildvana.Tool/Services/Versioning/VersionSpecChange.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace Buildvana.Tool.Services.Versioning; - -/// -/// Specifies how to modify the version specification upon publishing a release. -/// -internal enum VersionSpecChange -{ - /// - /// Do not force a version increment; do not modify the unstable tag. - /// - None, - - /// - /// Do not force a version increment; add an unstable tag if not present. - /// - Unstable, - - /// - /// Do not force a version increment; remove the unstable tag if present. - /// - Stable, - - /// - /// Force a minor version increment with respect to the latest stable version; add an unstable tag. - /// - Minor, - - /// - /// Force a major version increment and minor version reset with respect to the latest stable version; add an unstable tag. - /// - Major, -} diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 184d9390..f235ca6e 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -12,7 +12,7 @@ using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; -using Buildvana.Core.Json; +using Buildvana.Core.Versioning; using Buildvana.Tool.Build; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Infrastructure.Execution; @@ -42,7 +42,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await pipeline.RunThroughAsync(BuildStep.Test, configuration, cancellationToken).ConfigureAwait(false); var home = services.GetRequiredService(); - var jsonHelper = services.GetRequiredService(); + var versioningSettings = services.GetRequiredService(); var server = services.GetRequiredService(); var version = services.GetRequiredService(); var dotnet = services.GetRequiredService(); @@ -91,12 +91,12 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Modify version file if required. if (versionSpecChange != VersionSpecChange.None) { - var versionFile = VersionFile.Load(home, jsonHelper); - var previousVersionSpec = versionFile.VersionSpec; - if (versionFile.ApplyVersionSpecChange(versionSpecChange)) + var versionFile = VersionFile.Load(home); + var previousVersionSpec = versionFile.Spec; + if (versionFile.ApplyChange(versionSpecChange)) { - reporter.Info($"Version spec changed from {previousVersionSpec} to {versionFile.VersionSpec}."); - versionFile.Save(); + reporter.Info($"Version spec changed from {previousVersionSpec} to {versionFile.Spec}."); + versionFile.Save(versioningSettings.PrereleaseTag); release.UpdateRepository(versionFile.Path); } else @@ -180,7 +180,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Ensure that the release tag doesn't already exist. // This assumes that full repo history has been checked out; - // however, that is already a prerequisite for using Nerdbank.GitVersioning. + // however, that is already a prerequisite for computing the Git height. BuildFailedException.ThrowIfNot(!git.TagExists(version.CurrentStr), $"Tag '{version.CurrentStr}' already exists in repository."); // Artifact pass (Restore→Pack, no Clean): rebuild against the resolved version and make artifacts. diff --git a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs index c19beb63..f0354ca5 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseSettings.cs @@ -7,9 +7,9 @@ using System.Text.RegularExpressions; using Buildvana.Core; using Buildvana.Core.Configuration; +using Buildvana.Core.Versioning; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Services; -using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; namespace Buildvana.Tool.Subcommands; diff --git a/tests/Buildvana.Core.Versioning.Tests/Buildvana.Core.Versioning.Tests.csproj b/tests/Buildvana.Core.Versioning.Tests/Buildvana.Core.Versioning.Tests.csproj index 6f81faf1..de7f516f 100644 --- a/tests/Buildvana.Core.Versioning.Tests/Buildvana.Core.Versioning.Tests.csproj +++ b/tests/Buildvana.Core.Versioning.Tests/Buildvana.Core.Versioning.Tests.csproj @@ -8,11 +8,11 @@ - + diff --git a/tests/Buildvana.Core.Versioning.Tests/FixedHomeDirectoryProvider.cs b/tests/Buildvana.Core.Versioning.Tests/FixedHomeDirectoryProvider.cs deleted file mode 100644 index b4221384..00000000 --- a/tests/Buildvana.Core.Versioning.Tests/FixedHomeDirectoryProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -// 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.HomeDirectory; - -internal sealed class FixedHomeDirectoryProvider(string homeDirectory) : IHomeDirectoryProvider -{ - public string HomeDirectory => homeDirectory; -} diff --git a/tests/Buildvana.Core.Versioning.Tests/GitHeightCalculatorTests.cs b/tests/Buildvana.Core.Versioning.Tests/GitHeightCalculatorTests.cs index a2eba6b3..8685362c 100644 --- a/tests/Buildvana.Core.Versioning.Tests/GitHeightCalculatorTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/GitHeightCalculatorTests.cs @@ -3,6 +3,7 @@ using System.Text; using Buildvana.Core; +using Buildvana.Core.Testing; using Buildvana.Core.Versioning; internal sealed class GitHeightCalculatorTests @@ -173,6 +174,56 @@ public async Task Calculate_DetachedHead_ReportsEmptyBranchName() await Assert.That(result.Height).IsEqualTo(1); } + [Test] + public async Task TryGetRepositoryStateToken_WithoutRepository_ReturnsNull() + { + var directory = Directory.CreateTempSubdirectory("bv-test-"); + try + { + var token = GitHeightCalculator.TryGetRepositoryStateToken(directory.FullName); + await Assert.That(token).IsNull(); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task TryGetRepositoryStateToken_UnchangedState_ReturnsEqualTokens() + { + using var repo = new TempGitRepo(); + repo.CommitAll(); + var first = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + var second = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + await Assert.That(first).IsNotNull(); + await Assert.That(second).IsEqualTo(first); + } + + [Test] + public async Task TryGetRepositoryStateToken_NewCommit_ChangesToken() + { + using var repo = new TempGitRepo(); + repo.CommitAll(); + var first = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + repo.CommitAll(); + var second = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + await Assert.That(second).IsNotNull(); + await Assert.That(string.Equals(second, first, StringComparison.Ordinal)).IsFalse(); + } + + [Test] + public async Task TryGetRepositoryStateToken_DetachedHead_ChangesToken() + { + using var repo = new TempGitRepo(); + repo.CommitAll(); + var first = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + repo.CheckoutDetached(); + var second = GitHeightCalculator.TryGetRepositoryStateToken(repo.RootPath); + await Assert.That(second).IsNotNull(); + await Assert.That(string.Equals(second, first, StringComparison.Ordinal)).IsFalse(); + } + private static GitHeightResult Calculate(TempGitRepo repo, int major, int minor) => new GitHeightCalculator("VERSION").Calculate(repo.RootPath, new VersionSpec(major, minor, false)); } diff --git a/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs new file mode 100644 index 00000000..f23efcd3 --- /dev/null +++ b/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs @@ -0,0 +1,110 @@ +// 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; +using Buildvana.Core.Testing; +using Buildvana.Core.Versioning; + +internal sealed class VersionFileTests +{ + [Test] + public async Task Load_MissingFile_Throws() + { + using var home = new TempHome(); + var exception = CatchLoad(home); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("not found"); + } + + [Test] + public async Task Load_MalformedFile_Throws() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "banana\n"); + var exception = CatchLoad(home); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("banana"); + } + + [Test] + public async Task Load_ValidFile_ParsesSpecAndExposesPath() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3-preview\n"); + var versionFile = VersionFile.Load(home.Provider); + await Assert.That(versionFile.Spec).IsEqualTo(new VersionSpec(2, 3, true)); + await Assert.That(versionFile.Path).IsEqualTo(Path.Combine(home.RootPath, VersionFile.FileName)); + } + + [Test] + public async Task ApplyChange_UpdatesSpecAndReportsChange() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3-x\n"); + var versionFile = VersionFile.Load(home.Provider); + await Assert.That(versionFile.ApplyChange(VersionSpecChange.Stable)).IsTrue(); + await Assert.That(versionFile.Spec).IsEqualTo(new VersionSpec(2, 3, false)); + await Assert.That(versionFile.ApplyChange(VersionSpecChange.Stable)).IsFalse(); + } + + [Test] + public async Task Save_StableSpec_WritesCanonicalForm() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3\n"); + VersionFile.Load(home.Provider).Save("preview"); + await Assert.That(home.ReadFile(VersionFile.FileName)).IsEqualTo("2.3\n"); + } + + [Test] + public async Task Save_PrereleaseSpecWithConformingTag_WritesTag() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3-\n"); + VersionFile.Load(home.Provider).Save("preview"); + await Assert.That(home.ReadFile(VersionFile.FileName)).IsEqualTo("2.3-preview\n"); + } + + [Test] + public async Task Save_PrereleaseSpecWithNoTag_WritesBareMarker() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3-x\n"); + VersionFile.Load(home.Provider).Save(); + await Assert.That(home.ReadFile(VersionFile.FileName)).IsEqualTo("2.3-\n"); + } + + [Test] + public async Task Save_PrereleaseSpecWithNonConformingTag_WritesBareMarker() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3-x\n"); + VersionFile.Load(home.Provider).Save("beta.1"); + await Assert.That(home.ReadFile(VersionFile.FileName)).IsEqualTo("2.3-\n"); + } + + [Test] + public async Task Save_AfterApplyChange_RoundTrips() + { + using var home = new TempHome(); + home.WriteFile(VersionFile.FileName, "2.3\n"); + var versionFile = VersionFile.Load(home.Provider); + _ = versionFile.ApplyChange(VersionSpecChange.Minor); + versionFile.Save("preview"); + var reloaded = VersionFile.Load(home.Provider); + await Assert.That(reloaded.Spec).IsEqualTo(new VersionSpec(2, 4, true)); + } + + private static BuildFailedException? CatchLoad(TempHome home) + { + try + { + _ = VersionFile.Load(home.Provider); + return null; + } + catch (BuildFailedException e) + { + return e; + } + } +} diff --git a/tests/Buildvana.Core.Versioning.Tests/VersionSpecChangeTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersionSpecChangeTests.cs new file mode 100644 index 00000000..7e51ea80 --- /dev/null +++ b/tests/Buildvana.Core.Versioning.Tests/VersionSpecChangeTests.cs @@ -0,0 +1,73 @@ +// 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.Versioning; + +internal sealed class VersionSpecChangeTests +{ + [Test] + public async Task Stable_OnPrerelease_ClearsPrereleaseMarker() + { + var result = new VersionSpec(2, 3, true).Stable(); + await Assert.That(result).IsEqualTo(new VersionSpec(2, 3, false)); + } + + [Test] + public async Task Stable_OnStable_ReturnsEqualSpec() + { + var spec = new VersionSpec(2, 3, false); + await Assert.That(spec.Stable()).IsEqualTo(spec); + } + + [Test] + public async Task Unstable_OnStable_SetsPrereleaseMarker() + { + var result = new VersionSpec(2, 3, false).Unstable(); + await Assert.That(result).IsEqualTo(new VersionSpec(2, 3, true)); + } + + [Test] + public async Task Unstable_OnPrerelease_ReturnsEqualSpec() + { + var spec = new VersionSpec(2, 3, true); + await Assert.That(spec.Unstable()).IsEqualTo(spec); + } + + [Test] + public async Task NextMinor_IncrementsMinorAndStartsPrerelease() + { + var result = new VersionSpec(2, 3, false).NextMinor(); + await Assert.That(result).IsEqualTo(new VersionSpec(2, 4, true)); + } + + [Test] + public async Task NextMajor_IncrementsMajorResetsMinorAndStartsPrerelease() + { + var result = new VersionSpec(2, 3, false).NextMajor(); + await Assert.That(result).IsEqualTo(new VersionSpec(3, 0, true)); + } + + [Test] + [Arguments(false, VersionSpecChange.None, 2, 3, false, false)] + [Arguments(true, VersionSpecChange.None, 2, 3, true, false)] + [Arguments(false, VersionSpecChange.Unstable, 2, 3, true, true)] + [Arguments(true, VersionSpecChange.Unstable, 2, 3, true, false)] + [Arguments(false, VersionSpecChange.Stable, 2, 3, false, false)] + [Arguments(true, VersionSpecChange.Stable, 2, 3, false, true)] + [Arguments(false, VersionSpecChange.Minor, 2, 4, true, true)] + [Arguments(true, VersionSpecChange.Minor, 2, 4, true, true)] + [Arguments(false, VersionSpecChange.Major, 3, 0, true, true)] + [Arguments(true, VersionSpecChange.Major, 3, 0, true, true)] + public async Task ApplyChange_ComputesExpectedResult( + bool prerelease, + VersionSpecChange change, + int expectedMajor, + int expectedMinor, + bool expectedPrerelease, + bool expectedChanged) + { + var (result, changed) = new VersionSpec(2, 3, prerelease).ApplyChange(change); + await Assert.That(result).IsEqualTo(new VersionSpec(expectedMajor, expectedMinor, expectedPrerelease)); + await Assert.That(changed).IsEqualTo(expectedChanged); + } +} diff --git a/tests/Buildvana.Core.Versioning.Tests/VersioningServiceTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersioningServiceTests.cs index 560d2149..425e1ed6 100644 --- a/tests/Buildvana.Core.Versioning.Tests/VersioningServiceTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/VersioningServiceTests.cs @@ -4,6 +4,8 @@ using Buildvana.Core; using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; +using Buildvana.Core.HomeDirectory; +using Buildvana.Core.Testing; using Buildvana.Core.Versioning; internal sealed class VersioningServiceTests @@ -138,12 +140,22 @@ public async Task Constructor_DefaultPrecision_IsMajor() await Assert.That(service.AssemblyVersion).IsEqualTo("2.0.0.0"); } + [Test] + public async Task Constructor_FileVersion_IsFullPrecisionRegardlessOfAssemblyVersionPrecision() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "2.3\n"); + repo.CommitAll(); + var service = CreateService(repo, new BuildvanaConfig()); + await Assert.That(service.FileVersion).IsEqualTo("2.3.1.0"); + } + private static VersioningService CreateService(TempGitRepo repo, BuildvanaConfig config) => new( NullReporter.Instance, new FixedHomeDirectoryProvider(repo.RootPath), new VersioningSettings(config), - new GitHeightCalculator(VersioningService.VersionFileName)); + new GitHeightCalculator(VersionFile.FileName)); private static BuildFailedException? CatchCreate(TempGitRepo repo, BuildvanaConfig config) { diff --git a/tests/Buildvana.Sdk.Tasks.Tests/Buildvana.Sdk.Tasks.Tests.csproj b/tests/Buildvana.Sdk.Tasks.Tests/Buildvana.Sdk.Tasks.Tests.csproj index 6409d9fb..d57601a8 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/Buildvana.Sdk.Tasks.Tests.csproj +++ b/tests/Buildvana.Sdk.Tasks.Tests/Buildvana.Sdk.Tasks.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs new file mode 100644 index 00000000..e75397cb --- /dev/null +++ b/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs @@ -0,0 +1,113 @@ +// 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.Testing; +using Buildvana.Sdk.Tasks; + +internal sealed class ComputeVersionTests +{ + [Test] + public async Task Execute_WithMissingHomeDirectory_LogsError() + { + var engine = new RecordingBuildEngine(); + var task = new ComputeVersion { BuildEngine = engine }; + await Assert.That(task.Execute()).IsFalse(); + await Assert.That(engine.Errors.Count).IsEqualTo(1); + await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + } + + [Test] + public async Task Execute_StableNonPublic_ComputesVersionOutputs() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "1.2\n"); + repo.CommitAll(); + var task = RunTask(repo); + await Assert.That(task.SimpleVersion).IsEqualTo("1.2.1"); + await Assert.That(task.SemVer).IsEqualTo("1.2.1"); + await Assert.That(task.AssemblyVersion).IsEqualTo("1.0.0.0"); + await Assert.That(task.FileVersion).IsEqualTo("1.2.1.0"); + await Assert.That(task.InformationalVersion).IsEqualTo($"1.2.1-g{repo.HeadSha[..10]}"); + await Assert.That(task.IsPublicRelease).IsFalse(); + await Assert.That(task.IsPrerelease).IsFalse(); + await Assert.That(task.CommitId).IsEqualTo(repo.HeadSha); + await Assert.That(task.Height).IsEqualTo(1); + } + + [Test] + public async Task Execute_WithConfigurationFile_HonorsVersioningSettings() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "1.2-x\n"); + repo.WriteFile( + "buildvana.json", + """{ "release": { "branches": ["^rel$"] }, "versioning": { "prereleaseTag": "preview", "assemblyVersionPrecision": "minor" } }"""); + repo.CommitAll(); + repo.CheckoutNewBranch("rel"); + var task = RunTask(repo); + await Assert.That(task.SemVer).IsEqualTo("1.2.1-preview"); + await Assert.That(task.AssemblyVersion).IsEqualTo("1.2.0.0"); + await Assert.That(task.InformationalVersion).IsEqualTo("1.2.1-preview"); + await Assert.That(task.IsPublicRelease).IsTrue(); + await Assert.That(task.IsPrerelease).IsTrue(); + } + + [Test] + public async Task Execute_WithUnchangedRepositoryState_UsesCachedResult() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "1.2\n"); + repo.CommitAll(); + var firstEngine = new RecordingBuildEngine(); + _ = RunTask(repo, firstEngine); + var secondEngine = new RecordingBuildEngine(); + var second = RunTask(repo, secondEngine); + + // The versioning service logs the computed version; a cache hit computes nothing and stays silent. + await Assert.That(firstEngine.Messages.Count(m => m.Message!.Contains("Version 1.2.1", StringComparison.Ordinal))).IsEqualTo(1); + await Assert.That(secondEngine.Messages.Count(m => m.Message!.Contains("Version 1.2.1", StringComparison.Ordinal))).IsEqualTo(0); + await Assert.That(second.SemVer).IsEqualTo("1.2.1"); + await Assert.That(second.Height).IsEqualTo(1); + } + + [Test] + public async Task Execute_AfterNewCommit_RecomputesVersion() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "1.2\n"); + repo.CommitAll(); + var first = RunTask(repo); + await Assert.That(first.Height).IsEqualTo(1); + repo.CommitAll(); + var engine = new RecordingBuildEngine(); + var second = RunTask(repo, engine); + await Assert.That(second.Height).IsEqualTo(2); + await Assert.That(second.SemVer).IsEqualTo("1.2.2"); + await Assert.That(engine.Messages.Count(m => m.Message!.Contains("Version 1.2.2", StringComparison.Ordinal))).IsEqualTo(1); + } + + [Test] + public async Task Execute_AfterVersionFileChange_RecomputesVersion() + { + using var repo = new TempGitRepo(); + repo.WriteFile("VERSION", "1.2\n"); + repo.CommitAll(); + var first = RunTask(repo); + await Assert.That(first.SemVer).IsEqualTo("1.2.1"); + repo.WriteFile("VERSION", "1.3\n"); + var second = RunTask(repo); + await Assert.That(second.SemVer).IsEqualTo("1.3.0"); + await Assert.That(second.Height).IsEqualTo(0); + } + + private static ComputeVersion RunTask(TempGitRepo repo, RecordingBuildEngine? engine = null) + { + var task = new ComputeVersion + { + BuildEngine = engine ?? new RecordingBuildEngine(), + HomeDirectory = repo.RootPath, + }; + _ = task.Execute(); + return task; + } +} diff --git a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs index b36770f1..be023511 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseSettingsTests.cs @@ -3,8 +3,8 @@ using Buildvana.Core; using Buildvana.Core.Configuration; +using Buildvana.Core.Versioning; using Buildvana.Tool.Services; -using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; internal sealed class ReleaseSettingsTests