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[^>]*)?>.*\k>\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'."
+ }