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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Buildvana.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<Project Path="src/Buildvana.Core.Json/Buildvana.Core.Json.csproj" />
<Project Path="src/Buildvana.Core.JsonSchema/Buildvana.Core.JsonSchema.csproj" />
<Project Path="src/Buildvana.Core.Process/Buildvana.Core.Process.csproj" />
<Project Path="src/Buildvana.Core.Versioning/Buildvana.Core.Versioning.csproj" />
<Project Path="src/Buildvana.Sdk.SourceGenerators/Buildvana.Sdk.SourceGenerators.csproj" />
<Project Path="src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj" />
<Project Path="src/Buildvana.Sdk/Buildvana.Sdk.csproj" />
Expand All @@ -53,6 +54,7 @@
<File Path="tests/Common.targets" />
<Project Path="tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj" />
<Project Path="tests/Buildvana.Core.JsonSchema.Tests/Buildvana.Core.JsonSchema.Tests.csproj" />
<Project Path="tests/Buildvana.Core.Versioning.Tests/Buildvana.Core.Versioning.Tests.csproj" />
<Project Path="tests/Buildvana.Tool.Tests/Buildvana.Tool.Tests.csproj" />
</Folder>
</Solution>
14 changes: 14 additions & 0 deletions src/Buildvana.Core.Versioning/Buildvana.Core.Versioning.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Title>Buildvana versioning</Title>
<Description>Host-agnostic version computation: the current-version.json model, semantic-version calculation from git height, and public-release branch matching. Reports malformed input via standard exceptions.</Description>
<TargetFramework>$(StandardTfm)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="NuGet.Versioning" />
</ItemGroup>

</Project>
20 changes: 20 additions & 0 deletions src/Buildvana.Core.Versioning/CalculatedVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (C) Tenacom and Contributors. Licensed under the MIT license.
// See the LICENSE file in the project root for full license information.

using NuGet.Versioning;

namespace Buildvana.Core.Versioning;

/// <summary>
/// Represents the result of a version computation performed by <see cref="VersionCalculator.Compute"/>.
/// </summary>
/// <param name="SemanticVersion">The computed semantic version.</param>
/// <param name="CurrentStr">The normalized SemVer 2.0 string form of <paramref name="SemanticVersion"/>.</param>
/// <param name="IsPublicRelease">A value indicating whether the build is a public release, i.e. the current branch
/// matches one of the configured public-release patterns.</param>
/// <param name="IsPrerelease">A value indicating whether the computed version is a prerelease.</param>
public sealed record CalculatedVersion(
SemanticVersion SemanticVersion,
string CurrentStr,
bool IsPublicRelease,
bool IsPrerelease);
68 changes: 68 additions & 0 deletions src/Buildvana.Core.Versioning/VersionCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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.Linq;
using System.Text.RegularExpressions;
using CommunityToolkit.Diagnostics;
using NuGet.Versioning;

namespace Buildvana.Core.Versioning;

