Skip to content

Commit 375cb8d

Browse files
authored
Merge pull request #4944 from robertcoltheart/feature/enhance-branch-label-with-env-vars
feat: add support for environment variables in branch labels
2 parents e7c669c + 8d1e896 commit 375cb8d

26 files changed

Lines changed: 240 additions & 71 deletions

docs/input/docs/learn/who.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ that we know about today.
1212
* [ChocolateyGUI](https://github.com/chocolatey/ChocolateyGUI)
1313
* [GitLink](https://github.com/GitTools/GitLink)
1414
* [OctopusDeploy](https://github.com/OctopusDeploy)
15-
* [NUKE](https://nuke.build)
1615
* [Orc.\* packages](https://github.com/wildgums?query=orc)
1716
* [Orchestra](https://github.com/wildgums/orchestra)
1817
* [Shouldly](https://github.com/shouldly/shouldly)

docs/input/docs/reference/mdsource/configuration.source.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,8 @@ of `alpha.foo` with `label: 'alpha.{BranchName}'` and `regex: '^features?[\/-](?
504504

505505
Another example: branch `features/sc-12345/some-description` would become a pre-release label of `sc-12345` with `label: '{StoryNo}'` and `regex: '^features?[\/-](?<StoryNo>sc-\d+)[-/].+'`.
506506

507+
You can also use environment variable placeholders with the `{env:VARIABLE_NAME}` syntax. Environment variable placeholders can also be combined with regex placeholders, for example `{BranchName}-{env:VARIABLE_NAME}`, and support fallback values using the `{env:VARIABLE_NAME ?? "fallback"}` syntax.
508+
507509
**Note:** To clear a default use an empty string: `label: ''`
508510

509511
### increment

src/GitVersion.Configuration.Tests/Configuration/ConfigurationExtensionsTests.cs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ namespace GitVersion.Configuration.Tests;
66
[TestFixture]
77
public class ConfigurationExtensionsTests : TestBase
88
{
9+
private const string BranchName = "pull-request";
10+
911
[TestCase("release/2.0.0",
1012
"refs/heads/release/2.0.0", "release/2.0.0", "release/2.0.0",
1113
true, false, false, false, true)]
@@ -51,7 +53,94 @@ public void EnsureGetBranchSpecificLabelWorksAsExpected(string branchName, strin
5153
.Build();
5254

5355
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(branchName));
54-
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(branchName), null);
56+
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(branchName), null, new TestEnvironment());
5557
actual.ShouldBe(expectedLabel);
5658
}
59+
60+
[Test]
61+
public void EnsureGetBranchSpecificLabelProcessesEnvironmentVariables()
62+
{
63+
var environment = new TestEnvironment();
64+
environment.SetEnvironmentVariable("GITHUB_HEAD_REF", "feature-branch");
65+
66+
var configuration = GitFlowConfigurationBuilder.New
67+
.WithoutBranches()
68+
.WithBranch(BranchName, builder => builder
69+
.WithLabel("pr-{env:GITHUB_HEAD_REF}")
70+
.WithRegularExpression(@"^pull[/-]"))
71+
.Build();
72+
73+
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(BranchName));
74+
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment);
75+
actual.ShouldBe("pr-feature-branch");
76+
}
77+
78+
[Test]
79+
public void EnsureGetBranchSpecificLabelProcessesEnvironmentVariablesWithFallback()
80+
{
81+
var environment = new TestEnvironment();
82+
// Don't set GITHUB_HEAD_REF to test fallback
83+
84+
var configuration = GitFlowConfigurationBuilder.New
85+
.WithoutBranches()
86+
.WithBranch(BranchName, builder => builder
87+
.WithLabel("pr-{env:GITHUB_HEAD_REF ?? \"unknown\"}")
88+
.WithRegularExpression(@"^pull[/-]"))
89+
.Build();
90+
91+
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(BranchName));
92+
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment);
93+
actual.ShouldBe("pr-unknown");
94+
}
95+
96+
[Test]
97+
public void EnsureGetBranchSpecificLabelProcessesEnvironmentVariablesAndRegexPlaceholders()
98+
{
99+
var environment = new TestEnvironment();
100+
environment.SetEnvironmentVariable("GITHUB_HEAD_REF", "feature-branch");
101+
102+
var configuration = GitFlowConfigurationBuilder.New
103+
.WithoutBranches()
104+
.WithBranch("feature/test-branch", builder => builder
105+
.WithLabel("{BranchName}-{env:GITHUB_HEAD_REF}")
106+
.WithRegularExpression(@"^features?[\/-](?<BranchName>.+)"))
107+
.Build();
108+
109+
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName("feature/test-branch"));
110+
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName("feature/test-branch"), null, environment);
111+
actual.ShouldBe("test-branch-feature-branch");
112+
}
113+
114+
[Test]
115+
public void EnsureGetBranchSpecificLabelWorksWithoutEnvironmentWhenNoEnvPlaceholders()
116+
{
117+
var configuration = GitFlowConfigurationBuilder.New
118+
.WithoutBranches()
119+
.WithBranch("feature/test", builder => builder
120+
.WithLabel("{BranchName}")
121+
.WithRegularExpression(@"^features?[\/-](?<BranchName>.+)"))
122+
.Build();
123+
124+
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName("feature/test"));
125+
var actual = effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName("feature/test"), null, new TestEnvironment());
126+
actual.ShouldBe("test");
127+
}
128+
129+
[Test]
130+
public void EnsureGetBranchSpecificLabelThrowsWhenThrowIfNotFoundAndEnvVarMissing()
131+
{
132+
var environment = new TestEnvironment();
133+
// Do not set MISSING_VAR
134+
135+
var configuration = GitFlowConfigurationBuilder.New
136+
.WithoutBranches()
137+
.WithBranch(BranchName, builder => builder
138+
.WithLabel("pr-{env:MISSING_VAR}")
139+
.WithRegularExpression(@"^pull[/-]"))
140+
.Build();
141+
142+
var effectiveConfiguration = configuration.GetEffectiveConfiguration(ReferenceName.FromBranchName(BranchName));
143+
Should.Throw<ArgumentException>(() =>
144+
effectiveConfiguration.GetBranchSpecificLabel(ReferenceName.FromBranchName(BranchName), null, environment));
145+
}
57146
}

src/GitVersion.Core/Extensions/ConfigurationExtensions.cs

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using GitVersion.Core;
22
using GitVersion.Extensions;
3+
using GitVersion.Formatting;
34
using GitVersion.Git;
45
using GitVersion.VersionCalculation;
56

@@ -97,38 +98,25 @@ private static bool ShouldBeIgnored(ICommit commit, IIgnoreConfiguration ignore)
9798

9899
extension(EffectiveConfiguration configuration)
99100
{
100-
public string? GetBranchSpecificLabel(ReferenceName branchName, string? branchNameOverride)
101-
=> GetBranchSpecificLabel(configuration, branchName.WithoutOrigin, branchNameOverride);
101+
public string? GetBranchSpecificLabel(ReferenceName branchName, string? branchNameOverride, IEnvironment environment)
102+
=> GetBranchSpecificLabel(configuration, branchName.WithoutOrigin, branchNameOverride, environment);
102103

103-
public string? GetBranchSpecificLabel(string? branchName, string? branchNameOverride)
104+
public string? GetBranchSpecificLabel(string? branchName, string? branchNameOverride, IEnvironment environment)
104105
{
105106
configuration.NotNull();
107+
environment.NotNull();
106108

107109
var label = configuration.Label;
110+
108111
if (label is null)
109112
{
110113
return label;
111114
}
112115

113116
var effectiveBranchName = branchNameOverride ?? branchName;
114-
if (configuration.RegularExpression.IsNullOrWhiteSpace() || effectiveBranchName.IsNullOrEmpty()) return label;
115-
var regex = RegexPatterns.Cache.GetOrAdd(configuration.RegularExpression);
116-
var match = regex.Match(effectiveBranchName);
117-
if (!match.Success) return label;
118-
foreach (var groupName in regex.GetGroupNames())
119-
{
120-
var groupValue = match.Groups[groupName].Value;
121-
Lazy<string> escapedGroupValueLazy = new(() => groupValue.RegexReplace(RegexPatterns.SanitizeNameRegexPattern, "-"));
122-
var placeholder = $"{{{groupName}}}";
123-
int index, startIndex = 0;
124-
while ((index = label.IndexOf(placeholder, startIndex, StringComparison.InvariantCulture)) >= 0)
125-
{
126-
var escapedGroupValue = escapedGroupValueLazy.Value;
127-
label = label.Remove(index, placeholder.Length).Insert(index, escapedGroupValue);
128-
startIndex = index + escapedGroupValue.Length;
129-
}
130-
}
131-
return label;
117+
var labelPlaceholders = BuildLabelPlaceholders(configuration.RegularExpression, effectiveBranchName);
118+
119+
return label.FormatWith(labelPlaceholders, environment);
132120
}
133121

134122
public TaggedSemanticVersions GetTaggedSemanticVersion()
@@ -153,5 +141,27 @@ public TaggedSemanticVersions GetTaggedSemanticVersion()
153141
}
154142
return taggedSemanticVersion;
155143
}
144+
145+
private static Dictionary<string, object> BuildLabelPlaceholders(string? regularExpression, string? effectiveBranchName)
146+
{
147+
var placeholders = new Dictionary<string, object>();
148+
149+
if (regularExpression.IsNullOrWhiteSpace() || effectiveBranchName.IsNullOrEmpty())
150+
return placeholders;
151+
152+
var regex = RegexPatterns.Cache.GetOrAdd(regularExpression);
153+
var match = regex.Match(effectiveBranchName);
154+
155+
if (!match.Success)
156+
return placeholders;
157+
158+
foreach (var groupName in regex.GetGroupNames())
159+
{
160+
var groupValue = match.Groups[groupName].Value;
161+
placeholders[groupName] = groupValue.RegexReplace(RegexPatterns.SanitizeNameRegexPattern, "-");
162+
}
163+
164+
return placeholders;
165+
}
156166
}
157167
}

src/GitVersion.Core/Formatting/StringFormatWithExtension.cs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,51 @@ internal static class StringFormatWithExtension
3737
/// "{env:BUILD_NUMBER}".FormatWith(new { }, env);
3838
/// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env);
3939
/// </example>
40-
public string FormatWith<T>(T? source, IEnvironment environment)
40+
public string FormatWith(object source, IEnvironment environment)
41+
{
42+
ArgumentNullException.ThrowIfNull(source);
43+
44+
return template.FormatWith((member, format, fallback) => EvaluateMemberFromObject(source, member, format, fallback), environment);
45+
}
46+
47+
/// <summary>
48+
/// Formats the <paramref name="template"/>, replacing each expression wrapped in curly braces
49+
/// with the corresponding property from the <paramref name="source"/> or <paramref name="environment"/>.
50+
/// </summary>
51+
/// <param name="source">The source object to apply to the <paramref name="template"/></param>
52+
/// <param name="environment"></param>
53+
/// <exception cref="ArgumentNullException">The <paramref name="template"/> is null.</exception>
54+
/// <exception cref="ArgumentException">An environment variable was null and no fallback was provided.</exception>
55+
/// <remarks>
56+
/// An expression containing "." is treated as a property or field access on the <paramref name="source"/>.
57+
/// An expression starting with "env:" is replaced with the value of the corresponding variable from the <paramref name="environment"/>.
58+
/// Each expression may specify a single hardcoded fallback value using the {Prop ?? "fallback"} syntax, which applies if the expression evaluates to null.
59+
/// </remarks>
60+
/// <example>
61+
/// // replace an expression with a property value
62+
/// "Hello {Name}".FormatWith(new { Name = "Fred" }, env);
63+
/// "Hello {Name ?? \"Fred\"}".FormatWith(new { Name = GetNameOrNull() }, env);
64+
/// // replace an expression with an environment variable
65+
/// "{env:BUILD_NUMBER}".FormatWith(new { }, env);
66+
/// "{env:BUILD_NUMBER ?? \"0\"}".FormatWith(new { }, env);
67+
/// </example>
68+
public string FormatWith(IDictionary<string, object> source, IEnvironment environment)
4169
{
42-
ArgumentNullException.ThrowIfNull(template);
4370
ArgumentNullException.ThrowIfNull(source);
4471

72+
return template.FormatWith((member, format, fallback) => EvaluateMemberFromDictionary(source, member, format, fallback), environment);
73+
}
74+
75+
private string FormatWith(EvaluateMemberDelegate memberEvaluator, IEnvironment environment)
76+
{
77+
ArgumentNullException.ThrowIfNull(template);
78+
4579
var result = new StringBuilder();
4680
var lastIndex = 0;
4781

4882
foreach (var match in RegexPatterns.ExpandTokensRegex.Matches(template).Cast<Match>())
4983
{
50-
var replacement = EvaluateMatch(match, source, environment);
84+
var replacement = EvaluateMatch(match, memberEvaluator, environment);
5185
result.Append(template, lastIndex, match.Index - lastIndex);
5286
result.Append(replacement);
5387
lastIndex = match.Index + match.Length;
@@ -58,7 +92,7 @@ public string FormatWith<T>(T? source, IEnvironment environment)
5892
}
5993
}
6094

61-
private static string EvaluateMatch<T>(Match match, T source, IEnvironment environment)
95+
private static string EvaluateMatch(Match match, EvaluateMemberDelegate memberEvaluator, IEnvironment environment)
6296
{
6397
var fallback = match.Groups["fallback"].Success ? match.Groups["fallback"].Value : null;
6498

@@ -68,7 +102,7 @@ private static string EvaluateMatch<T>(Match match, T source, IEnvironment envir
68102
if (match.Groups["member"].Success)
69103
{
70104
var format = match.Groups["format"].Success ? match.Groups["format"].Value : null;
71-
return EvaluateMember(source, match.Groups["member"].Value, format, fallback);
105+
return memberEvaluator(match.Groups["member"].Value, format, fallback);
72106
}
73107

74108
throw new ArgumentException($"Invalid token format: '{match.Value}'");
@@ -82,7 +116,7 @@ private static string EvaluateEnvVar(string name, string? fallback, IEnvironment
82116
?? throw new ArgumentException($"Environment variable {safeName} not found and no fallback provided");
83117
}
84118

85-
private static string EvaluateMember<T>(T source, string member, string? format, string? fallback)
119+
private static string EvaluateMemberFromObject(object source, string member, string? format, string? fallback)
86120
{
87121
var safeMember = InputSanitizer.SanitizeMemberName(member);
88122
var memberPath = MemberResolver.ResolveMemberPath(source!.GetType(), safeMember);
@@ -93,13 +127,31 @@ private static string EvaluateMember<T>(T source, string member, string? format,
93127
return fallback ?? string.Empty;
94128

95129
if (format is not null && ValueFormatter.Default.TryFormat(
96-
value,
97-
InputSanitizer.SanitizeFormat(format),
98-
out var formatted))
130+
value,
131+
InputSanitizer.SanitizeFormat(format),
132+
out var formatted))
99133
{
100134
return formatted;
101135
}
102136

103137
return value.ToString() ?? fallback ?? string.Empty;
104138
}
139+
140+
private static string EvaluateMemberFromDictionary(IDictionary<string, object> source, string member, string? format, string? fallback)
141+
{
142+
var safeMember = InputSanitizer.SanitizeMemberName(member);
143+
144+
if (!source.TryGetValue(safeMember, out var value))
145+
return fallback ?? string.Empty;
146+
147+
if (value is null)
148+
return fallback ?? string.Empty;
149+
150+
if (format is not null && ValueFormatter.Default.TryFormat(value, InputSanitizer.SanitizeFormat(format), out var formatted))
151+
return formatted;
152+
153+
return value.ToString() ?? fallback ?? string.Empty;
154+
}
155+
156+
private delegate string EvaluateMemberDelegate(string member, string? format, string? fallback);
105157
}

src/GitVersion.Core/VersionCalculation/Mainline/EnrichIncrement.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public void Enrich(MainlineIteration iteration, MainlineCommit commit, MainlineC
1919

2020
if (commit.Predecessor is not null && commit.Predecessor.BranchName != commit.BranchName)
2121
context.Label = null;
22-
context.Label ??= effectiveConfiguration.GetBranchSpecificLabel(commit.BranchName, null);
22+
context.Label ??= effectiveConfiguration.GetBranchSpecificLabel(commit.BranchName, null, context.Environment);
2323

2424
if (effectiveConfiguration.IsMainBranch)
2525
context.BaseVersionSource = commit.Predecessor?.Value;

src/GitVersion.Core/VersionCalculation/Mainline/EnrichSemanticVersion.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ public void Enrich(MainlineIteration iteration, MainlineCommit commit, MainlineC
99
{
1010
var branchSpecificLabel = context.TargetLabel;
1111
branchSpecificLabel ??= iteration.GetEffectiveConfiguration(context.Configuration)
12-
.GetBranchSpecificLabel(commit.BranchName, null);
12+
.GetBranchSpecificLabel(commit.BranchName, null, context.Environemnt);
1313
branchSpecificLabel ??= commit.GetEffectiveConfiguration(context.Configuration)
14-
.GetBranchSpecificLabel(commit.BranchName, null);
14+
.GetBranchSpecificLabel(commit.BranchName, null, context.Environment);
1515

1616
var semanticVersions = commit.SemanticVersions.Where(
1717
element => element.IsMatchForBranchSpecificLabel(branchSpecificLabel)

src/GitVersion.Core/VersionCalculation/Mainline/MainlineContext.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
namespace GitVersion.VersionCalculation.Mainline;
66

7-
internal record MainlineContext(IIncrementStrategyFinder IncrementStrategyFinder, IGitVersionConfiguration Configuration)
7+
internal record MainlineContext(IIncrementStrategyFinder IncrementStrategyFinder, IGitVersionConfiguration Configuration, IEnvironment Environment)
88
{
99
public IIncrementStrategyFinder IncrementStrategyFinder { get; } = IncrementStrategyFinder.NotNull();
1010

1111
public IGitVersionConfiguration Configuration { get; } = Configuration.NotNull();
1212

13+
public IEnvironment Environemnt { get; } = Environment.NotNull();
14+
1315
public string? TargetLabel { get; init; }
1416

1517
public SemanticVersion? SemanticVersion { get; set; }

src/GitVersion.Core/VersionCalculation/Mainline/NonTrunk/CommitOnNonTrunk.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public IEnumerable<IBaseVersionIncrement> GetIncrements(
2222
context.Label = null;
2323

2424
var effectiveConfiguration = commit.GetEffectiveConfiguration(context.Configuration);
25-
context.Label ??= effectiveConfiguration.GetBranchSpecificLabel(commit.BranchName, null);
25+
context.Label ??= effectiveConfiguration.GetBranchSpecificLabel(commit.BranchName, null, context.Environment);
2626

2727
if (commit.Successor is not null) yield break;
2828
yield return new BaseVersionOperator

src/GitVersion.Core/VersionCalculation/Mainline/NonTrunk/CommitOnNonTrunkBranchedBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public virtual IEnumerable<IBaseVersionIncrement> GetIncrements(
2121
context.Increment = context.Increment.Consolidate(incrementForcedByBranch);
2222

2323
var iterationEffectiveConfiguration = iteration.GetEffectiveConfiguration(context.Configuration);
24-
context.Label = iterationEffectiveConfiguration.GetBranchSpecificLabel(iteration.BranchName, null) ?? context.Label;
24+
context.Label = iterationEffectiveConfiguration.GetBranchSpecificLabel(iteration.BranchName, null, context.Environment) ?? context.Label;
2525
context.ForceIncrement = true;
2626

2727
yield return new BaseVersionOperator

0 commit comments

Comments
 (0)