diff --git a/docs/input/docs/reference/build-servers/gitlab.md b/docs/input/docs/reference/build-servers/gitlab.md index aa97d9e5fd..0e8b16db94 100755 --- a/docs/input/docs/reference/build-servers/gitlab.md +++ b/docs/input/docs/reference/build-servers/gitlab.md @@ -9,6 +9,10 @@ To use GitVersion with GitLab CI, either use the [MSBuild Task](/docs/usage/msbuild) or put the GitVersion executable in your runner's `PATH`. +### Merge Request pipelines + +GitVersion supports GitLab Merge Request refs natively. In MR pipelines, GitLab sets `CI_MERGE_REQUEST_REF_PATH` (e.g. `refs/merge-requests/15/head` or `refs/merge-requests/15/merge`). GitVersion uses this variable when present and treats the ref as a pull-request branch, exposing it as `pull-requests/` so that your `pull-request` configuration in GitVersion.yml applies without any CI workarounds (no need to create synthetic refs under `refs/heads/`). The branch name matches the default regex `^(pull-requests|pull|pr)[\/-](?\d*)`. + A working example of integrating GitVersion with GitLab is maintained in the project [Utterly Automated Versioning][utterly-automated-versioning] Here is a summary of what it demonstrated (many more details in the [Readme][readme]) diff --git a/docs/input/docs/usage/docker.md b/docs/input/docs/usage/docker.md index f7d7c2cb96..0a223c252b 100644 --- a/docs/input/docs/usage/docker.md +++ b/docs/input/docs/usage/docker.md @@ -34,6 +34,12 @@ On GitHub Actions, you may need to set the following environment variables: docker run --rm -v "$(pwd):/repo" --env GITHUB_ACTIONS=true --env GITHUB_REF=$(GITHUB_REF) gittools/gitversion:{tag} /repo ``` +On GitLab CI (including Merge Request pipelines), pass the GitLab variables so GitVersion can detect the branch or MR ref: + +```sh +docker run --rm -v "$(pwd):/repo" --env GITLAB_CI=true --env CI_MERGE_REQUEST_REF_PATH=$CI_MERGE_REQUEST_REF_PATH --env CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME --env CI_COMMIT_TAG=$CI_COMMIT_TAG gittools/gitversion:{tag} /repo +``` + ### Tags Most of the tags we provide have both arm64 and amd64 variants. If you need to pull a architecture specific tag you can do that like: diff --git a/src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs b/src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs index c0dbf74ee0..c6d3355dca 100644 --- a/src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs +++ b/src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs @@ -191,7 +191,9 @@ private static async Task VerifyPullRequestVersionIsCalculatedProperly(string pu new object[] { "refs/pull-requests/5/merge", "refs/pull-requests/5/merge", false, true, false }, new object[] { "refs/pull/5/merge", "refs/pull/5/merge", false, true, false }, new object[] { "refs/heads/pull/5/head", "pull/5/head", true, false, false }, - new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true } + new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true }, + new object[] { "refs/merge-requests/15/head", "pull-requests/15", false, true, false }, + new object[] { "refs/merge-requests/15/merge", "pull-requests/15", false, true, false } ]; [TestCaseSource(nameof(PrMergeRefInputs))] diff --git a/src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs b/src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs index 5e6ebb9b40..aa71977c3e 100644 --- a/src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs +++ b/src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs @@ -26,7 +26,13 @@ public void SetUp() } [TearDown] - public void TearDown() => this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null); + public void TearDown() + { + this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, null); + this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, null); + this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, null); + } [Test] public void ShouldSetBuildNumber() @@ -50,7 +56,7 @@ public void ShouldSetOutputVariables() [TestCase("#3-change_projectname", "#3-change_projectname")] public void GetCurrentBranchShouldHandleBranches(string branchName, string expectedResult) { - this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName); var result = this.buildServer.GetCurrentBranch(false); @@ -63,8 +69,8 @@ public void GetCurrentBranchShouldHandleBranches(string branchName, string expec [TestCase("v1.2.1", "v1.2.1", null)] public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag, string? expectedResult) { - this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName); - this.environment.SetEnvironmentVariable("CI_COMMIT_TAG", commitTag); // only set in pipelines for tags + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName); + this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, commitTag); // only set in pipelines for tags var result = this.buildServer.GetCurrentBranch(false); @@ -85,13 +91,49 @@ public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag [TestCase("#3-change_projectname", "#3-change_projectname")] public void GetCurrentBranchShouldHandlePullRequests(string branchName, string expectedResult) { - this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName); var result = this.buildServer.GetCurrentBranch(false); result.ShouldBe(expectedResult); } + [TestCase("refs/merge-requests/15/head")] + [TestCase("refs/merge-requests/15/merge")] + [TestCase("refs/merge-requests/1/head")] + public void GetCurrentBranch_WhenMergeRequestRefPathSet_ReturnsMergeRequestRefPath(string mrRefPath) + { + this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, mrRefPath); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "some-branch"); + + var result = this.buildServer.GetCurrentBranch(false); + + result.ShouldBe(mrRefPath); + } + + [Test] + public void GetCurrentBranch_WhenMergeRequestRefPathAndCommitRefNameSet_PrefersMergeRequestRefPath() + { + this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/42/head"); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "feature/foo"); + + var result = this.buildServer.GetCurrentBranch(false); + + result.ShouldBe("refs/merge-requests/42/head"); + } + + [Test] + public void GetCurrentBranch_WhenTagSet_ReturnsNull() + { + this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, "v1.0.0"); + this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/10/head"); + this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "main"); + + var result = this.buildServer.GetCurrentBranch(false); + + result.ShouldBeNull(); + } + [Test] public void WriteAllVariablesToTheTextWriter() { diff --git a/src/GitVersion.BuildAgents/Agents/GitLabCi.cs b/src/GitVersion.BuildAgents/Agents/GitLabCi.cs index 90308f1e5e..e3b54eb946 100644 --- a/src/GitVersion.BuildAgents/Agents/GitLabCi.cs +++ b/src/GitVersion.BuildAgents/Agents/GitLabCi.cs @@ -7,6 +7,10 @@ namespace GitVersion.Agents; internal class GitLabCi : BuildAgentBase { public const string EnvironmentVariableName = "GITLAB_CI"; + public const string CommitRefNameEnvironmentVariableName = "CI_COMMIT_REF_NAME"; + public const string CommitTagEnvironmentVariableName = "CI_COMMIT_TAG"; + public const string MergeRequestRefPathEnvironmentVariableName = "CI_MERGE_REQUEST_REF_PATH"; + private string? file; public GitLabCi(IEnvironment environment, ILog log, IFileSystem fileSystem) : base(environment, log, fileSystem) => WithPropertyFile("gitversion.properties"); @@ -22,14 +26,16 @@ public override string[] SetOutputVariables(string name, string? value) => $"GitVersion_{name}={value}" ]; - // CI_COMMIT_REF_NAME can contain either the branch or the tag - // See https://docs.gitlab.com/ee/ci/variables/predefined_variables.html - // CI_COMMIT_TAG is only available in tag pipelines, - // so we can exit if CI_COMMIT_REF_NAME would return the tag - public override string? GetCurrentBranch(bool usingDynamicRepos) => - string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable("CI_COMMIT_TAG")) - ? this.Environment.GetEnvironmentVariable("CI_COMMIT_REF_NAME") - : null; + // CI_COMMIT_REF_NAME = branch/tag name. In MR pipelines, CI_MERGE_REQUEST_REF_PATH = refs/merge-requests//head. + public override string? GetCurrentBranch(bool usingDynamicRepos) + { + if (!string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable(CommitTagEnvironmentVariableName))) + return null; + var mrRef = this.Environment.GetEnvironmentVariable(MergeRequestRefPathEnvironmentVariableName); + if (!string.IsNullOrEmpty(mrRef)) + return mrRef; + return this.Environment.GetEnvironmentVariable(CommitRefNameEnvironmentVariableName); + } public override bool PreventFetch() => true; diff --git a/src/GitVersion.Core/Core/GitPreparer.cs b/src/GitVersion.Core/Core/GitPreparer.cs index 2258d4ef0c..ec7b424b6c 100644 --- a/src/GitVersion.Core/Core/GitPreparer.cs +++ b/src/GitVersion.Core/Core/GitPreparer.cs @@ -234,7 +234,12 @@ private void EnsureHeadIsAttachedToBranch(string? currentBranchName, Authenticat var localBranchesWhereCommitShaIsHead = this.repository.Branches.Where(b => !b.IsRemote && b.Tip?.Sha == headSha).ToList(); var matchingCurrentBranch = !currentBranchName.IsNullOrEmpty() - ? localBranchesWhereCommitShaIsHead.SingleOrDefault(b => b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/")) + ? localBranchesWhereCommitShaIsHead.SingleOrDefault(b => + { + if (ReferenceName.TryParseMergeRequestsRef(currentBranchName, out var mergeRequestId)) + return b.Name.Canonical == ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId); + return b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/"); + }) : null; if (matchingCurrentBranch != null) { @@ -379,11 +384,15 @@ public void EnsureLocalBranchExistsForCurrentBranch(IRemote remote, string? curr const string referencePrefix = "refs/"; var isLocalBranch = currentBranch.StartsWith(ReferenceName.LocalBranchPrefix); - var localCanonicalName = !currentBranch.StartsWith(referencePrefix) - ? ReferenceName.LocalBranchPrefix + currentBranch - : isLocalBranch - ? currentBranch - : ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..]; + string localCanonicalName; + if (ReferenceName.TryParseMergeRequestsRef(currentBranch, out var mergeRequestId)) + localCanonicalName = ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId); + else + localCanonicalName = !currentBranch.StartsWith(referencePrefix) + ? ReferenceName.LocalBranchPrefix + currentBranch + : isLocalBranch + ? currentBranch + : ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..]; var repoTip = this.repository.Head.Tip; diff --git a/src/GitVersion.Core/Core/RepositoryStore.cs b/src/GitVersion.Core/Core/RepositoryStore.cs index 50b5512a07..8e4a227bf3 100644 --- a/src/GitVersion.Core/Core/RepositoryStore.cs +++ b/src/GitVersion.Core/Core/RepositoryStore.cs @@ -73,8 +73,13 @@ public IBranch GetTargetBranch(string? targetBranchName) if (targetBranchName.IsNullOrEmpty()) return desiredBranch; - // There are some edge cases where HEAD is not pointing to the desired branch. - // Therefore, it's important to verify if 'currentBranch' is indeed the desired branch. + if (ReferenceName.TryParseMergeRequestsRef(targetBranchName, out var mergeRequestId)) + { + var prBranch = FindBranch(ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId)); + if (prBranch != null) + return prBranch; + } + var targetBranch = FindBranch(targetBranchName); // CanonicalName can be "refs/heads/develop", so we need to check for "/{TargetBranch}" as well diff --git a/src/GitVersion.Core/Git/ReferenceName.cs b/src/GitVersion.Core/Git/ReferenceName.cs index 9686a0a22a..1f0567121d 100644 --- a/src/GitVersion.Core/Git/ReferenceName.cs +++ b/src/GitVersion.Core/Git/ReferenceName.cs @@ -18,10 +18,18 @@ public class ReferenceName : IEquatable, IComparable + /// The sole entry for refs/merge-requests/<id>/head|merge. + /// Adding another prefix that also contains /merge-requests/ will fail at type initialization. + /// + private static readonly string mergeRequestsRefPrefix = PullRequestPrefixes.Single( + p => p.Contains("/merge-requests/", StringComparison.Ordinal)); + public ReferenceName(string canonical) { Canonical = canonical.NotNull(); @@ -84,6 +92,37 @@ public bool EquivalentTo(string? name) => || Friendly.Equals(name, StringComparison.OrdinalIgnoreCase) || WithoutOrigin.Equals(name, StringComparison.OrdinalIgnoreCase); + /// + /// Parses canonical refs under refs/merge-requests/<id>/head or /merge (convention used by some Git hosts) and extracts the merge-request id. + /// + public static bool TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId) + { + mergeRequestId = 0; + if (string.IsNullOrEmpty(canonicalRef) || !canonicalRef.StartsWith(mergeRequestsRefPrefix, StringComparison.Ordinal)) + return false; + var after = canonicalRef.Substring(mergeRequestsRefPrefix.Length); + var slash = after.IndexOf('/'); + if (slash <= 0 || slash >= after.Length - 1) return false; + var suffix = after[(slash + 1)..]; + if (!suffix.Equals("head", StringComparison.OrdinalIgnoreCase) && !suffix.Equals("merge", StringComparison.OrdinalIgnoreCase)) + return false; + return int.TryParse(after.Substring(0, slash), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out mergeRequestId) + && mergeRequestId > 0; + } + + /// + /// Returns the branch-style name pull-requests/<id> for default pull-request configuration matching. + /// + /// A positive merge-request identifier (must be greater than zero). + /// + /// Thrown when is zero or negative. + /// + public static string MergeRequestsRefFriendlyName(int mergeRequestId) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mergeRequestId); + return $"pull-requests/{mergeRequestId}"; + } + private string Shorten() { if (IsLocalBranch) @@ -92,7 +131,13 @@ private string Shorten() if (IsRemoteBranch) return Canonical[RemoteTrackingBranchPrefix.Length..]; - return IsTag ? Canonical[TagPrefix.Length..] : Canonical; + if (IsTag) + return Canonical[TagPrefix.Length..]; + + if (TryParseMergeRequestsRef(Canonical, out var mergeRequestId)) + return MergeRequestsRefFriendlyName(mergeRequestId); + + return Canonical; } private string RemoveOrigin() diff --git a/src/GitVersion.Core/PublicAPI.Unshipped.txt b/src/GitVersion.Core/PublicAPI.Unshipped.txt index 7dc5c58110..ab0400d718 100644 --- a/src/GitVersion.Core/PublicAPI.Unshipped.txt +++ b/src/GitVersion.Core/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +static GitVersion.Git.ReferenceName.MergeRequestsRefFriendlyName(int mergeRequestId) -> string! +static GitVersion.Git.ReferenceName.TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId) -> bool \ No newline at end of file diff --git a/src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs b/src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs index 022fdf8c11..04ecee3b69 100644 --- a/src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs +++ b/src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs @@ -70,7 +70,9 @@ public void CreateBranchForPullRequestBranch(AuthenticationInfo auth) => Reposit } else if (referenceName.IsPullRequest) { - var fakeBranchName = canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/"); + var fakeBranchName = ReferenceName.TryParseMergeRequestsRef(canonicalName, out var mergeRequestId) + ? $"{ReferenceName.LocalBranchPrefix}{ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId)}" + : canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/"); this.log.Info($"Creating fake local branch '{fakeBranchName}'."); References.Add(fakeBranchName, headTipSha);