/// <summary>
/// Computes a semantic version from version-file data, the git height, the current branch, and release policy.
/// </summary>
public static class VersionCalculator
{
/// <summary>
/// Computes the version to build.
/// </summary>
/// <param name="fileData">The data parsed from <c>current-version.json</c>.</param>
/// <param name="height">The git height (number of commits since the version was last changed); must be non-negative.</param>
/// <param name="branchName">The short name of the current branch, or the empty string when HEAD is detached.</param>
/// <param name="publicReleaseBranches">The patterns identifying public-release branches, matched against
/// <paramref name="branchName"/>. Callers should compile these with <see cref="RegexOptions.CultureInvariant"/>
/// and a match timeout; the patterns are expected to carry their own anchors.</param>
/// <param name="prereleaseTag">The prerelease tag to apply when <paramref name="fileData"/> denotes a prerelease;
/// required (non-empty) in that case, ignored otherwise.</param>
/// <returns>A <see cref="CalculatedVersion"/> describing the computed version.</returns>
/// <exception cref="ArgumentNullException">A reference argument is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="height"/> is negative.</exception>
/// <exception cref="ArgumentException"><paramref name="fileData"/> denotes a prerelease but
/// <paramref name="prereleaseTag"/> is <see langword="null"/> or empty.</exception>
/// <exception cref="RegexMatchTimeoutException">A pattern in <paramref name="publicReleaseBranches"/> exceeded its match timeout.</exception>
public static CalculatedVersion Compute(
VersionFileData fileData,
int height,
string branchName,
IReadOnlyList<Regex> publicReleaseBranches,
string? prereleaseTag)
{
Guard.IsNotNull(fileData);
Guard.IsGreaterThanOrEqualTo(height, 0);
Guard.IsNotNull(branchName);
Guard.IsNotNull(publicReleaseBranches);

SemanticVersion semanticVersion;
if (fileData.Prerelease)
{
if (string.IsNullOrEmpty(prereleaseTag))
{
throw new ArgumentException(
"A prerelease tag is required to compute a prerelease version, but none was provided.",
nameof(prereleaseTag));
}

semanticVersion = new SemanticVersion(fileData.Major, fileData.Minor, height, prereleaseTag);
}
else
{
semanticVersion = new SemanticVersion(fileData.Major, fileData.Minor, height);
}

var currentStr = semanticVersion.ToNormalizedString();
var isPublicRelease = publicReleaseBranches.Any(pattern => pattern.IsMatch(branchName));
return new CalculatedVersion(semanticVersion, currentStr, isPublicRelease, semanticVersion.IsPrerelease);
}
}
93 changes: 93 additions & 0 deletions src/Buildvana.Core.Versioning/VersionFileData.cs
Original file line number Diff line number Diff line change
@@ -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.Text.Json;
using CommunityToolkit.Diagnostics;

namespace Buildvana.Core.Versioning;

/// <summary>
/// Represents the immutable data parsed from a <c>current-version.json</c> file
/// (<c>{ "major": &lt;int&gt;, "minor": &lt;int&gt;, "prerelease": &lt;bool&gt; }</c>).
/// </summary>
/// <param name="Major">The major version component.</param>
/// <param name="Minor">The minor version component.</param>
/// <param name="Prerelease">A value indicating whether the version is a prerelease.</param>
/// <remarks>
/// <para>The <c>current-version.json</c> file holds the version <em>value</em>; release <em>policy</em>
/// (public-release branches, prerelease tag, assembly-version precision) lives in <c>buildvana.json</c>.</para>
/// <para>This type does not read the file from disk. Callers are responsible for reading the file content and
/// for <strong>failing loudly when the file is absent</strong>: a missing <c>current-version.json</c> must be
/// treated as an error, never silently defaulted.</para>
/// </remarks>
public sealed record VersionFileData(int Major, int Minor, bool Prerelease)
{
/// <summary>
/// Parses a <see cref="VersionFileData"/> from the textual content of a <c>current-version.json</c> file.
/// </summary>
/// <param name="json">The JSON content to parse.</param>
/// <returns>The parsed <see cref="VersionFileData"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="json"/> is <see langword="null"/>.</exception>
/// <exception cref="FormatException">
/// <paramref name="json"/> is not a JSON object, is missing a required property, or has a property of the wrong
/// type or value (<c>major</c> and <c>minor</c> must be non-negative integers, <c>prerelease</c> a boolean).
/// </exception>
public static VersionFileData Parse(string json)
{
Guard.IsNotNull(json);

JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException exception)
{
throw new FormatException("current-version.json is not valid JSON.", exception);
}

using (document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new FormatException("current-version.json must contain a JSON object.");
}

var major = GetNonNegativeInt32(root, "major");
var minor = GetNonNegativeInt32(root, "minor");
var prerelease = GetBoolean(root, "prerelease");
return new VersionFileData(major, minor, prerelease);
}
}

private static int GetNonNegativeInt32(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var element))
{
throw new FormatException($"current-version.json is missing the required '{propertyName}' property.");
}

if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var value) && value >= 0)
{
return value;
}

throw new FormatException($"current-version.json property '{propertyName}' must be a non-negative integer.");
}

private static bool GetBoolean(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var element))
{
throw new FormatException($"current-version.json is missing the required '{propertyName}' property.");
}

