Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/guide/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,82 @@ buildmark --validate --results validation-results.trx
buildmark --validate --results validation-results.xml
```

# Version Selection Rules

BuildMark automatically determines which previous version to use as the baseline when generating build notes. This
section explains how BuildMark selects the baseline version for different scenarios.

## Pre-Release Versions

For pre-release versions (e.g., `1.2.3-beta.1`, `1.2.3-rc.1`), BuildMark picks the **previous tag (release or
pre-release) that has a different commit hash**.

This behavior handles cases where multiple pre-release tags point to the same commit (re-tagging scenarios), ensuring
the generated changelog shows actual code changes rather than an empty diff.

### Example: Pre-Release with Re-Tagged Commits

Consider the following tags:

- `1.1.2-rc.1` (commit hash: `a1b2c3d4`)
- `1.1.2-beta.2` (commit hash: `a1b2c3d4`)
- `1.1.2-beta.1` (commit hash: `734713bc`)

When generating build notes for `1.1.2-rc.1`:

1. BuildMark identifies that `1.1.2-beta.2` has the same commit hash (`a1b2c3d4`)
2. BuildMark skips `1.1.2-beta.2` since it would result in an empty changelog
3. BuildMark selects `1.1.2-beta.1` as the baseline (different commit hash: `734713bc`)

The generated build notes will show changes between `1.1.2-beta.1` and `1.1.2-rc.1`.

## Release Versions

For release versions (e.g., `1.2.3`), BuildMark picks the **previous release tag**, skipping all pre-release versions.

This ensures release notes compare against the previous stable release, showing the complete set of changes since the
last production release.

### Example: Release Skipping Pre-Releases

Consider the following tags:

- `1.1.2` (release)
- `1.1.2-rc.1` (pre-release)
- `1.1.2-beta.2` (pre-release)
- `1.1.2-beta.1` (pre-release)
- `1.1.1` (release)

When generating build notes for `1.1.2`:

1. BuildMark identifies `1.1.2` as a release version (no pre-release suffix)
2. BuildMark skips all pre-release tags (`1.1.2-rc.1`, `1.1.2-beta.2`, `1.1.2-beta.1`)
3. BuildMark selects `1.1.1` as the baseline (the previous release)

The generated build notes will show all changes between `1.1.1` and `1.1.2`, including changes from all the
pre-release versions.

## No Previous Version

If no previous version is found (e.g., generating build notes for the first release), BuildMark will build the
history from the beginning of the repository, showing all commits up to the specified version.

## Version Tag Format

BuildMark recognizes version tags with various formats:

- Simple format: `1.2.3`
- V-prefix: `v1.2.3`
- Custom prefixes: `ver-1.2.3`, `release_1.2.3`
- Pre-release suffixes: `-alpha.1`, `-beta.2`, `-rc.1`, `.pre.1`
- Build metadata: `+build.123`, `+linux.x64`

Examples of recognized version tags:

- `1.0.0`, `v1.0.0`, `ver-1.0.0`
- `2.0.0-beta.1`, `v2.0.0-rc.2`
- `1.2.3+build.456`, `v2.0.0-rc.1+linux`

# Best Practices

## Version Tagging
Expand Down
54 changes: 43 additions & 11 deletions src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public override async Task<BuildInformation> GetBuildInformationAsync(Version? v
var (toVersion, toHash) = DetermineTargetVersion(version, currentCommitHash.Trim(), lookupData);

// Determine the starting release for comparing changes
var (fromVersion, fromHash) = DetermineBaselineVersion(toVersion, lookupData);
var (fromVersion, fromHash) = DetermineBaselineVersion(toVersion, toHash, lookupData);

// Get commits in range
var commitsInRange = GetCommitsInRange(gitHubData.Commits, fromHash, toHash);
Expand Down Expand Up @@ -348,10 +348,12 @@ private static (Version toVersion, string toHash) DetermineTargetVersion(
/// Determines the baseline version for comparing changes.
/// </summary>
/// <param name="toVersion">Target version.</param>
/// <param name="toHash">Commit hash of target version.</param>
/// <param name="lookupData">Lookup data structures.</param>
/// <returns>Tuple of (fromVersion, fromHash).</returns>
private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion(
Version toVersion,
string toHash,
LookupData lookupData)
{
// Return null baseline if no releases exist
Expand All @@ -365,7 +367,7 @@ private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion

// Determine baseline version based on whether target is pre-release
var fromVersion = toVersion.IsPreRelease
? DetermineBaselineForPreRelease(toIndex, lookupData.ReleaseVersions)
? DetermineBaselineForPreRelease(toIndex, toHash, lookupData)
: DetermineBaselineForRelease(toIndex, lookupData.ReleaseVersions);

// Get commit hash for baseline version if one was found
Expand All @@ -382,26 +384,56 @@ private static (Version? fromVersion, string? fromHash) DetermineBaselineVersion

/// <summary>
/// Determines the baseline version for a pre-release.
/// Pre-release versions pick the previous tag (release or pre-release) that isn't the same commit-hash.
/// </summary>
/// <param name="toIndex">Index of target version in release history.</param>
/// <param name="releaseVersions">List of release versions.</param>
/// <param name="toHash">Commit hash of target version.</param>
/// <param name="lookupData">Lookup data structures.</param>
/// <returns>Baseline version or null.</returns>
private static Version? DetermineBaselineForPreRelease(int toIndex, List<Version> releaseVersions)
private static Version? DetermineBaselineForPreRelease(int toIndex, string toHash, LookupData lookupData)
{
// Pre-release versions use the immediately previous (older) release as baseline
var releaseVersions = lookupData.ReleaseVersions;

// Determine starting index for search
int startIndex;
if (toIndex >= 0 && toIndex < releaseVersions.Count - 1)
{
// Target version exists in history, use next older release (higher index)
return releaseVersions[toIndex + 1];
// Target exists, start from next older release
startIndex = toIndex + 1;
}
else if (toIndex == -1 && releaseVersions.Count > 0)
{
// Target not in history, start from most recent
startIndex = 0;
}
else
{
// No valid starting point
startIndex = -1;
}

// Target version not in history, use most recent release as baseline
if (toIndex == -1 && releaseVersions.Count > 0)
// If no valid starting point, return null
if (startIndex < 0)
{
return releaseVersions[0];
return null;
}

// Search forward through older releases (incrementing index) for previous version with different commit hash
for (var i = startIndex; i < releaseVersions.Count; i++)
{
var candidateVersion = releaseVersions[i];

// Get commit hash for candidate version
if (lookupData.TagToRelease.TryGetValue(candidateVersion.Tag, out var candidateRelease) &&
lookupData.TagsByName.TryGetValue(candidateRelease.TagName!, out var candidateTag) &&
candidateTag.Commit.Sha != toHash)
{
// Found a version with a different commit hash - use it
return candidateVersion;
}
}

// If toIndex is last in list, this is the oldest release, no baseline
// No version with different commit hash found
return null;
}

Expand Down
187 changes: 187 additions & 0 deletions test/DemaConsulting.BuildMark.Tests/GitHubRepoConnectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,191 @@ public async Task GitHubRepoConnector_GetBuildInformationAsync_WithOpenIssues_Id
var hasExpectedIssue = knownIssueTitles.Exists(t => t.Contains("Known bug") || t.Contains("Feature request"));
Assert.IsTrue(hasExpectedIssue, "Should have at least one of the open issues as a known issue");
}

/// <summary>
/// Test that pre-release baseline selection skips tags with the same commit hash.
/// Example: 1.1.2-rc.1 (hash a1b2c3d4) and 1.1.2-beta.2 (hash a1b2c3d4) are re-tags.
/// When processing 1.1.2-rc.1, it should skip 1.1.2-beta.2 and use 1.1.2-beta.1 (hash 734713bc).
/// </summary>
[TestMethod]
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseWithSameCommitHash_SkipsToNextDifferentHash()
{
// Arrange - Create mock responses with multiple pre-releases on same and different hashes
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
.AddCommitsResponse("a1b2c3d4", "734713bc", "commit1")
.AddReleasesResponse(
new MockRelease("1.1.2-rc.1", "2024-03-03T00:00:00Z"), // Same hash as beta.2
new MockRelease("1.1.2-beta.2", "2024-03-02T00:00:00Z"), // Same hash as rc.1
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"), // Different hash
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
.AddPullRequestsResponse()
.AddIssuesResponse()
.AddTagsResponse(
new MockTag("1.1.2-rc.1", "a1b2c3d4"), // rc.1 and beta.2 on same hash
new MockTag("1.1.2-beta.2", "a1b2c3d4"), // Same hash as rc.1
new MockTag("1.1.2-beta.1", "734713bc"), // Different hash
new MockTag("v1.1.1", "commit1"));

using var mockHttpClient = new HttpClient(mockHandler);
var connector = new MockableGitHubRepoConnector(mockHttpClient);

// Set up mock command responses
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
connector.SetCommandResponse("git rev-parse HEAD", "a1b2c3d4");
connector.SetCommandResponse("gh auth token", "test-token");

// Act - Process 1.1.2-rc.1
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-rc.1"));

// Assert
Assert.IsNotNull(buildInfo);
Assert.AreEqual("1.1.2-rc.1", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("a1b2c3d4", buildInfo.CurrentVersionTag.CommitHash);

// Should have skipped 1.1.2-beta.2 (same hash) and selected 1.1.2-beta.1 (different hash)
Assert.IsNotNull(buildInfo.BaselineVersionTag);
Assert.AreEqual("1.1.2-beta.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("734713bc", buildInfo.BaselineVersionTag.CommitHash);

// Should have changelog link between beta.1 and rc.1
Assert.IsNotNull(buildInfo.CompleteChangelogLink);
Assert.Contains("1.1.2-beta.1...1.1.2-rc.1", buildInfo.CompleteChangelogLink.TargetUrl);
}

/// <summary>
/// Test that release baseline selection skips all pre-release versions.
/// Example: 1.1.2 should skip 1.1.2-rc.1, 1.1.2-beta.2, 1.1.2-beta.1 and use 1.1.1.
/// </summary>
[TestMethod]
public async Task GitHubRepoConnector_GetBuildInformationAsync_ReleaseVersion_SkipsAllPreReleases()
{
// Arrange - Create mock responses with release and multiple pre-releases
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
.AddCommitsResponse("commit5", "commit4", "commit3", "commit2", "commit1")
.AddReleasesResponse(
new MockRelease("1.1.2", "2024-03-05T00:00:00Z"),
new MockRelease("1.1.2-rc.1", "2024-03-04T00:00:00Z"),
new MockRelease("1.1.2-beta.2", "2024-03-03T00:00:00Z"),
new MockRelease("1.1.2-beta.1", "2024-03-02T00:00:00Z"),
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
.AddPullRequestsResponse()
.AddIssuesResponse()
.AddTagsResponse(
new MockTag("1.1.2", "commit5"),
new MockTag("1.1.2-rc.1", "commit4"),
new MockTag("1.1.2-beta.2", "commit3"),
new MockTag("1.1.2-beta.1", "commit2"),
new MockTag("v1.1.1", "commit1"));

using var mockHttpClient = new HttpClient(mockHandler);
var connector = new MockableGitHubRepoConnector(mockHttpClient);

// Set up mock command responses
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
connector.SetCommandResponse("git rev-parse HEAD", "commit5");
connector.SetCommandResponse("gh auth token", "test-token");

// Act - Process 1.1.2
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2"));

// Assert
Assert.IsNotNull(buildInfo);
Assert.AreEqual("1.1.2", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("commit5", buildInfo.CurrentVersionTag.CommitHash);

// Should have skipped all pre-releases and selected 1.1.1
Assert.IsNotNull(buildInfo.BaselineVersionTag);
Assert.AreEqual("1.1.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("commit1", buildInfo.BaselineVersionTag.CommitHash);

// Should have changelog link between 1.1.1 and 1.1.2
Assert.IsNotNull(buildInfo.CompleteChangelogLink);
Assert.Contains("v1.1.1...1.1.2", buildInfo.CompleteChangelogLink.TargetUrl);
}

/// <summary>
/// Test that pre-release baseline selection works correctly when target is not in release history.
/// This happens when generating build notes for a version that hasn't been tagged yet.
/// </summary>
[TestMethod]
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseNotInHistory_UsesLatestDifferentHash()
{
// Arrange - Create mock responses where target version doesn't exist yet
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
.AddCommitsResponse("new-hash-123", "commit2", "commit1")
.AddReleasesResponse(
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"),
new MockRelease("v1.1.1", "2024-02-01T00:00:00Z"))
.AddPullRequestsResponse()
.AddIssuesResponse()
.AddTagsResponse(
new MockTag("1.1.2-beta.1", "commit2"),
new MockTag("v1.1.1", "commit1"));

using var mockHttpClient = new HttpClient(mockHandler);
var connector = new MockableGitHubRepoConnector(mockHttpClient);

// Set up mock command responses
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
connector.SetCommandResponse("git rev-parse HEAD", "new-hash-123");
connector.SetCommandResponse("gh auth token", "test-token");

// Act - Process 1.1.2-beta.2 which doesn't exist in releases yet
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-beta.2"));

// Assert
Assert.IsNotNull(buildInfo);
Assert.AreEqual("1.1.2-beta.2", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("new-hash-123", buildInfo.CurrentVersionTag.CommitHash);

// Should use most recent release with different hash
Assert.IsNotNull(buildInfo.BaselineVersionTag);
Assert.AreEqual("1.1.2-beta.1", buildInfo.BaselineVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("commit2", buildInfo.BaselineVersionTag.CommitHash);
}

/// <summary>
/// Test that pre-release baseline selection returns null when all previous versions have the same hash.
/// This is an edge case where all previous tags are re-tags of the current commit.
/// </summary>
[TestMethod]
public async Task GitHubRepoConnector_GetBuildInformationAsync_PreReleaseAllPreviousSameHash_ReturnsNullBaseline()
{
// Arrange - Create mock responses where all versions are on the same commit
using var mockHandler = new MockGitHubGraphQLHttpMessageHandler()
.AddCommitsResponse("same-hash-123")
.AddReleasesResponse(
new MockRelease("1.1.2-rc.1", "2024-03-03T00:00:00Z"),
new MockRelease("1.1.2-beta.2", "2024-03-02T00:00:00Z"),
new MockRelease("1.1.2-beta.1", "2024-03-01T00:00:00Z"))
.AddPullRequestsResponse()
.AddIssuesResponse()
.AddTagsResponse(
new MockTag("1.1.2-rc.1", "same-hash-123"),
new MockTag("1.1.2-beta.2", "same-hash-123"),
new MockTag("1.1.2-beta.1", "same-hash-123"));

using var mockHttpClient = new HttpClient(mockHandler);
var connector = new MockableGitHubRepoConnector(mockHttpClient);

// Set up mock command responses
connector.SetCommandResponse("git remote get-url origin", "https://github.com/test/repo.git");
connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main");
connector.SetCommandResponse("git rev-parse HEAD", "same-hash-123");
connector.SetCommandResponse("gh auth token", "test-token");

// Act - Process 1.1.2-rc.1
var buildInfo = await connector.GetBuildInformationAsync(Version.Create("1.1.2-rc.1"));

// Assert
Assert.IsNotNull(buildInfo);
Assert.AreEqual("1.1.2-rc.1", buildInfo.CurrentVersionTag.VersionInfo.FullVersion);
Assert.AreEqual("same-hash-123", buildInfo.CurrentVersionTag.CommitHash);

// Should have null baseline since all previous versions are on the same hash
Assert.IsNull(buildInfo.BaselineVersionTag);
}
}