fix(release): reconcile preview deltas against full history (#50132)

## Summary of the Pull Request

Fixes preview release branch-transition deltas that incorrectly report
stable cherry-picks as removed when the matching PR exists before the
merge base in `main`.

The resolver now compares PR identities and equivalent patches against
each branch's full reachable history. It also excludes content-free
aggregate promotion merges, such as `main`-to-`stable` release merges,
from semantic release notes.

## PR Checklist

- [x] **Tests:** Added/updated and all pass
- [x] **Dev docs:** Added/updated
- [x] **New binaries:** N/A

## Detailed Description of the Pull Request / Additional comments

`get-preview-release-delta.ps1` previously built identity sets only from
commits after the merge base. A stable cherry-pick after that merge base
was therefore reported as removed even when the same PR had already
merged into `main` earlier with a different SHA.

The updated resolver:

- Collects PR identities from the full reachable history of both release
commits.
- Uses `git cherry` to recognize equivalent patches across the full
histories.
- Ignores merge commits with no independent patch when they only promote
a parent already contained by the opposite branch.
- Documents the full-history comparison in
`.github/skills/release-note-generation/references/preview-delta-resolution.md`.
- Adds regression coverage in
`.github/skills/release-note-generation/tests/preview-release.Tests.ps1`.

This was exercised against preview build `155850552`, where the
corrected delta changed from 11 added / 71 removed PRs to 11 added / 0
removed PRs. Of the false removals, 68 had the same PR identity in
`main`, and three were aggregate promotion merges.

## Validation Steps Performed

- `Invoke-Pester
.\.github\skills\release-note-generation\tests\preview-release.Tests.ps1
-EnableExit` - 26/26 passed.
- Recomputed build `155850552` against published stable `v0.101.2362.0`:
11 added, 0 removed, 0 unattributed commits.
- Ran the canonical preview dry-run verifier: PASS with 7/7 assets
verified.

---------

Copilot-Session: 5e9606ff-44ec-4d35-990e-a1fb2c0a10b1
Copilot-Session: 4953c1b4-3f0f-45b3-84a5-1d26d8800de1
This commit is contained in:
Boliang Zhang authored and GitHub committed 2026-10-27 10:49:46 +08:00
1 parent b701ce788f
commit 08e9edb3d3
3 files changed
+258

No files matched your search

@@ -20,6 +20,18 @@ Added = Target identities - Previous identities
Removed = Previous identities - Target identities
```
Compare each side against the other commit's full reachable history, not only
the commits after the merge base. Stable cherry-picks can appear after the
merge base even when the same PR was merged into `main` earlier. Equal PR
identities or equivalent patches anywhere in the opposite history cancel.
Compare delta records against stable patch-ID sets built from each tip's full
reachable history, including root commits. Do not use `git cherry` for this:
it excludes shared history before the merge base from patch comparison.
Aggregate merge commits with no independent patch also cancel when all
merged-in parents (excluding the first parent) are ancestors of the opposite
tip; they are not independent release-note changes. A shared first parent
alone does not make a feature merge a promotion.
Resolve commit identity in this order:
1. Squash subject ending in `(#<number>)`.
@@ -195,6 +195,66 @@ function New-IdentitySet {
return ,$set
}
function Get-HistoryPrIdentitySet {
param([Parameter(Mandatory)][string]$Commit)
$set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($subject in @(Invoke-Git log --format=%s $Commit)) {
$prNumber = Get-SubjectPrNumber -Subject ([string]$subject)
if ($prNumber) {
[void]$set.Add("pr:$prNumber")
}
}
return ,$set
}
function Get-HistoryPatchIdSet {
param([Parameter(Mandatory)][string]$Commit)
# Stream each full history once instead of spawning git for every commit.
$patchOutput = & {
& git -C $RepoPath log --format=%H --patch --cc --no-ext-diff --binary $Commit
if ($LASTEXITCODE -ne 0) {
throw "git log failed while collecting patches reachable from $Commit."
}
} | & git -C $RepoPath patch-id --stable
if ($LASTEXITCODE -ne 0) {
throw "git patch-id failed for history reachable from $Commit."
}
$set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($line in $patchOutput) {
if ([string]$line -notmatch "^([0-9a-fA-F]{40})\s+[0-9a-fA-F]{40}$") {
throw "Unexpected git patch-id output: $line"
}
[void]$set.Add($matches[1])
}
return ,$set
}
function Test-PromotionMerge {
param(
[Parameter(Mandatory)]$Record,
[Parameter(Mandatory)][string]$OtherTip
)
if ($Record.patchId) {
return $false
}
$parents = @(([string](Invoke-Git show -s --format=%P $Record.sha)).Trim() -split "\s+" | Where-Object { $_ })
if ($parents.Count -lt 2) {
return $false
}
foreach ($parent in $parents[1..($parents.Count - 1)]) {
if (-not (Test-Ancestor -Ancestor $parent -Descendant $OtherTip)) {
return $false
}
}
return $true
}
function Resolve-CrossSidePatchIdentities {
param(
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Left,
@@ -275,6 +335,32 @@ Resolve-CrossSidePatchIdentities -Left $targetRecords -Right $previousRecords
$previousIdentities = New-IdentitySet -Records $previousRecords
$targetIdentities = New-IdentitySet -Records $targetRecords
if ($deltaMode -eq "branch-transition") {
$previousHistoryPrIdentities = Get-HistoryPrIdentitySet -Commit $previousSha
$targetHistoryPrIdentities = Get-HistoryPrIdentitySet -Commit $targetSha
foreach ($identity in $previousHistoryPrIdentities) {
[void]$previousIdentities.Add($identity)
}
foreach ($identity in $targetHistoryPrIdentities) {
[void]$targetIdentities.Add($identity)
}
$previousHistoryPatchIds = Get-HistoryPatchIdSet -Commit $previousSha
$targetHistoryPatchIds = Get-HistoryPatchIdSet -Commit $targetSha
foreach ($record in $previousRecords) {
if (($record.patchId -and $targetHistoryPatchIds.Contains([string]$record.patchId)) -or
(Test-PromotionMerge -Record $record -OtherTip $targetSha)) {
[void]$targetIdentities.Add([string]$record.identity)
}
}
foreach ($record in $targetRecords) {
if (($record.patchId -and $previousHistoryPatchIds.Contains([string]$record.patchId)) -or
(Test-PromotionMerge -Record $record -OtherTip $previousSha)) {
[void]$previousIdentities.Add([string]$record.identity)
}
}
}
$addedPrs = Get-PrOutput -Records $targetRecords -OtherSide $previousIdentities
$removedPrs = if ($deltaMode -eq "branch-transition") {
Get-PrOutput -Records $previousRecords -OtherSide $targetIdentities
@@ -37,6 +37,7 @@ function Add-TestCommit {
[Parameter(Mandatory)][string]$Message
)
Invoke-TestGit -Repository $Repository config core.safecrlf false | Out-Null
Set-Content -LiteralPath (Join-Path $Repository $FileName) -Value $Content
Invoke-TestGit -Repository $Repository add $FileName | Out-Null
Invoke-TestGit -Repository $Repository commit -q -m $Message | Out-Null
@@ -466,6 +467,165 @@ Describe "preview release delta" {
($result.addedPrNumbers -join ",") | Should Be "102,104"
($result.removedPrNumbers -join ",") | Should Be "103"
}
It "does not remove a PR already present before the merge base on the target branch" {
$repo = Join-Path $TestDrive "full-history-identity"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$root = Add-TestCommit -Repository $repo -FileName "root.txt" -Content "root" -Message "Root"
Invoke-TestGit -Repository $repo checkout -q -b main | Out-Null
Add-TestCommit -Repository $repo -FileName "feature.txt" -Content "main" -Message "Shared feature (#201)" | Out-Null
$mainTip = Add-TestCommit -Repository $repo -FileName "anchor.txt" -Content "anchor" -Message "Main anchor (#202)"
Invoke-TestGit -Repository $repo checkout -q -b stable $mainTip | Out-Null
$previous = Add-TestCommit -Repository $repo -FileName "stable.txt" -Content "stable adaptation" -Message "Shared feature (#201)"
Invoke-TestGit -Repository $repo checkout -q main | Out-Null
$target = Add-TestCommit -Repository $repo -FileName "added.txt" -Content "added" -Message "Main addition (#203)"
$output = Join-Path $TestDrive "full-history-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
($result.addedPrNumbers -join ",") | Should Be "203"
$result.removedPrNumbers.Count | Should Be 0
}
It "matches unattributed patches before the merge base in either direction" -TestCases @(
@{ Reverse = $false; AddedCount = 1; RemovedCount = 0 }
@{ Reverse = $true; AddedCount = 0; RemovedCount = 1 }
) {
param($Reverse, $AddedCount, $RemovedCount)
$repo = Join-Path $TestDrive "full-history-patch-$Reverse"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$original = Add-TestCommit -Repository $repo -FileName "shared.txt" -Content "shared" -Message "Original shared change"
Invoke-TestGit -Repository $repo revert --no-edit $original | Out-Null
$anchor = Add-TestCommit -Repository $repo -FileName "anchor.txt" -Content "anchor" -Message "Shared anchor"
Invoke-TestGit -Repository $repo checkout -q -b stable | Out-Null
Invoke-TestGit -Repository $repo cherry-pick --no-commit $original | Out-Null
Invoke-TestGit -Repository $repo commit -q -m "Reapplied shared change" | Out-Null
$previous = ([string](Invoke-TestGit -Repository $repo rev-parse HEAD)).Trim()
Invoke-TestGit -Repository $repo checkout -q -b main $anchor | Out-Null
$unique = Add-TestCommit -Repository $repo -FileName "unique.txt" -Content "unique" -Message "Unique main change"
$target = $unique
if ($Reverse) {
$target = $previous
$previous = $unique
}
$output = Join-Path $TestDrive "full-history-patch-output-$Reverse"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
$result.deltaMode | Should Be "branch-transition"
$result.addedPrNumbers.Count | Should Be 0
$result.removedPrNumbers.Count | Should Be 0
$result.unattributedCommitCount | Should Be $AddedCount
$commits = Get-Content -LiteralPath (Join-Path $output "delta-commits.json") -Raw | ConvertFrom-Json
$commits.removedUnattributedCommits.Count | Should Be $RemovedCount
$added = Get-Content -LiteralPath (Join-Path $output "unattributed-commits.json") -Raw | ConvertFrom-Json
$unmatched = @($added) + @($commits.removedUnattributedCommits) | Where-Object { $_ }
@($unmatched).Count | Should Be 1
$unmatched.sha | Should Be $unique
}
It "retains a PR merge when only its first parent is in the opposite history" {
$repo = Join-Path $TestDrive "feature-merge"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$root = Add-TestCommit -Repository $repo -FileName "root.txt" -Content "root" -Message "Root"
Invoke-TestGit -Repository $repo checkout -q -b feature | Out-Null
Add-TestCommit -Repository $repo -FileName "feature.txt" -Content "feature" -Message "Feature implementation" | Out-Null
Invoke-TestGit -Repository $repo checkout -q -b stable $root | Out-Null
Invoke-TestGit -Repository $repo merge -q --no-ff feature -m "Merge pull request #401 from feature" | Out-Null
$previous = ([string](Invoke-TestGit -Repository $repo rev-parse HEAD)).Trim()
Invoke-TestGit -Repository $repo checkout -q -b main $root | Out-Null
$target = Add-TestCommit -Repository $repo -FileName "main.txt" -Content "main" -Message "Main addition (#402)"
$output = Join-Path $TestDrive "feature-merge-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
($result.addedPrNumbers -join ",") | Should Be "402"
($result.removedPrNumbers -join ",") | Should Be "401"
}
It "retains an octopus merge when a merged-in parent is absent from the opposite history" {
$repo = Join-Path $TestDrive "octopus-merge"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
$root = Add-TestCommit -Repository $repo -FileName "root.txt" -Content "root" -Message "Root"
Invoke-TestGit -Repository $repo checkout -q -b main | Out-Null
Add-TestCommit -Repository $repo -FileName "shared.txt" -Content "shared" -Message "Shared feature (#501)" | Out-Null
Invoke-TestGit -Repository $repo checkout -q -b feature $root | Out-Null
Add-TestCommit -Repository $repo -FileName "feature.txt" -Content "feature" -Message "Feature implementation" | Out-Null
Invoke-TestGit -Repository $repo checkout -q -b stable $root | Out-Null
Invoke-TestGit -Repository $repo merge -q --no-ff main feature -m "Merge features (#502)" | Out-Null
$previous = ([string](Invoke-TestGit -Repository $repo rev-parse HEAD)).Trim()
Invoke-TestGit -Repository $repo checkout -q main | Out-Null
$target = Add-TestCommit -Repository $repo -FileName "later.txt" -Content "later" -Message "Later main feature (#503)"
$output = Join-Path $TestDrive "octopus-merge-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
($result.addedPrNumbers -join ",") | Should Be "503"
($result.removedPrNumbers -join ",") | Should Be "502"
}
It "does not report a merge that only promotes the other branch" {
$repo = Join-Path $TestDrive "promotion-merge"
New-Item -ItemType Directory -Path $repo | Out-Null
Invoke-TestGit -Repository $repo init -q | Out-Null
Invoke-TestGit -Repository $repo config user.email "test@example.com" | Out-Null
Invoke-TestGit -Repository $repo config user.name "Test User" | Out-Null
Add-TestCommit -Repository $repo -FileName "root.txt" -Content "root" -Message "Root" | Out-Null
Invoke-TestGit -Repository $repo checkout -q -b main | Out-Null
Add-TestCommit -Repository $repo -FileName "feature.txt" -Content "feature" -Message "Main feature (#301)" | Out-Null
Invoke-TestGit -Repository $repo checkout -q -b stable HEAD~1 | Out-Null
Add-TestCommit -Repository $repo -FileName "stable.txt" -Content "stable" -Message "Stable fix (#302)" | Out-Null
Invoke-TestGit -Repository $repo merge -q --no-ff main -m "Merge main into stable (#303)" | Out-Null
$previous = ([string](Invoke-TestGit -Repository $repo rev-parse HEAD)).Trim()
Invoke-TestGit -Repository $repo checkout -q main | Out-Null
$target = Add-TestCommit -Repository $repo -FileName "later.txt" -Content "later" -Message "Later main feature (#304)"
$output = Join-Path $TestDrive "promotion-output"
$result = & (Join-Path $scripts "get-preview-release-delta.ps1") `
-PreviousCommit $previous `
-TargetCommit $target `
-RepoPath $repo `
-OutputDirectory $output `
-NoGitHubLookup
($result.addedPrNumbers -join ",") | Should Be "304"
($result.removedPrNumbers -join ",") | Should Be "302"
}
}
Describe "preview release asset links" {