return element.ValueKind switch {
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new FormatException($"current-version.json property '{propertyName}' must be a boolean."),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace Buildvana.Tool.Services.Versioning;
namespace Buildvana.Core.Versioning;

/// <summary>
/// Represents a Major.Minor[-Tag] version as found in version.json.
/// </summary>
internal sealed partial record VersionSpec
public sealed partial record VersionSpec
{
private static readonly Regex VersionSpecRegex = GetVersionSpecRegex();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// 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;
namespace Buildvana.Core.Versioning;

/// <summary>
/// Specifies how to modify the version specification upon publishing a release.
/// </summary>
internal enum VersionSpecChange
public enum VersionSpecChange
{
/// <summary>
/// Do not force a version increment; do not modify the unstable tag.
Expand Down
1 change: 1 addition & 0 deletions src/Buildvana.Tool/Buildvana.Tool.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<ProjectReference Include="..\Buildvana.Core.HomeDirectory\Buildvana.Core.HomeDirectory.csproj" />
<ProjectReference Include="..\Buildvana.Core.Json\Buildvana.Core.Json.csproj" />
<ProjectReference Include="..\Buildvana.Core.Process\Buildvana.Core.Process.csproj" />
<ProjectReference Include="..\Buildvana.Core.Versioning\Buildvana.Core.Versioning.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Buildvana.Tool/Services/Versioning/VersionFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Buildvana.Core;
using Buildvana.Core.HomeDirectory;
using Buildvana.Core.Json;
using Buildvana.Core.Versioning;
using CommunityToolkit.Diagnostics;

namespace Buildvana.Tool.Services.Versioning;
Expand Down
1 change: 1 addition & 0 deletions src/Buildvana.Tool/Services/Versioning/VersionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Buildvana.Core.ConsoleOutput;
using Buildvana.Core.Json;
using Buildvana.Core.Process;
using Buildvana.Core.Versioning;
using Buildvana.Tool.Services.Git;
using Buildvana.Tool.Services.PublicApiFiles;
using CommunityToolkit.Diagnostics;
Expand Down Expand Up @@ -114,69 +115,69 @@
/// <param name="requestedChange">The version spec change requested by the user.</param>
/// <param name="checkPublicApiFiles">If <see langword="true"/>, account for changes in public API files.</param>
/// <returns>A newly-created <see cref="VersionSpecChange"/> representing the actual change to apply.</returns>
public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedChange, bool checkPublicApiFiles)
{
// 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;
_reporter.Info($"Current version increment: {currentVersionIncrement}");

// Determine the kind of change in public API
var publicApiChangeKind = checkPublicApiFiles ? _publicApiFiles.GetApiChangeKind() : ApiChangeKind.None;
var notCheckedSuffix = checkPublicApiFiles ? string.Empty : " (not checked)";
_reporter.Info($"Public API change kind: {publicApiChangeKind}{notCheckedSuffix}");

// Determine the version increment required by SemVer rules
// 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 {
ApiChangeKind.Breaking => isMajorVersionZero ? VersionIncrement.Minor : VersionIncrement.Major,
ApiChangeKind.Additive => isMajorVersionZero ? VersionIncrement.None : VersionIncrement.Minor,
_ => VersionIncrement.None,
};
_reporter.Info($"Required version increment according to Semantic Versioning rules: {semanticVersionIncrement}");

// Determine the requested version increment, if any.
_reporter.Info($"Requested version spec change: {requestedChange}");
var requestedVersionIncrement = requestedChange switch {
VersionSpecChange.Major => VersionIncrement.Major,
VersionSpecChange.Minor => VersionIncrement.Minor,
_ => VersionIncrement.None,
};
_reporter.Info($"Requested version increment: {requestedVersionIncrement}.");

// Adjust requested version increment to follow SemVer rules
if (semanticVersionIncrement > requestedVersionIncrement)
{
requestedVersionIncrement = semanticVersionIncrement;
}

// Determine the kind of version increment actually required
var actualVersionIncrement = requestedVersionIncrement > currentVersionIncrement ? requestedVersionIncrement : VersionIncrement.None;
_reporter.Info($"Required version increment with respect to current version: {actualVersionIncrement}");

// 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 {
VersionSpecChange.Major or VersionSpecChange.Minor => VersionSpecChange.None,
_ => requestedChange,
};
actualChange = actualVersionIncrement switch {
VersionIncrement.Major => VersionSpecChange.Major,
VersionIncrement.Minor => VersionSpecChange.Minor,
_ => actualChange,
};
_reporter.Info($"Actual version spec change: {actualChange}.");
return actualChange;
}

