diff --git a/.github/scripts/Merge-SourceOnlyVersionProperties.Tests.ps1 b/.github/scripts/Merge-SourceOnlyVersionProperties.Tests.ps1 new file mode 100644 index 000000000000..4a9ccd8631ce --- /dev/null +++ b/.github/scripts/Merge-SourceOnlyVersionProperties.Tests.ps1 @@ -0,0 +1,366 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester + +BeforeAll { + . "$PSScriptRoot/Merge-SourceOnlyVersionProperties.ps1" + + function Write-TestXml { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content + ) + + [System.IO.File]::WriteAllText( + $Path, + $Content.TrimStart("`r", "`n") + "`n", + [System.Text.UTF8Encoding]::new($false)) + } +} + +Describe 'Merge-SourceOnlyVersionProperty' { + BeforeEach { + $script:TestRoot = Join-Path ([System.IO.Path]::GetTempPath()) "maui-version-merge-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $script:TestRoot | Out-Null + $script:SourcePath = Join-Path $script:TestRoot 'source.props' + $script:TargetPath = Join-Path $script:TestRoot 'target.props' + $script:AncestorPath = Join-Path $script:TestRoot 'ancestor.props' + } + + AfterEach { + Remove-Item -LiteralPath $script:TestRoot -Recurse -Force + } + + It 'adds source-only properties while preserving release values' { + Write-TestXml -Path $script:SourcePath -Content @' + + + 11.0.100-rc.1 + 0.3.298 + 11.0.0-rc.1 + 11.0.0-rc.1 + 10.0.2 + + + 8.3.2 + 11.0.0-preview.6 + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + 11.0.100-preview.7 + 10.0.2 + + + 8.3.2 + + +'@ + + $result = Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + $updated = Get-Content -LiteralPath $script:TargetPath -Raw + + $result.Changed | Should -BeTrue + $result.AddedProperties | Should -Be @( + 'MicrosoftWindowsCsWin32PackageVersion' + 'MicrosoftAspNetCoreIdentityEntityFrameworkCorePackageVersion' + 'MicrosoftEntityFrameworkCoreSqlitePackageVersion' + 'AvaloniaControlsMauiPackageVersion' + ) + $updated | Should -Match '11\.0\.100-preview\.7' + $updated | Should -Match '0\.3\.298' + $updated | Should -Match '11\.0\.0-rc\.1' + $updated | Should -Match '11\.0\.0-rc\.1' + $updated | Should -Match '11\.0\.0-preview\.6' + } + + It 'places a source-only property after a retained category comment' { + Write-TestXml -Path $script:SourcePath -Content @' + + + 8.0.148 + + 0.3.298 + 2.3.1 + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + 8.0.148 + + 1.8.0 + + +'@ + + [void](Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath) + $updated = Get-Content -LiteralPath $script:TargetPath -Raw + + $updated | Should -Match '(?s)\r?\n .*?\r?\n ' + } + + It 'is idempotent' { + Write-TestXml -Path $script:SourcePath -Content @' + + + source + new + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + target + + +'@ + + $first = Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + $afterFirst = Get-Content -LiteralPath $script:TargetPath -Raw + $second = Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + $afterSecond = Get-Content -LiteralPath $script:TargetPath -Raw + + $first.Changed | Should -BeTrue + $second.Changed | Should -BeFalse + $afterSecond | Should -BeExactly $afterFirst + } + + It 'does not overwrite a same-name target property' { + Write-TestXml -Path $script:SourcePath -Content @' + + + source + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + release + + +'@ + + $result = Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + + $result.Changed | Should -BeFalse + (Get-Content -LiteralPath $script:TargetPath -Raw) | Should -Match 'release' + } + + It 'fails closed when a source-only property is declared more than once' { + Write-TestXml -Path $script:SourcePath -Content @' + + + one + two + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + value + + +'@ + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw "*DuplicateVersion*declared more than once*" + } + + It 'fails closed for a multi-line source-only property' { + Write-TestXml -Path $script:SourcePath -Content @' + + + + value + + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + value + + +'@ + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw "*ComplexVersion*single-line element*" + } + + It 'fails closed when a placement anchor is multi-line in the source' { + Write-TestXml -Path $script:SourcePath -Content @' + + + + source + + new + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + release + + +'@ + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw "*previous placement anchor 'existing'*not a single-line element*" + } + + It 'fails closed for malformed XML' { + Write-TestXml -Path $script:SourcePath -Content '' + Write-TestXml -Path $script:TargetPath -Content '' + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw '*not valid safe XML*' + } + + It 'rejects XML with a document type declaration' { + Write-TestXml -Path $script:SourcePath -Content @' +]> + + + &value; + + +'@ + Write-TestXml -Path $script:TargetPath -Content '' + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw '*not valid safe XML*' + } + + It 'fails closed when the target lacks the matching property group' { + Write-TestXml -Path $script:SourcePath -Content @' + + + one + + + two + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + one + + +'@ + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw '*no PropertyGroup matching source group 1*' + } + + It 'fails closed when the corresponding target group shares no property' { + Write-TestXml -Path $script:SourcePath -Content @' + + + one + two + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + zzz + + +'@ + + { + Merge-SourceOnlyVersionProperty -SourcePath $script:SourcePath -TargetPath $script:TargetPath + } | Should -Throw '*shares no property with the corresponding source group*' + } + + It 'does not resurrect a property deleted on the target when an ancestor is supplied' { + # Ancestor had both pins; the target (release) intentionally deleted DeletedPin while the + # source (net11.0) left it unchanged. Three-way provenance must NOT restore it. + Write-TestXml -Path $script:AncestorPath -Content @' + + + 1.0.0 + 1.0.0 + + +'@ + Write-TestXml -Path $script:SourcePath -Content @' + + + 1.0.0 + 1.0.0 + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + 2.0.0 + + +'@ + + $result = Merge-SourceOnlyVersionProperty ` + -SourcePath $script:SourcePath -TargetPath $script:TargetPath -AncestorPath $script:AncestorPath + $updated = Get-Content -LiteralPath $script:TargetPath -Raw + + $result.Changed | Should -BeFalse + $updated | Should -Not -Match 'DeletedPin' + $updated | Should -Match '2\.0\.0' + } + + It 'restores a property genuinely added on the source when absent from the ancestor' { + # NewPin is absent from the ancestor (added on net11.0), so it is a real source-only add. + Write-TestXml -Path $script:AncestorPath -Content @' + + + 1.0.0 + + +'@ + Write-TestXml -Path $script:SourcePath -Content @' + + + 1.0.0 + 3.0.0 + + +'@ + Write-TestXml -Path $script:TargetPath -Content @' + + + 2.0.0 + + +'@ + + $result = Merge-SourceOnlyVersionProperty ` + -SourcePath $script:SourcePath -TargetPath $script:TargetPath -AncestorPath $script:AncestorPath + $updated = Get-Content -LiteralPath $script:TargetPath -Raw + + $result.Changed | Should -BeTrue + $result.AddedProperties | Should -Be @('NewPin') + $updated | Should -Match '3\.0\.0' + $updated | Should -Match '2\.0\.0' + } +} diff --git a/.github/scripts/Merge-SourceOnlyVersionProperties.ps1 b/.github/scripts/Merge-SourceOnlyVersionProperties.ps1 new file mode 100644 index 000000000000..6c686fe02ba3 --- /dev/null +++ b/.github/scripts/Merge-SourceOnlyVersionProperties.ps1 @@ -0,0 +1,429 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$SourcePath = '', + [string]$TargetPath = '', + [string]$AncestorPath = '' +) + +$ErrorActionPreference = 'Stop' + +function Read-Utf8TextFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + $bytes = [System.IO.File]::ReadAllBytes($resolvedPath) + $hasBom = $bytes.Length -ge 3 -and + $bytes[0] -eq 0xEF -and + $bytes[1] -eq 0xBB -and + $bytes[2] -eq 0xBF + $offset = if ($hasBom) { 3 } else { 0 } + $encoding = [System.Text.UTF8Encoding]::new($false, $true) + + try { + $text = $encoding.GetString($bytes, $offset, $bytes.Length - $offset) + } + catch { + throw "File '$resolvedPath' is not valid UTF-8. $($_.Exception.Message)" + } + + [pscustomobject]@{ + Path = $resolvedPath + Text = $text + HasBom = $hasBom + } +} + +function Read-SafeXmlDocument { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $settings = [System.Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + + $stringReader = [System.IO.StringReader]::new($Text) + $reader = $null + + try { + $reader = [System.Xml.XmlReader]::Create($stringReader, $settings) + $document = [System.Xml.XmlDocument]::new() + $document.PreserveWhitespace = $true + $document.XmlResolver = $null + $document.Load($reader) + } + catch { + throw "$Description is not valid safe XML. $($_.Exception.Message)" + } + finally { + if ($null -ne $reader) { + $reader.Dispose() + } + $stringReader.Dispose() + } + + if ($document.DocumentElement.LocalName -ne 'Project') { + throw "$Description must have a Project root element." + } + + return $document +} + +function Get-DirectPropertyGroup { + param( + [Parameter(Mandatory = $true)] + [System.Xml.XmlDocument]$Document + ) + + $groups = [System.Collections.Generic.List[object]]::new() + + foreach ($child in $Document.DocumentElement.ChildNodes) { + if ($child.NodeType -ne [System.Xml.XmlNodeType]::Element -or + $child.LocalName -ne 'PropertyGroup') { + continue + } + + $properties = [System.Collections.Generic.List[object]]::new() + foreach ($property in $child.ChildNodes) { + if ($property.NodeType -eq [System.Xml.XmlNodeType]::Element) { + $properties.Add([pscustomobject]@{ + Name = $property.LocalName + Order = $properties.Count + }) + } + } + + $groups.Add([pscustomobject]@{ + Index = $groups.Count + Properties = $properties.ToArray() + }) + } + + return $groups.ToArray() +} + +function ConvertTo-LineModel { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $hasCrLf = $Text.Contains("`r`n") + $withoutCrLf = $Text.Replace("`r`n", '') + if ($hasCrLf -and $withoutCrLf.Contains("`n")) { + throw "$Description contains mixed line endings." + } + + $newLine = if ($hasCrLf) { "`r`n" } else { "`n" } + $endsWithNewLine = $Text.EndsWith($newLine, [System.StringComparison]::Ordinal) + $lines = [System.Collections.Generic.List[string]]::new() + $splitLines = [System.Text.RegularExpressions.Regex]::Split($Text, "\r?\n") + $lineCount = if ($endsWithNewLine) { $splitLines.Length - 1 } else { $splitLines.Length } + + for ($index = 0; $index -lt $lineCount; $index++) { + $lines.Add($splitLines[$index]) + } + + [pscustomobject]@{ + Lines = $lines + NewLine = $newLine + EndsWithNewLine = $endsWithNewLine + } +} + +function Get-PropertyGroupLineLayout { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.List[string]]$Lines, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $groups = [System.Collections.Generic.List[object]]::new() + $currentGroup = $null + $propertyPattern = '^\s*<(?[A-Za-z_][A-Za-z0-9_.-]*)(?:\s[^>]*)?>.*>\s*$' + + for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { + $line = $Lines[$lineIndex] + + if ($line -match '^\s*)') { + if ($null -ne $currentGroup) { + throw "$Description contains a nested PropertyGroup at line $($lineIndex + 1)." + } + + $currentGroup = [pscustomobject]@{ + Index = $groups.Count + StartLine = $lineIndex + EndLine = -1 + Properties = @{} + } + $groups.Add($currentGroup) + continue + } + + if ($line -match '^\s*\s*$') { + if ($null -eq $currentGroup) { + throw "$Description contains an unmatched PropertyGroup close tag at line $($lineIndex + 1)." + } + + $currentGroup.EndLine = $lineIndex + $currentGroup = $null + continue + } + + if ($null -ne $currentGroup -and $line -match $propertyPattern) { + $key = $Matches.name.ToLowerInvariant() + if (-not $currentGroup.Properties.ContainsKey($key)) { + $currentGroup.Properties[$key] = [System.Collections.Generic.List[int]]::new() + } + $currentGroup.Properties[$key].Add($lineIndex) + } + } + + if ($null -ne $currentGroup) { + throw "$Description contains an unclosed PropertyGroup." + } + + foreach ($group in $groups) { + if ($group.EndLine -lt 0) { + throw "$Description contains an incomplete PropertyGroup." + } + } + + return $groups.ToArray() +} + +function Get-SourcePropertyLine { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.List[string]]$Lines, + [Parameter(Mandatory = $true)] + [object]$Group, + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + $key = $PropertyName.ToLowerInvariant() + if (-not $Group.Properties.ContainsKey($key) -or $Group.Properties[$key].Count -ne 1) { + throw "Source-only property '$PropertyName' must be represented by exactly one single-line element." + } + + $lineIndex = $Group.Properties[$key][0] + [pscustomobject]@{ + Index = $lineIndex + Text = $Lines[$lineIndex] + } +} + +function Merge-SourceOnlyVersionProperty { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$TargetPath, + # Optional merge-base (three-way) version of the target file. When supplied, a source + # property missing from the target is only restored if it is ALSO absent from this ancestor + # (i.e. genuinely added on the source branch). A property present in the ancestor but absent + # from the target was intentionally deleted on the target branch and must NOT be resurrected. + [string]$AncestorPath = '' + ) + + $sourceFile = Read-Utf8TextFile -Path $SourcePath + $targetFile = Read-Utf8TextFile -Path $TargetPath + $sourceDocument = Read-SafeXmlDocument -Text $sourceFile.Text -Description "Source file '$($sourceFile.Path)'" + $targetDocument = Read-SafeXmlDocument -Text $targetFile.Text -Description "Target file '$($targetFile.Path)'" + $sourceGroups = @(Get-DirectPropertyGroup -Document $sourceDocument) + $targetGroups = @(Get-DirectPropertyGroup -Document $targetDocument) + + $targetNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($group in $targetGroups) { + foreach ($property in $group.Properties) { + [void]$targetNames.Add($property.Name) + } + } + + # Three-way provenance: names present in the merge-base ancestor. A property missing from the + # target that IS in the ancestor was deleted on the target branch, so it must not be restored. + $ancestorNames = $null + if (-not [string]::IsNullOrWhiteSpace($AncestorPath)) { + $ancestorFile = Read-Utf8TextFile -Path $AncestorPath + $ancestorDocument = Read-SafeXmlDocument -Text $ancestorFile.Text -Description "Ancestor file '$($ancestorFile.Path)'" + $ancestorGroups = @(Get-DirectPropertyGroup -Document $ancestorDocument) + $ancestorNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($group in $ancestorGroups) { + foreach ($property in $group.Properties) { + [void]$ancestorNames.Add($property.Name) + } + } + } + + $sourceCounts = @{} + foreach ($group in $sourceGroups) { + foreach ($property in $group.Properties) { + $key = $property.Name.ToLowerInvariant() + if ($sourceCounts.ContainsKey($key)) { + $sourceCounts[$key]++ + } + else { + $sourceCounts[$key] = 1 + } + } + } + + $missingProperties = [System.Collections.Generic.List[object]]::new() + foreach ($group in $sourceGroups) { + foreach ($property in $group.Properties) { + if ($targetNames.Contains($property.Name)) { + continue + } + + # Deleted on the target branch (present in ancestor, absent from target): do not resurrect. + if ($null -ne $ancestorNames -and $ancestorNames.Contains($property.Name)) { + continue + } + + $key = $property.Name.ToLowerInvariant() + if ($sourceCounts[$key] -ne 1) { + throw "Source-only property '$($property.Name)' is declared more than once and cannot be merged safely." + } + + $missingProperties.Add([pscustomobject]@{ + Name = $property.Name + GroupIndex = $group.Index + Order = $property.Order + }) + } + } + + if ($missingProperties.Count -eq 0) { + return [pscustomobject]@{ + Changed = $false + AddedProperties = @() + } + } + + $sourceLines = ConvertTo-LineModel -Text $sourceFile.Text -Description "Source file '$($sourceFile.Path)'" + $targetLines = ConvertTo-LineModel -Text $targetFile.Text -Description "Target file '$($targetFile.Path)'" + $sourceLayout = @(Get-PropertyGroupLineLayout -Lines $sourceLines.Lines -Description "Source file '$($sourceFile.Path)'") + + if ($sourceLayout.Count -ne $sourceGroups.Count) { + throw "Source XML PropertyGroup structure does not match its line layout." + } + + $addedProperties = [System.Collections.Generic.List[string]]::new() + + foreach ($missing in $missingProperties) { + $targetLayout = @(Get-PropertyGroupLineLayout -Lines $targetLines.Lines -Description "Target file '$($targetFile.Path)'") + if ($missing.GroupIndex -ge $targetLayout.Count) { + throw "Target file has no PropertyGroup matching source group $($missing.GroupIndex)." + } + + $sourceGroup = $sourceGroups[$missing.GroupIndex] + $sourceLineGroup = $sourceLayout[$missing.GroupIndex] + $targetGroup = $targetLayout[$missing.GroupIndex] + $sourcePropertyLine = Get-SourcePropertyLine ` + -Lines $sourceLines.Lines ` + -Group $sourceLineGroup ` + -PropertyName $missing.Name + + $previousAnchor = $null + for ($index = $missing.Order - 1; $index -ge 0; $index--) { + $candidate = $sourceGroup.Properties[$index].Name.ToLowerInvariant() + if ($targetGroup.Properties.ContainsKey($candidate)) { + $previousAnchor = [pscustomobject]@{ + Name = $candidate + Line = $targetGroup.Properties[$candidate][-1] + } + break + } + } + + $nextAnchor = $null + for ($index = $missing.Order + 1; $index -lt $sourceGroup.Properties.Count; $index++) { + $candidate = $sourceGroup.Properties[$index].Name.ToLowerInvariant() + if ($targetGroup.Properties.ContainsKey($candidate)) { + $nextAnchor = [pscustomobject]@{ + Name = $candidate + Line = $targetGroup.Properties[$candidate][0] + } + break + } + } + + $preferNextAnchor = $false + if ($null -ne $previousAnchor) { + if (-not $sourceLineGroup.Properties.ContainsKey($previousAnchor.Name)) { + throw "Cannot place source-only property '$($missing.Name)' because its previous placement anchor '$($previousAnchor.Name)' is not a single-line element." + } + + $sourcePreviousLine = $sourceLineGroup.Properties[$previousAnchor.Name][-1] + for ($lineIndex = $sourcePreviousLine + 1; $lineIndex -lt $sourcePropertyLine.Index; $lineIndex++) { + if (-not [string]::IsNullOrWhiteSpace($sourceLines.Lines[$lineIndex])) { + $preferNextAnchor = $true + break + } + } + } + + if ($preferNextAnchor -and $null -ne $nextAnchor) { + $insertAt = $nextAnchor.Line + } + elseif ($null -ne $previousAnchor) { + $insertAt = $previousAnchor.Line + 1 + } + elseif ($null -ne $nextAnchor) { + $insertAt = $nextAnchor.Line + } + else { + # No property from the source group is present in the target group at this index, so the + # Nth-group correspondence cannot be verified. Fail closed rather than risk inserting into + # an unrelated group (which would still produce well-formed XML). + throw "Cannot place source-only property '$($missing.Name)' because target PropertyGroup $($missing.GroupIndex) shares no property with the corresponding source group, so their correspondence cannot be verified." + } + + $targetLines.Lines.Insert($insertAt, $sourcePropertyLine.Text) + [void]$targetNames.Add($missing.Name) + $addedProperties.Add($missing.Name) + } + + $updatedText = [string]::Join($targetLines.NewLine, $targetLines.Lines) + if ($targetLines.EndsWithNewLine) { + $updatedText += $targetLines.NewLine + } + + [void](Read-SafeXmlDocument -Text $updatedText -Description "Updated target file '$($targetFile.Path)'") + $encoding = [System.Text.UTF8Encoding]::new($targetFile.HasBom) + [System.IO.File]::WriteAllText($targetFile.Path, $updatedText, $encoding) + + [pscustomobject]@{ + Changed = $true + AddedProperties = $addedProperties.ToArray() + } +} + +if ($MyInvocation.InvocationName -ne '.') { + if ([string]::IsNullOrWhiteSpace($SourcePath) -or [string]::IsNullOrWhiteSpace($TargetPath)) { + throw 'SourcePath and TargetPath are required.' + } + + $result = Merge-SourceOnlyVersionProperty -SourcePath $SourcePath -TargetPath $TargetPath -AncestorPath $AncestorPath + if ($result.Changed) { + Write-Output "Added source-only version properties: $($result.AddedProperties -join ', ')" + } + else { + Write-Output 'No source-only version properties needed reconciliation.' + } +} diff --git a/.github/workflows/merge-net11-to-release.yml b/.github/workflows/merge-net11-to-release.yml index 4d625b992653..d8a95aee3e6e 100644 --- a/.github/workflows/merge-net11-to-release.yml +++ b/.github/workflows/merge-net11-to-release.yml @@ -129,6 +129,14 @@ jobs: throw "Invalid MergeToBranch value '$mergeToBranch'." } + # The prefix regex still admits values Git rejects as refs (a trailing '/', '..', '@{', ...), + # so validate it as a real branch name before it is used to build refs. + git check-ref-format --branch $mergeToBranch *> $null + if ($LASTEXITCODE -ne 0) + { + throw "MergeToBranch value '$mergeToBranch' is not a valid Git branch name." + } + "merge_to_branch=$mergeToBranch" >> $env:GITHUB_OUTPUT - name: Check for an open net11.0 to release merge PR @@ -164,3 +172,183 @@ jobs: with: configuration_file_branch: 'net11.0' configuration_file_path: 'github-merge-flow-release-11.jsonc' + + ReconcileVersionProperties: + needs: + - CheckForOpenMergePullRequest + - Merge + if: >- + github.ref_name == 'net11.0' && + needs.CheckForOpenMergePullRequest.outputs.should_run == 'true' && + needs.Merge.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout net11.0 + uses: actions/checkout@v6 + with: + ref: net11.0 + fetch-depth: 0 + # This job pushes its reconciliation commit to the generated merge branch. + persist-credentials: true + + - name: Resolve merge branches + id: branches + shell: pwsh + run: | + $config = Get-Content -Raw github-merge-flow-release-11.jsonc | ConvertFrom-Json + $mergeToBranch = $config.'merge-flow-configurations'.'net11.0'.MergeToBranch + + if ($mergeToBranch -notmatch '^release/[A-Za-z0-9._/-]+$') + { + throw "Invalid MergeToBranch value '$mergeToBranch'." + } + + # The prefix regex still admits values Git rejects as refs (a trailing '/', '..', '@{', ...), + # and this value is used to build refspecs below, so validate it as a real branch name. + git check-ref-format --branch $mergeToBranch *> $null + if ($LASTEXITCODE -ne 0) + { + throw "MergeToBranch value '$mergeToBranch' is not a valid Git branch name." + } + + "merge_to_branch=$mergeToBranch" >> $env:GITHUB_OUTPUT + "merge_branch=merge/net11.0-to-$mergeToBranch" >> $env:GITHUB_OUTPUT + + - name: Preserve source-only version properties + shell: pwsh + env: + MERGE_BRANCH: ${{ steps.branches.outputs.merge_branch }} + MERGE_TO_BRANCH: ${{ steps.branches.outputs.merge_to_branch }} + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $false + + # Fetch the always-present source/target branches first. The generated merge branch is + # fetched separately below because Arcade legitimately produces no merge branch when there + # is nothing to merge (or only filtered automation commits), and a combined fetch would + # fail on the missing ref before the intended no-work check. + $refSpecs = @( + '+refs/heads/net11.0:refs/remotes/origin/net11.0' + ('+refs/heads/{0}:refs/remotes/origin/{0}' -f $env:MERGE_TO_BRANCH) + ) + git fetch --quiet origin @refSpecs + if ($LASTEXITCODE -ne 0) + { + throw "Failed to fetch merge refs." + } + + $commitCount = git rev-list --count "origin/$env:MERGE_TO_BRANCH..origin/net11.0" + if ($LASTEXITCODE -ne 0) + { + throw "Failed to determine whether net11.0 has commits to merge." + } + + if ([int]$commitCount -eq 0) + { + Write-Host 'No net11.0 commits need reconciliation.' + exit 0 + } + + # A missing generated merge branch is a clean no-op (Arcade merged nothing), not an error. + git fetch --quiet origin ('+refs/heads/{0}:refs/remotes/origin/{0}' -f $env:MERGE_BRANCH) + if ($LASTEXITCODE -ne 0) + { + Write-Host "Generated merge branch '$env:MERGE_BRANCH' does not exist; nothing to reconcile." + exit 0 + } + + git merge-base --is-ancestor origin/net11.0 "origin/$env:MERGE_BRANCH" + if ($LASTEXITCODE -ne 0) + { + throw "Generated merge branch '$env:MERGE_BRANCH' does not contain the current net11.0 head." + } + + git checkout --quiet -B $env:MERGE_BRANCH "origin/$env:MERGE_BRANCH" + if ($LASTEXITCODE -ne 0) + { + throw "Failed to check out generated merge branch '$env:MERGE_BRANCH'." + } + + $sourceVersions = Join-Path $env:RUNNER_TEMP 'net11-Versions.props' + $sourceProcess = Start-Process ` + -FilePath 'git' ` + -ArgumentList @('show', 'origin/net11.0:eng/Versions.props') ` + -RedirectStandardOutput $sourceVersions ` + -NoNewWindow ` + -Wait ` + -PassThru + if ($sourceProcess.ExitCode -ne 0) + { + throw "Failed to read eng/Versions.props from net11.0." + } + + # Extract the merge-base (three-way ancestor) copy of eng/Versions.props so the reconciler + # can tell a property genuinely added on net11.0 apart from one intentionally deleted on the + # release branch, and never resurrect a deleted pin. + $mergeBase = "$(git merge-base origin/net11.0 "origin/$env:MERGE_TO_BRANCH")".Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($mergeBase)) + { + throw "Failed to determine the merge base between net11.0 and $env:MERGE_TO_BRANCH." + } + + $ancestorVersions = Join-Path $env:RUNNER_TEMP 'ancestor-Versions.props' + $ancestorProcess = Start-Process ` + -FilePath 'git' ` + -ArgumentList @('show', "$($mergeBase):eng/Versions.props") ` + -RedirectStandardOutput $ancestorVersions ` + -NoNewWindow ` + -Wait ` + -PassThru + if ($ancestorProcess.ExitCode -ne 0) + { + throw "Failed to read eng/Versions.props from the merge base." + } + + & ./.github/scripts/Merge-SourceOnlyVersionProperties.ps1 ` + -SourcePath $sourceVersions ` + -TargetPath eng/Versions.props ` + -AncestorPath $ancestorVersions + + $changes = git status --porcelain -- eng/Versions.props + if ($LASTEXITCODE -ne 0) + { + throw "Failed to inspect reconciled eng/Versions.props." + } + + if ([string]::IsNullOrWhiteSpace(($changes | Out-String))) + { + Write-Host 'No source-only version properties need to be committed.' + exit 0 + } + + git config user.name 'github-actions[bot]' + if ($LASTEXITCODE -ne 0) + { + throw "Failed to configure the reconciliation commit author name." + } + + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + if ($LASTEXITCODE -ne 0) + { + throw "Failed to configure the reconciliation commit author email." + } + + git add -- eng/Versions.props + if ($LASTEXITCODE -ne 0) + { + throw "Failed to stage reconciled eng/Versions.props." + } + + git commit -m 'Preserve source-only version properties after release reset' + if ($LASTEXITCODE -ne 0) + { + throw "Failed to commit reconciled eng/Versions.props." + } + + git push origin "HEAD:$env:MERGE_BRANCH" + if ($LASTEXITCODE -ne 0) + { + throw "Failed to push reconciled merge branch '$env:MERGE_BRANCH'." + }