diff --git a/docfx/docs/versionJson.md b/docfx/docs/versionJson.md index a2bf7551d..9f3cf8574 100644 --- a/docfx/docs/versionJson.md +++ b/docfx/docs/versionJson.md @@ -65,7 +65,8 @@ The content of the version.json file is a JSON serialized object with these prop "versionIncrement" : "minor", "firstUnstableTag" : "alpha" }, - "inherit": false // optional. Set to true in secondary version.json files used to tweak settings for subsets of projects. + "inherit": false, // optional. Set to true in secondary version.json files used to tweak settings for subsets of projects. + "prerelease": "beta" // optional. Only valid when inherit is true. Adds or overrides the prerelease tag of the inherited version. } ``` @@ -109,4 +110,80 @@ In this example, the offset of 100 will be applied as long as the version remain > [!NOTE] > This feature is particularly useful when a `version.json` file uses `"inherit": true` to get the version from a parent `version.json` file higher in the source tree. In such cases, you can set `versionHeightOffset` and `versionHeightOffsetAppliesTo` in the inheriting file without having to update it when the parent version changes. The offset will automatically stop applying when the inherited version no longer matches `versionHeightOffsetAppliesTo`. +## Prerelease Tag in Inheriting Files + +The `prerelease` property allows a version.json file that inherits from a parent to add or override the prerelease tag without duplicating the version number. This is particularly useful when you want to publish an "unstable" prerelease from a stable branch, or when one package is still considered unstable while its peers are already stable. + +### Usage Rules + +- The `prerelease` property can **only** be used when `"inherit": true` is set. +- The `version` property in the inheriting file must **not** include a prerelease tag (the `-suffix` part). +- Setting `"prerelease": "beta"` will append `-beta` to the inherited version. +- Setting `"prerelease": ""` (empty string) will explicitly **suppress** any prerelease tag inherited from the parent. +- Omitting the `prerelease` property will inherit the prerelease tag as-is from the parent. + +### Examples + +**Example 1: Adding a prerelease tag to a stable inherited version** + +Parent `version.json` at repository root: +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.2" +} +``` + +Child `version.json` in a subdirectory (e.g., `src/ExperimentalPackage/version.json`): +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "inherit": true, + "prerelease": "beta" +} +``` + +Result: The subdirectory will use version `1.2-beta` while the rest of the repository uses `1.2`. + +**Example 2: Suppressing an inherited prerelease tag** + +Parent `version.json` at repository root: +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.2-alpha" +} +``` + +Child `version.json` in a subdirectory (e.g., `src/StablePackage/version.json`): +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "inherit": true, + "prerelease": "" +} +``` + +Result: The subdirectory will use version `1.2` (stable) while the rest of the repository uses `1.2-alpha`. + +**Example 3: Inheriting the prerelease tag as-is** + +Parent `version.json` at repository root: +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.2-rc" +} +``` + +Child `version.json` in a subdirectory: +```json +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + "inherit": true +} +``` + +Result: The subdirectory will inherit and use version `1.2-rc` from the parent. + [Learn more about pathFilters](path-filters.md). diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs index 7f09c9104..3c18fbf55 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs @@ -126,6 +126,7 @@ internal LibGit2VersionFile(LibGit2Context context) } JsonConvert.PopulateObject(versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: searchDirectory)); + ApplyPrereleaseProperty(result); result.Inherit = false; } } diff --git a/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs b/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs index 292054bb2..cc1fd42db 100644 --- a/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs +++ b/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs @@ -141,6 +141,7 @@ public ManagedVersionFile(GitContext context) } JsonConvert.PopulateObject(versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: searchDirectory)); + ApplyPrereleaseProperty(result); result.Inherit = false; } else diff --git a/src/NerdBank.GitVersioning/VersionFile.cs b/src/NerdBank.GitVersioning/VersionFile.cs index 92d77d482..1fe16e294 100644 --- a/src/NerdBank.GitVersioning/VersionFile.cs +++ b/src/NerdBank.GitVersioning/VersionFile.cs @@ -197,6 +197,70 @@ protected static bool VersionOptionsSatisfyRequirements(VersionOptions? options, return true; } + /// + /// Applies the standalone property to the property. + /// + /// The version options to modify. + /// + /// This method should be called after merging a child version.json with a parent version.json. + /// If the child specified a property, this method will apply it to the version. + /// + protected static void ApplyPrereleaseProperty(VersionOptions options) + { + Requires.NotNull(options, nameof(options)); + + // Only apply if the Prerelease property was explicitly set + if (options.Prerelease is null) + { + return; + } + + // The version must exist to apply a prerelease tag + if (options.Version is null) + { + throw new InvalidOperationException("The 'prerelease' property cannot be used without a 'version' property."); + } + + // If prerelease is an empty string, it suppresses any inherited prerelease tag + if (options.Prerelease == string.Empty) + { + // Remove any existing prerelease tag + if (!string.IsNullOrEmpty(options.Version.Prerelease)) + { + options.Version = new SemanticVersion( + options.Version.Version, + null, + options.Version.BuildMetadata); + } + + options.Prerelease = null; + return; + } + + // Validate that the version doesn't already have a prerelease tag (non-empty prerelease being applied) + if (!string.IsNullOrEmpty(options.Version.Prerelease)) + { + throw new InvalidOperationException("The 'prerelease' property cannot be used when the 'version' property already includes a prerelease tag."); + } + + // Apply the prerelease tag to the version + string prereleaseTag = options.Prerelease; + if (!prereleaseTag.StartsWith("-", StringComparison.Ordinal)) + { + // Add the hyphen prefix if not present + prereleaseTag = "-" + prereleaseTag; + } + + // Create a new SemanticVersion with the prerelease tag applied + options.Version = new SemanticVersion( + options.Version.Version, + prereleaseTag, + options.Version.BuildMetadata); + + // Clear the Prerelease property since it has been applied + options.Prerelease = null; + } + protected static string TrimTrailingPathSeparator(string path) => path.Length > 0 && (path[^1] == Path.DirectorySeparatorChar || path[^1] == Path.AltDirectorySeparatorChar) ? path[..^1] : path; @@ -319,6 +383,7 @@ protected void ApplyLocations(VersionOptions? options, string currentLocation, r versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: repoRelativeBaseDirectory)); + ApplyPrereleaseProperty(result); result.Inherit = false; } } diff --git a/src/NerdBank.GitVersioning/VersionOptions.cs b/src/NerdBank.GitVersioning/VersionOptions.cs index 8d1475c9f..85ca75fd5 100644 --- a/src/NerdBank.GitVersioning/VersionOptions.cs +++ b/src/NerdBank.GitVersioning/VersionOptions.cs @@ -133,6 +133,12 @@ public class VersionOptions : IEquatable [DebuggerBrowsable(DebuggerBrowsableState.Never)] private bool inherit; + /// + /// Backing field for the property. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string? prerelease; + /// /// Initializes a new instance of the class. /// @@ -161,6 +167,7 @@ public VersionOptions(VersionOptions copyFrom) this.cloudBuild = copyFrom.cloudBuild is object ? new CloudBuildOptions(copyFrom.cloudBuild) : null; this.release = copyFrom.release is object ? new ReleaseOptions(copyFrom.release) : null; this.pathFilters = copyFrom.pathFilters?.ToList(); + this.prerelease = copyFrom.prerelease; } /// @@ -556,6 +563,27 @@ public bool Inherit set => this.SetIfNotReadOnly(ref this.inherit, value); } + /// + /// Gets or sets a prerelease tag to append to an inherited version. + /// + /// + /// + /// This property is only valid when is and the property + /// does not already include a prerelease tag. When set, this prerelease tag will be appended to the version number + /// inherited from the parent version.json file. + /// + /// + /// Setting this to an empty string explicitly suppresses any prerelease tag that might be inherited. + /// Omitting this property (leaving it as ) means the prerelease tag will be inherited as-is from the parent. + /// + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string? Prerelease + { + get => this.prerelease; + set => this.SetIfNotReadOnly(ref this.prerelease, value); + } + /// /// Gets a value indicating whether this instance rejects all attempts to mutate it. /// diff --git a/src/NerdBank.GitVersioning/version.schema.json b/src/NerdBank.GitVersioning/version.schema.json index 814eb5214..e534aa6e6 100644 --- a/src/NerdBank.GitVersioning/version.schema.json +++ b/src/NerdBank.GitVersioning/version.schema.json @@ -16,6 +16,7 @@ "inheritingFile": { "allOf": [ { "$ref": "#/definitions/allProperties" }, + { "$ref": "#/definitions/inheritingProperties" }, { "required": [ "inherit" ] }, { "properties": { @@ -217,6 +218,15 @@ } } }, + "inheritingProperties": { + "properties": { + "prerelease": { + "type": "string", + "description": "A prerelease tag to append to the version inherited from a parent version.json file. This property can only be used when 'inherit' is true and the 'version' property does not include a prerelease tag. Set to an empty string to explicitly suppress an inherited prerelease tag. Omit this property to inherit the prerelease tag as-is from the parent.", + "pattern": "^[\\da-zA-Z\\-]*$" + } + } + }, "twoToFourComponentVersion": { "type": "string", "description": "A major.minor[.build[.revision]] version (2-4 version components).", diff --git a/test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs b/test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs index 5e644ccf5..dc07945cd 100644 --- a/test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs +++ b/test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs @@ -712,6 +712,213 @@ public void GetVersion_CaseInsensitivePathMatching() } } + [Fact] + public void Prerelease_AddedToInheritedVersion() + { + // Arrange: Create a parent version.json with a stable version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json that inherits and adds a prerelease tag + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + Prerelease = "beta", + }, + "child"); + + // Act: Get the version from the child directory + using GitContext context = this.CreateGitContext(Path.Combine(this.RepoPath, "child")); + VersionOptions childOptions = context.VersionFile.GetVersion(); + + // Assert: The child should have the inherited version with the beta prerelease tag + Assert.NotNull(childOptions); + Assert.Equal("1.2-beta", childOptions.Version.ToString()); + Assert.False(childOptions.Inherit); // Inherit should be false after merging + Assert.Null(childOptions.Prerelease); // Prerelease should be null after being applied + } + + [Fact] + public void Prerelease_SuppressesInheritedPrerelease() + { + // Arrange: Create a parent version.json with a prerelease version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2-alpha"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json that inherits and suppresses the prerelease tag + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + Prerelease = string.Empty, + }, + "child"); + + // Act: Get the version from the child directory + using GitContext context = this.CreateGitContext(Path.Combine(this.RepoPath, "child")); + VersionOptions childOptions = context.VersionFile.GetVersion(); + + // Assert: The child should have the inherited version without prerelease tag + Assert.NotNull(childOptions); + Assert.Equal("1.2", childOptions.Version.ToString()); + Assert.False(childOptions.Inherit); + Assert.Null(childOptions.Prerelease); + } + + [Fact] + public void Prerelease_InheritsAsIs_WhenNotSpecified() + { + // Arrange: Create a parent version.json with a prerelease version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2-rc"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json that inherits without specifying prerelease + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + }, + "child"); + + // Act: Get the version from the child directory + using GitContext context = this.CreateGitContext(Path.Combine(this.RepoPath, "child")); + VersionOptions childOptions = context.VersionFile.GetVersion(); + + // Assert: The child should have the inherited version with the rc prerelease tag + Assert.NotNull(childOptions); + Assert.Equal("1.2-rc", childOptions.Version.ToString()); + Assert.False(childOptions.Inherit); + } + + [Fact] + public void Prerelease_ThrowsWhen_VersionAlreadyHasPrerelease() + { + // Arrange: Create a parent version.json with a stable version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json that specifies both version with prerelease AND prerelease property + // This creates an invalid state that should throw when read. + // Note: We manually create the JSON here (rather than using WriteVersionFile) because + // this is an intentionally invalid configuration that cannot be represented through the API. + string childPath = Path.Combine(this.RepoPath, "child"); + Directory.CreateDirectory(childPath); + string versionJsonPath = Path.Combine(childPath, "version.json"); + string invalidJson = "{ \"inherit\": true, \"version\": \"1.2-alpha\", \"prerelease\": \"beta\" }"; + File.WriteAllText(versionJsonPath, invalidJson); + + // Act & Assert: Attempting to get the version should throw + using GitContext context = this.CreateGitContext(childPath); + Assert.Throws(() => context.VersionFile.GetVersion()); + } + + [Fact] + public void Prerelease_ThrowsWhen_InheritedVersionHasPrereleaseAndChildSpecifiesPrerelease() + { + // Arrange: Create a parent version.json with a prerelease version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2-alpha"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json that tries to override the prerelease with a non-empty value + // This should throw because you can't override a non-empty prerelease with another non-empty value + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + Prerelease = "beta", + }, + "child"); + + // Act & Assert: Attempting to get the version should throw + using GitContext context = this.CreateGitContext(Path.Combine(this.RepoPath, "child")); + Assert.Throws(() => context.VersionFile.GetVersion()); + } + + [Fact] + public void Prerelease_MultiLevel_Inheritance() + { + // Arrange: Create a three-level hierarchy + // Level 1 (root): stable version + this.WriteVersionFile(new VersionOptions + { + Version = SemanticVersion.Parse("1.2"), + }); + + // Level 2: inherits and adds beta tag + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + Prerelease = "beta", + }, + "level2"); + + // Level 3: inherits from level 2 (which already has beta applied) + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + }, + "level2/level3"); + + // Act: Get versions from each level + using GitContext level1Context = this.CreateGitContext(this.RepoPath); + using GitContext level2Context = this.CreateGitContext(Path.Combine(this.RepoPath, "level2")); + using GitContext level3Context = this.CreateGitContext(Path.Combine(this.RepoPath, "level2/level3")); + + VersionOptions level1Options = level1Context.VersionFile.GetVersion(); + VersionOptions level2Options = level2Context.VersionFile.GetVersion(); + VersionOptions level3Options = level3Context.VersionFile.GetVersion(); + + // Assert + Assert.Equal("1.2", level1Options.Version.ToString()); + Assert.Equal("1.2-beta", level2Options.Version.ToString()); + Assert.Equal("1.2-beta", level3Options.Version.ToString()); + } + + [Fact] + public void Prerelease_WithoutHyphen_IsHandledCorrectly() + { + // Arrange: Create a parent version.json with a stable version + VersionOptions parent = new VersionOptions + { + Version = SemanticVersion.Parse("1.2"), + }; + this.WriteVersionFile(parent); + + // Create a child version.json with prerelease without hyphen (should be added automatically) + this.WriteVersionFile( + new VersionOptions + { + Inherit = true, + Prerelease = "alpha", // No hyphen + }, + "child"); + + // Act + using GitContext context = this.CreateGitContext(Path.Combine(this.RepoPath, "child")); + VersionOptions childOptions = context.VersionFile.GetVersion(); + + // Assert: The hyphen should be added automatically + Assert.Equal("1.2-alpha", childOptions.Version.ToString()); + } + private void AssertPathHasVersion(string committish, string absolutePath, VersionOptions expected) { VersionOptions actual = this.GetVersionOptions(absolutePath, committish);