/// <summary>
/// Update version information, typically after a commit.
/// </summary>

Check notice on line 180 in src/Buildvana.Tool/Services/Versioning/VersionService.cs

View check run for this annotation

codefactor.io / CodeFactor

src/Buildvana.Tool/Services/Versioning/VersionService.cs#L118-L180

Complex Method
public void Update() => (CurrentStr, Current, IsPublicRelease, IsPrerelease) = GetVersionInformationFromNbgv();

private (string CurrentStr, SemanticVersion Current, bool IsPublicRelease, bool IsPrerelease) GetVersionInformationFromNbgv()
Expand Down
1 change: 1 addition & 0 deletions src/Buildvana.Tool/Subcommands/ReleaseCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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;
Expand Down
2 changes: 1 addition & 1 deletion src/Buildvana.Tool/Subcommands/ReleaseSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$(StandardTfm)</TargetFramework>
<ClsCompliant>false</ClsCompliant>
<ImplicitUsings>true</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="TUnit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Buildvana.Core.Versioning\Buildvana.Core.Versioning.csproj" />
</ItemGroup>

</Project>
81 changes: 81 additions & 0 deletions tests/Buildvana.Core.Versioning.Tests/VersionCalculatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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.RegularExpressions;
using Buildvana.Core.Versioning;
using NuGet.Versioning;

internal sealed class VersionCalculatorTests
{
[Test]
public async Task Compute_StableVersion_BuildsMajorMinorHeight()
{
var result = Compute(new VersionFileData(2, 1, false), height: 7, branchName: "main");
await Assert.That(result.CurrentStr).IsEqualTo("2.1.7");
await Assert.That(result.SemanticVersion).IsEqualTo(new SemanticVersion(2, 1, 7));
await Assert.That(result.IsPrerelease).IsFalse();
}

[Test]
public async Task Compute_PrereleaseVersion_AppendsTagToHeight()
{
var result = Compute(new VersionFileData(2, 0, true), height: 3, prereleaseTag: "preview");
await Assert.That(result.CurrentStr).IsEqualTo("2.0.3-preview");
await Assert.That(result.IsPrerelease).IsTrue();
}

[Test]
public async Task Compute_PrereleaseWithoutTag_Throws()
{
await Assert.That(() => Compute(new VersionFileData(1, 0, true))).Throws<ArgumentException>();
}

[Test]
public async Task Compute_NegativeHeight_Throws()
{
await Assert.That(() => Compute(new VersionFileData(1, 0, false), height: -1))
.Throws<ArgumentOutOfRangeException>();
}

[Test]
public async Task Compute_BranchMatchesAnyPattern_IsPublicReleaseTrue()
{
IReadOnlyList<Regex> branches = [new Regex("^main$"), new Regex(@"^v\d+\.\d+$")];
var result = Compute(new VersionFileData(2, 0, false), branchName: "v2.0", publicReleaseBranches: branches);
await Assert.That(result.IsPublicRelease).IsTrue();
}

[Test]
public async Task Compute_BranchMatchesNoPattern_IsPublicReleaseFalse()
{
IReadOnlyList<Regex> branches = [new Regex("^main$")];
var result = Compute(new VersionFileData(2, 0, false), branchName: "topic", publicReleaseBranches: branches);
await Assert.That(result.IsPublicRelease).IsFalse();
}

[Test]
public async Task Compute_EmptyBranchList_IsPublicReleaseFalse()
{
var result = Compute(new VersionFileData(2, 0, false), branchName: "main");
await Assert.That(result.IsPublicRelease).IsFalse();
}

[Test]
public async Task Compute_DetachedHead_IsPublicReleaseFalse()
{
IReadOnlyList<Regex> branches = [new Regex("^main$")];
var result = Compute(
new VersionFileData(2, 0, false),
branchName: string.Empty,
publicReleaseBranches: branches);
await Assert.That(result.IsPublicRelease).IsFalse();
}

private static CalculatedVersion Compute(
VersionFileData fileData,
int height = 0,
string branchName = "",
IReadOnlyList<Regex>? publicReleaseBranches = null,
string? prereleaseTag = null)
=> VersionCalculator.Compute(fileData, height, branchName, publicReleaseBranches ?? [], prereleaseTag);
}
Loading