From 9032f04f600b19fcd75a83a818f783086417c845 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 9 Sep 2026 18:15:02 +0200 Subject: [PATCH] Fix normalization of GitHub Actions historical tag builds --- .../Agents/GitHubActionsTests.cs | 15 ++ .../Agents/GitHubActions.cs | 11 ++ .../RemoteRepositoryScenarios.cs | 164 ++++++++++++++++++ src/GitVersion.Core/Agents/BuildAgentBase.cs | 2 + src/GitVersion.Core/Agents/IBuildAgent.cs | 3 + src/GitVersion.Core/Core/GitPreparer.cs | 10 ++ src/GitVersion.Core/PublicAPI.Unshipped.txt | 1 + 7 files changed, 206 insertions(+) diff --git a/src/GitVersion.BuildAgents.Tests/Agents/GitHubActionsTests.cs b/src/GitVersion.BuildAgents.Tests/Agents/GitHubActionsTests.cs index 5586f6e23b..51331b4c09 100644 --- a/src/GitVersion.BuildAgents.Tests/Agents/GitHubActionsTests.cs +++ b/src/GitVersion.BuildAgents.Tests/Agents/GitHubActionsTests.cs @@ -104,6 +104,21 @@ public void GetCurrentBranchShouldHandlePullRequests() result.ShouldBe("refs/pull/1/merge"); } + [TestCase("tag", "refs/tags/1.0.0", "refs/tags/1.0.0")] + [TestCase("TAG", "refs/tags/1.0.0", "refs/tags/1.0.0")] + [TestCase("branch", "refs/heads/main", null)] + [TestCase("branch", "refs/pull/1/merge", null)] + [TestCase(null, "refs/tags/1.0.0", null)] + [TestCase("tag", null, null)] + [TestCase("tag", "refs/heads/main", null)] + public void GetCurrentTagOnlyReturnsAnExplicitTagReference(string? refType, string? reference, string? expected) + { + this.environment.SetEnvironmentVariable("GITHUB_REF_TYPE", refType); + this.environment.SetEnvironmentVariable("GITHUB_REF", reference); + + this.buildServer.GetCurrentTag().ShouldBe(expected); + } + [Test] public void ShouldSetOutputVariables() { diff --git a/src/GitVersion.BuildAgents/Agents/GitHubActions.cs b/src/GitVersion.BuildAgents/Agents/GitHubActions.cs index 4bb233c576..193d0d4db8 100644 --- a/src/GitVersion.BuildAgents/Agents/GitHubActions.cs +++ b/src/GitVersion.BuildAgents/Agents/GitHubActions.cs @@ -57,4 +57,15 @@ public override void WriteIntegration(Action writer, GitVersionVariable } public override bool PreventFetch() => true; + + public override string? GetCurrentTag() + { + var refType = this.environment.GetEnvironmentVariable("GITHUB_REF_TYPE"); + var reference = this.environment.GetEnvironmentVariable("GITHUB_REF"); + return string.Equals(refType, "tag", StringComparison.OrdinalIgnoreCase) + && reference != null + && reference.StartsWith("refs/tags/", StringComparison.Ordinal) + ? reference + : null; + } } diff --git a/src/GitVersion.Core.Tests/IntegrationTests/RemoteRepositoryScenarios.cs b/src/GitVersion.Core.Tests/IntegrationTests/RemoteRepositoryScenarios.cs index ee3e998fe0..66f9d9cd44 100644 --- a/src/GitVersion.Core.Tests/IntegrationTests/RemoteRepositoryScenarios.cs +++ b/src/GitVersion.Core.Tests/IntegrationTests/RemoteRepositoryScenarios.cs @@ -8,6 +8,170 @@ namespace GitVersion.Tests.IntegrationTests; [TestFixture] public class RemoteRepositoryScenarios : TestBase { + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + [TestCase(false, false, "tree", false)] + [TestCase(true, false, "tree", false)] + [TestCase(false, false, "blob", false)] + [TestCase(true, false, "blob", false)] + [TestCase(false, false, "tree", true)] + [TestCase(true, false, "tree", true)] + [TestCase(false, false, "blob", true)] + [TestCase(true, false, "blob", true)] + public void TaggedHistoricalCommitNormalizesWithoutAccessingRemote(bool annotated, bool multipleTags, string? metadataTarget = null, bool packed = false) + { + using var fixture = new RemoteRepositoryFixture(path => + { + Repository.Init(path); + var repository = new Repository(path); + repository.MakeACommit(); + if (annotated) + { + repository.ApplyTag("1.2.3", repository.Head.Tip.Author, "Release"); + } + else + { + repository.ApplyTag("1.2.3"); + } + if (multipleTags) + { + repository.ApplyTag("release-alias"); + } + repository.MakeACommit(); + repository.CreateBranch("feature/normalized"); + return repository; + }); + var localRepository = fixture.LocalRepositoryFixture.Repository; + if (metadataTarget != null) + { + using var content = new MemoryStream([1, 2, 3]); + GitObject target = metadataTarget == "tree" + ? localRepository.Head.Tip.Tree + : localRepository.ObjectDatabase.CreateBlob(content); + // Sort before the version tag so that the lookup must inspect this tag first. + if (annotated) + { + localRepository.ApplyTag("!metadata", target.Sha, localRepository.Head.Tip.Author, "Metadata"); + } + else + { + localRepository.ApplyTag("!metadata", target.Sha); + } + } + if (packed) + { + GitTestExtensions.ExecuteGitCmd($"-C \"{fixture.LocalRepositoryFixture.RepositoryPath}\" pack-refs --all", "."); + } + var taggedCommit = (Commit)localRepository.Tags["1.2.3"].PeeledTarget; + localRepository.Branches["feature/normalized"].ShouldBeNull(); + Commands.Checkout(localRepository, taggedCommit); + localRepository.Network.Remotes.Update("origin", remote => + remote.Url = Path.Combine(fixture.LocalRepositoryFixture.RepositoryPath, "missing-remote")); + + var options = Options.Create(new GitVersionOptions + { + WorkingDirectory = fixture.LocalRepositoryFixture.RepositoryPath, + Settings = { NoNormalize = false, NoFetch = true } + }); + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(GitHubActions.EnvironmentVariableName, "true"); + environment.SetEnvironmentVariable("GITHUB_REF_TYPE", "tag"); + environment.SetEnvironmentVariable("GITHUB_REF", "refs/tags/1.2.3"); + var sp = ConfigureServices(services => + { + services.AddSingleton(options); + services.AddSingleton(environment); + }); + sp.DiscoverRepository(); + + sp.GetRequiredService().Prepare(); + + localRepository.Head.Tip.Sha.ShouldBe(taggedCommit.Sha); + localRepository.Info.IsHeadDetached.ShouldBeTrue(); + localRepository.Branches["feature/normalized"].Tip.Sha.ShouldBe(localRepository.Branches["origin/feature/normalized"].Tip.Sha); + fixture.AssertFullSemver("1.2.3", repository: localRepository); + } + + [TestCase("refs/pull/42/merge", 0, "pull/42/merge", "1.2.4-PullRequest42.0")] + [TestCase(null, 1, "release/1.2.3", "1.3.0-beta.1+0")] + [TestCase(null, 2, "main", "1.2.3")] + public void TaggedCommitNormalizationPreservesBranchSelection(string? currentBranch, int localBranchCount, string expectedBranch, string expectedVersion) + { + using var fixture = new RemoteRepositoryFixture(); + var repository = fixture.LocalRepositoryFixture.Repository; + var commit = repository.Head.Tip; + repository.ApplyTag("1.2.3"); + repository.MakeACommit(); + Commands.Checkout(repository, commit); + // Avoid updating main back to the tagged commit from its remote tracking ref. + repository.Refs.Remove("refs/remotes/origin/main"); + if (localBranchCount > 0) + { + repository.CreateBranch("release/1.2.3", commit); + } + if (localBranchCount > 1) + { + repository.Refs.UpdateTarget(repository.Refs["refs/heads/main"], commit.Id); + } + repository.Network.Remotes.Update("origin", remote => + remote.Url = Path.Combine(fixture.LocalRepositoryFixture.RepositoryPath, "missing-remote")); + + PrepareOnGitHubActions(fixture.LocalRepositoryFixture.RepositoryPath, currentBranch); + + repository.Head.Tip.Sha.ShouldBe(commit.Sha); + repository.Info.IsHeadDetached.ShouldBeFalse(); + repository.Head.FriendlyName.ShouldBe(expectedBranch); + fixture.AssertFullSemver(expectedVersion, repository: repository); + } + + [TestCase(null)] + [TestCase("refs/tags/missing")] + [TestCase("refs/tags/1.2.3")] + public void HistoricalCommitWithoutMatchingBuildTagStillDiscoversPullRequest(string? currentTag) + { + using var fixture = new RemoteRepositoryFixture(path => + { + Repository.Init(path); + var repository = new Repository(path); + repository.MakeACommit(); + repository.Refs.Add("refs/pull/42/merge", repository.Head.Tip.Id); + repository.MakeATaggedCommit("1.2.3"); + return repository; + }); + var localRepository = fixture.LocalRepositoryFixture.Repository; + var commit = localRepository.Head.Tip.Parents.Single(); + Commands.Checkout(localRepository, commit); + localRepository.ApplyTag("local-only"); + + PrepareOnGitHubActions(fixture.LocalRepositoryFixture.RepositoryPath, null, currentTag); + + localRepository.Head.Tip.Sha.ShouldBe(commit.Sha); + localRepository.Info.IsHeadDetached.ShouldBeFalse(); + localRepository.Head.FriendlyName.ShouldBe("pull/42/merge"); + } + + private static void PrepareOnGitHubActions(string workingDirectory, string? currentBranch, string? currentTag = null) + { + var options = Options.Create(new GitVersionOptions + { + WorkingDirectory = workingDirectory, + Settings = { NoNormalize = false, NoFetch = true } + }); + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(GitHubActions.EnvironmentVariableName, "true"); + environment.SetEnvironmentVariable("GITHUB_REF", currentTag ?? currentBranch); + environment.SetEnvironmentVariable("GITHUB_REF_TYPE", currentTag == null ? "branch" : "tag"); + var sp = ConfigureServices(services => + { + services.AddSingleton(options); + services.AddSingleton(environment); + }); + sp.DiscoverRepository(); + sp.GetRequiredService().Prepare(); + } + [Test] public void GivenARemoteGitRepositoryWithCommitsThenClonedLocalShouldMatchRemoteVersion() { diff --git a/src/GitVersion.Core/Agents/BuildAgentBase.cs b/src/GitVersion.Core/Agents/BuildAgentBase.cs index 028e9806d4..ef7a0d0515 100644 --- a/src/GitVersion.Core/Agents/BuildAgentBase.cs +++ b/src/GitVersion.Core/Agents/BuildAgentBase.cs @@ -19,6 +19,8 @@ internal abstract class BuildAgentBase(IEnvironment environment, ILogger logger, public virtual string? GetCurrentBranch(bool usingDynamicRepos) => null; + public virtual string? GetCurrentTag() => null; + public virtual bool IsDefault => false; public virtual bool PreventFetch() => true; public virtual bool ShouldCleanUpRemotes() => false; diff --git a/src/GitVersion.Core/Agents/IBuildAgent.cs b/src/GitVersion.Core/Agents/IBuildAgent.cs index caf301dfa1..ca269b0e14 100644 --- a/src/GitVersion.Core/Agents/IBuildAgent.cs +++ b/src/GitVersion.Core/Agents/IBuildAgent.cs @@ -16,6 +16,9 @@ public interface IBuildAgent /// Returns the name of the current branch as reported by the build agent environment. string? GetCurrentBranch(bool usingDynamicRepos); + /// Returns the canonical tag reference explicitly selected by the build agent environment, or null for other builds. + string? GetCurrentTag() => null; + /// Indicates whether fetching from the remote should be suppressed in this build agent environment. bool PreventFetch(); diff --git a/src/GitVersion.Core/Core/GitPreparer.cs b/src/GitVersion.Core/Core/GitPreparer.cs index 82d71b02ef..846f969371 100644 --- a/src/GitVersion.Core/Core/GitPreparer.cs +++ b/src/GitVersion.Core/Core/GitPreparer.cs @@ -261,6 +261,16 @@ private void EnsureHeadIsAttachedToBranch(string? currentBranchName, Authenticat ChooseLocalBranchToAttach(headSha, localBranchesWhereCommitShaIsHead); break; case 0: + // Only the tag selected by the build identifies a tag checkout. + // Resolve that tag alone; unrelated local tags do not establish build context. + var currentTag = this.buildAgent.GetCurrentTag(); + if (currentBranchName.IsNullOrEmpty() && currentTag != null + && this.repository.Tags.FirstOrDefault(tag => tag.Name.Canonical == currentTag)?.Commit.Sha == headSha) + { + this.logger.LogInformation("HEAD points at the selected tag '{CurrentTag}' at '{HeadSha}'. Leaving HEAD detached.", currentTag, headSha); + break; + } + this.logger.LogInformation("No local branch pointing at the commit '{HeadSha}'. Fake branch needs to be created.", headSha); this.retryAction.Execute(() => this.repository.CreateBranchForPullRequestBranch(authentication)); break; diff --git a/src/GitVersion.Core/PublicAPI.Unshipped.txt b/src/GitVersion.Core/PublicAPI.Unshipped.txt index d2bf01e297..b463674315 100644 --- a/src/GitVersion.Core/PublicAPI.Unshipped.txt +++ b/src/GitVersion.Core/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +GitVersion.Agents.IBuildAgent.GetCurrentTag() -> string? GitVersion.ConfigurationMigrationInfo GitVersion.ConfigurationMigrationInfo.ConfigurationMigrationInfo() -> void GitVersion.ConfigurationMigrationInfo.Force.get -> bool