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);