From bd0357d93953ebb927427f22b1230d6513b4dac4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 11:26:11 +0200 Subject: [PATCH 1/4] [ci] Retry MAUI restore during feed publication lag MAUI's prerelease template can resolve a newly published Microsoft.Extensions.Logging.Debug package while NuGet still holds an older Microsoft.Extensions.Logging version index. This causes NU1102 in the first gating R2R build even though the dependency is already on dotnet11. Pre-restore the Android-only generated project without the HTTP cache. Retry only correlated prerelease Logging NU1102 diagnostics, clear the HTTP cache between bounded attempts, and fail all other restore errors immediately. Clear stale metadata after success so later restore graphs see the same published package set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../automation/azure-pipelines-internal.yaml | 7 + .../automation/azure-pipelines-public.yaml | 7 + build-tools/automation/azure-pipelines.yaml | 7 + .../scripts/RestoreMauiTemplate.Tests.ps1 | 166 ++++++++++++++++ .../scripts/RestoreMauiTemplate.ps1 | 188 ++++++++++++++++++ .../yaml-templates/restore-maui-template.yaml | 25 +++ 6 files changed, 400 insertions(+) create mode 100644 build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 create mode 100644 build-tools/automation/scripts/RestoreMauiTemplate.ps1 create mode 100644 build-tools/automation/yaml-templates/restore-maui-template.yaml diff --git a/build-tools/automation/azure-pipelines-internal.yaml b/build-tools/automation/azure-pipelines-internal.yaml index 256e9e7f57a..ec3657712ad 100644 --- a/build-tools/automation/azure-pipelines-internal.yaml +++ b/build-tools/automation/azure-pipelines-internal.yaml @@ -963,6 +963,13 @@ extends: parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + - template: /build-tools/automation/yaml-templates/restore-maui-template.yaml@self + parameters: + project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + nugetConfig: $(Build.SourcesDirectory)/maui/NuGet.config + xaSourcePath: $(Build.SourcesDirectory)/android + logsDirectory: $(Build.StagingDirectory)/logs + - template: /build-tools/automation/yaml-templates/run-dotnet-preview.yaml@self parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj diff --git a/build-tools/automation/azure-pipelines-public.yaml b/build-tools/automation/azure-pipelines-public.yaml index 52c6c79ea1f..58a663dfe14 100644 --- a/build-tools/automation/azure-pipelines-public.yaml +++ b/build-tools/automation/azure-pipelines-public.yaml @@ -605,6 +605,13 @@ stages: parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + - template: /build-tools/automation/yaml-templates/restore-maui-template.yaml + parameters: + project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + nugetConfig: $(Build.SourcesDirectory)/maui/NuGet.config + xaSourcePath: $(Build.SourcesDirectory)/android + logsDirectory: $(Build.StagingDirectory)/logs + - template: /build-tools/automation/yaml-templates/run-dotnet-preview.yaml parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj diff --git a/build-tools/automation/azure-pipelines.yaml b/build-tools/automation/azure-pipelines.yaml index e2c14fc0b03..b43e2471dc8 100644 --- a/build-tools/automation/azure-pipelines.yaml +++ b/build-tools/automation/azure-pipelines.yaml @@ -223,6 +223,13 @@ extends: parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + - template: /build-tools/automation/yaml-templates/restore-maui-template.yaml@self + parameters: + project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj + nugetConfig: $(Build.SourcesDirectory)/maui/NuGet.config + xaSourcePath: $(Build.SourcesDirectory)/android + logsDirectory: $(Build.StagingDirectory)/logs + - template: /build-tools/automation/yaml-templates/run-dotnet-preview.yaml@self parameters: project: $(Build.StagingDirectory)/MauiTestProj/MauiTestProj.csproj diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 new file mode 100644 index 00000000000..adebcbc7051 --- /dev/null +++ b/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 @@ -0,0 +1,166 @@ +$ErrorActionPreference = 'Stop' +$restoreScript = Join-Path $PSScriptRoot 'RestoreMauiTemplate.ps1' +. $restoreScript -DefineOnly + +function Assert-Equal +{ + param ( + [object] $Expected, + [object] $Actual, + [string] $Message + ) + + if (-not [object]::Equals($Expected, $Actual)) { + throw "$Message Expected '$Expected', actual '$Actual'." + } +} + +function Assert-True +{ + param ( + [bool] $Condition, + [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function New-CommandResult +{ + param ( + [int] $ExitCode, + [string []] $Output + ) + + return [pscustomobject] @{ + ExitCode = $ExitCode + Output = $Output + } +} + +function Invoke-TestRestore +{ + param ( + [object []] $Responses, + [int] $MaxAttempts = 4 + ) + + $responseQueue = [Collections.Generic.Queue[object]]::new() + foreach ($response in $Responses) { + $responseQueue.Enqueue($response) + } + $invocations = [Collections.Generic.List[object]]::new() + $delays = [Collections.Generic.List[int]]::new() + + $commandInvoker = { + param ([string []] $CommandArguments) + + $invocations.Add([string []] @($CommandArguments)) + if ($responseQueue.Count -eq 0) { + throw "No command result was queued for '$($CommandArguments -join ' ')'." + } + return $responseQueue.Dequeue() + }.GetNewClosure() + $sleepAction = { + param ([int] $Seconds) + $delays.Add($Seconds) + }.GetNewClosure() + + $exitCode = Invoke-MauiTemplateRestore ` + -DotNetPath 'dotnet' ` + -Project 'MauiTestProj.csproj' ` + -NuGetConfig 'NuGet.config' ` + -MaxAttempts $MaxAttempts ` + -InitialRetryDelaySeconds 1 ` + -MaxRetryDelaySeconds 2 ` + -CommandInvoker $commandInvoker ` + -SleepAction $sleepAction + + return [pscustomobject] @{ + ExitCode = $exitCode + Invocations = $invocations + Delays = $delays + } +} + +$success = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) + (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) +) +Assert-Equal 0 $success.ExitCode 'An immediate restore success should succeed.' +Assert-Equal 2 $success.Invocations.Count 'An immediate restore success should clear stale metadata for later restore graphs.' +Assert-True ($success.Invocations[0] -contains '--no-http-cache') 'Restore must bypass the NuGet HTTP cache.' +Assert-True ($success.Invocations[0] -contains '--force-evaluate') 'Restore must force dependency reevaluation.' +Assert-Equal 'nuget locals http-cache --clear' ($success.Invocations[1] -join ' ') 'A successful restore should clear stale metadata for subsequent restores.' +Assert-Equal 0 $success.Delays.Count 'An immediate restore success should not wait.' + +$retryableOutput = @( + 'MauiTestProj.csproj : error NU1102: Unable to find package Microsoft.Extensions.Logging with version (>= 11.0.0-rc.2.26453.114)' + 'MauiTestProj.csproj : error NU1102: - Found 606 version(s) in dotnet11 [ Nearest version: 11.0.0-rc.2.26453.107 ]' +) +$recovered = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output $retryableOutput) + (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) + (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) +) +Assert-Equal 0 $recovered.ExitCode 'A publication-skew restore should recover.' +Assert-Equal 3 $recovered.Invocations.Count 'A recovered restore should clear the cache and retry once.' +Assert-Equal 'nuget locals http-cache --clear' ($recovered.Invocations[1] -join ' ') 'The retry should clear only the NuGet HTTP cache.' +Assert-Equal 1 $recovered.Delays.Count 'A recovered restore should wait once.' + +$unrelatedFailure = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output @('error NU1301: Unable to load the service index.')) +) +Assert-Equal 1 $unrelatedFailure.ExitCode 'An unrelated restore failure should fail.' +Assert-Equal 1 $unrelatedFailure.Invocations.Count 'An unrelated restore failure should not retry.' +Assert-Equal 0 $unrelatedFailure.Delays.Count 'An unrelated restore failure should not wait.' + +$mixedFailure = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output ($retryableOutput + 'error NU1301: Unable to load the service index.')) +) +Assert-Equal 1 $mixedFailure.ExitCode 'A mixed restore failure should fail.' +Assert-Equal 1 $mixedFailure.Invocations.Count 'A mixed restore failure should not retry.' +Assert-Equal 0 $mixedFailure.Delays.Count 'A mixed restore failure should not wait.' + +$differentPackageFailure = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output @( + 'MauiTestProj.csproj : error NU1102: Unable to find package Unrelated.Package with version (>= 11.0.0-rc.2.26453.114)' + 'MauiTestProj.csproj : error NU1102: - Found 10 version(s) in dotnet11 [ Nearest version: 11.0.0-rc.2.26453.107 ]' + )) +) +Assert-Equal 1 $differentPackageFailure.ExitCode 'An unrelated missing package should fail.' +Assert-Equal 1 $differentPackageFailure.Invocations.Count 'An unrelated missing package should not retry.' + +$partiallyRetryableFailure = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output ($retryableOutput + @( + 'MauiTestProj.csproj : error NU1102: Unable to find package Microsoft.Extensions.Logging.Abstractions with version (>= 11.0.0-rc.2.26453.114)' + 'MauiTestProj.csproj : error NU1102: - Found 0 version(s) in dotnet11' + ))) +) +Assert-Equal 1 $partiallyRetryableFailure.ExitCode 'Every missing Logging package should require evidence of publication skew.' +Assert-Equal 1 $partiallyRetryableFailure.Invocations.Count 'A partially retryable failure should not retry.' + +$persistentFailure = Invoke-TestRestore -MaxAttempts 3 -Responses @( + (New-CommandResult -ExitCode 1 -Output $retryableOutput) + (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) + (New-CommandResult -ExitCode 1 -Output $retryableOutput) + (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) + (New-CommandResult -ExitCode 1 -Output $retryableOutput) +) +Assert-Equal 1 $persistentFailure.ExitCode 'A persistently missing version should remain a hard failure.' +Assert-Equal 5 $persistentFailure.Invocations.Count 'A persistent failure should stop after the bounded attempts.' +Assert-Equal 2 $persistentFailure.Delays.Count 'A persistent failure should wait only between attempts.' +Assert-Equal 1 $persistentFailure.Delays[0] 'The first retry should use the initial delay.' +Assert-Equal 2 $persistentFailure.Delays[1] 'The second retry should use the capped backoff.' + +$cacheClearFailure = Invoke-TestRestore -Responses @( + (New-CommandResult -ExitCode 1 -Output $retryableOutput) + (New-CommandResult -ExitCode 7 -Output @('Cache clear failed.')) +) +Assert-Equal 7 $cacheClearFailure.ExitCode 'A cache-clear failure should be surfaced.' +Assert-Equal 2 $cacheClearFailure.Invocations.Count 'A cache-clear failure should stop before another restore.' +Assert-Equal 0 $cacheClearFailure.Delays.Count 'A cache-clear failure should not wait.' + +Write-Host 'MAUI template restore retry tests passed.' diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 new file mode 100644 index 00000000000..bae2d975721 --- /dev/null +++ b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 @@ -0,0 +1,188 @@ +[CmdletBinding()] +param ( + [string] $DotNetPath, + [string] $Project, + [string] $NuGetConfig, + [string] $BinaryLogPath, + [int] $MaxAttempts = 4, + [int] $InitialRetryDelaySeconds = 15, + [int] $MaxRetryDelaySeconds = 60, + [switch] $DefineOnly +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +function Test-RetryableMauiRestoreFailure +{ + param ( + [Parameter(Mandatory)] + [string] $Output + ) + + $errorLines = @($Output -split '\r?\n' | Where-Object { $_ -match '(?i)\berror\b' }) + if ($errorLines.Count -eq 0) { + return $false + } + if ($errorLines | Where-Object { $_ -notmatch '(?i)error NU1102:' }) { + return $false + } + + $missingPackageCount = 0 + $currentPackageHasOlderDotNet11Version = $false + foreach ($line in $Output -split '\r?\n') { + if ($line -match '(?i)error NU1102: Unable to find package') { + if ($missingPackageCount -gt 0 -and -not $currentPackageHasOlderDotNet11Version) { + return $false + } + if ($line -notmatch '(?i)error NU1102: Unable to find package Microsoft\.Extensions\.Logging(?:\.[A-Za-z0-9.-]+)? with version \(>= \d+\.\d+\.\d+-(preview|rc|alpha|beta)[^)]+\)') { + return $false + } + + $missingPackageCount++ + $currentPackageHasOlderDotNet11Version = $false + continue + } + if ($missingPackageCount -gt 0 -and $line -match '(?i)Found \d+ version\(s\) in dotnet11 \[ Nearest version: \d+\.\d+\.\d+[^]]* \]') { + $currentPackageHasOlderDotNet11Version = $true + } + } + + return $missingPackageCount -gt 0 -and $currentPackageHasOlderDotNet11Version +} + +function Clear-NuGetHttpCache +{ + param ( + [Parameter(Mandatory)] + [scriptblock] $CommandInvoker + ) + + $clearResult = & $CommandInvoker @('nuget', 'locals', 'http-cache', '--clear') + @($clearResult.Output) | ForEach-Object { Write-Host "$_" } + return [int] $clearResult.ExitCode +} + +function Invoke-MauiTemplateRestore +{ + param ( + [Parameter(Mandatory)] + [string] $DotNetPath, + [Parameter(Mandatory)] + [string] $Project, + [Parameter(Mandatory)] + [string] $NuGetConfig, + [string] $BinaryLogPath, + [int] $MaxAttempts = 4, + [int] $InitialRetryDelaySeconds = 15, + [int] $MaxRetryDelaySeconds = 60, + [scriptblock] $CommandInvoker, + [scriptblock] $SleepAction + ) + + if ($MaxAttempts -lt 1) { + throw "MaxAttempts must be at least 1." + } + if ($InitialRetryDelaySeconds -lt 0) { + throw "InitialRetryDelaySeconds cannot be negative." + } + if ($MaxRetryDelaySeconds -lt $InitialRetryDelaySeconds) { + throw "MaxRetryDelaySeconds cannot be less than InitialRetryDelaySeconds." + } + + if ($null -eq $CommandInvoker) { + $CommandInvoker = { + param ([string []] $CommandArguments) + + $output = @(& $DotNetPath @CommandArguments 2>&1) + return [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + }.GetNewClosure() + } + if ($null -eq $SleepAction) { + $SleepAction = { + param ([int] $Seconds) + Start-Sleep -Seconds $Seconds + } + } + + $restoreArguments = @( + 'restore' + $Project + '--configfile' + $NuGetConfig + '--no-http-cache' + '--force-evaluate' + ) + + $lastExitCode = 1 + $httpCacheCleared = $false + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $attemptArguments = @($restoreArguments) + if ($BinaryLogPath) { + $binaryLogDirectory = Split-Path -Parent $BinaryLogPath + if ($binaryLogDirectory -and -not (Test-Path -LiteralPath $binaryLogDirectory)) { + New-Item -ItemType Directory -Path $binaryLogDirectory -Force | Out-Null + } + + $extension = [IO.Path]::GetExtension($BinaryLogPath) + $fileName = [IO.Path]::GetFileNameWithoutExtension($BinaryLogPath) + $attemptBinaryLog = Join-Path $binaryLogDirectory "$fileName-attempt-$attempt$extension" + $attemptArguments += "-bl:$attemptBinaryLog" + } + + Write-Host "Restoring MAUI template dependencies (attempt $attempt of $MaxAttempts)..." + $result = & $CommandInvoker $attemptArguments + $output = @($result.Output) + $output | ForEach-Object { Write-Host "$_" } + $lastExitCode = [int] $result.ExitCode + if ($lastExitCode -eq 0) { + if (-not $httpCacheCleared) { + Write-Host "Clearing NuGet's HTTP cache so subsequent MAUI restore graphs cannot reuse stale package metadata." + $clearExitCode = Clear-NuGetHttpCache -CommandInvoker $CommandInvoker + if ($clearExitCode -ne 0) { + Write-Host "NuGet HTTP cache clearing failed with exit code $clearExitCode." + return $clearExitCode + } + } + return 0 + } + + $outputText = $output -join [Environment]::NewLine + if (-not (Test-RetryableMauiRestoreFailure -Output $outputText) -or $attempt -eq $MaxAttempts) { + return $lastExitCode + } + Write-Host "The dotnet11 feed has an older version than the requested prerelease package. Clearing NuGet's HTTP cache before retrying." + $clearExitCode = Clear-NuGetHttpCache -CommandInvoker $CommandInvoker + if ($clearExitCode -ne 0) { + Write-Host "NuGet HTTP cache clearing failed with exit code $clearExitCode." + return $clearExitCode + } + $httpCacheCleared = $true + + $retryDelaySeconds = [Math]::Min( + $MaxRetryDelaySeconds, + $InitialRetryDelaySeconds * [Math]::Pow(2, $attempt - 1) + ) + Write-Host "Retrying MAUI template restore after $retryDelaySeconds seconds..." + & $SleepAction ([int] $retryDelaySeconds) + } + + return $lastExitCode +} + +if (-not $DefineOnly) { + $exitCode = Invoke-MauiTemplateRestore ` + -DotNetPath $DotNetPath ` + -Project $Project ` + -NuGetConfig $NuGetConfig ` + -BinaryLogPath $BinaryLogPath ` + -MaxAttempts $MaxAttempts ` + -InitialRetryDelaySeconds $InitialRetryDelaySeconds ` + -MaxRetryDelaySeconds $MaxRetryDelaySeconds + if ($exitCode -ne 0) { + throw "MAUI template restore failed with exit code $exitCode." + } +} diff --git a/build-tools/automation/yaml-templates/restore-maui-template.yaml b/build-tools/automation/yaml-templates/restore-maui-template.yaml new file mode 100644 index 00000000000..9db7945b440 --- /dev/null +++ b/build-tools/automation/yaml-templates/restore-maui-template.yaml @@ -0,0 +1,25 @@ +parameters: + project: + nugetConfig: + xaSourcePath: + logsDirectory: + configuration: $(XA.Build.Configuration) + +steps: +- powershell: | + & '${{ parameters.xaSourcePath }}/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1' + displayName: Test MAUI template restore retry + +- powershell: | + if ([Environment]::OSVersion.Platform -eq "Unix") { + $dotnetPath = "${{ parameters.xaSourcePath }}/bin/${{ parameters.configuration }}/dotnet/dotnet" + } else { + $dotnetPath = "${{ parameters.xaSourcePath }}\bin\${{ parameters.configuration }}\dotnet\dotnet.exe" + } + + & '${{ parameters.xaSourcePath }}/build-tools/automation/scripts/RestoreMauiTemplate.ps1' ` + -DotNetPath $dotnetPath ` + -Project '${{ parameters.project }}' ` + -NuGetConfig '${{ parameters.nugetConfig }}' ` + -BinaryLogPath '${{ parameters.logsDirectory }}/MauiTestProj-Restore.binlog' + displayName: Restore MAUI template dependencies From 4bd949e6e326c16ba15b834a6bb5ebe13fe317e5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 21:46:50 +0200 Subject: [PATCH 2/4] [ci] Handle leaf-only MAUI restore binlog paths Use the current directory when BinaryLogPath has no parent directory. Add a focused regression assertion for the generated attempt log argument. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../automation/scripts/RestoreMauiTemplate.Tests.ps1 | 11 ++++++++++- .../automation/scripts/RestoreMauiTemplate.ps1 | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 index adebcbc7051..402e31465f3 100644 --- a/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 +++ b/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 @@ -44,7 +44,8 @@ function Invoke-TestRestore { param ( [object []] $Responses, - [int] $MaxAttempts = 4 + [int] $MaxAttempts = 4, + [string] $BinaryLogPath ) $responseQueue = [Collections.Generic.Queue[object]]::new() @@ -72,6 +73,7 @@ function Invoke-TestRestore -DotNetPath 'dotnet' ` -Project 'MauiTestProj.csproj' ` -NuGetConfig 'NuGet.config' ` + -BinaryLogPath $BinaryLogPath ` -MaxAttempts $MaxAttempts ` -InitialRetryDelaySeconds 1 ` -MaxRetryDelaySeconds 2 ` @@ -96,6 +98,13 @@ Assert-True ($success.Invocations[0] -contains '--force-evaluate') 'Restore must Assert-Equal 'nuget locals http-cache --clear' ($success.Invocations[1] -join ' ') 'A successful restore should clear stale metadata for subsequent restores.' Assert-Equal 0 $success.Delays.Count 'An immediate restore success should not wait.' +$leafBinaryLog = Invoke-TestRestore -BinaryLogPath 'restore.binlog' -Responses @( + (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) + (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) +) +$expectedBinaryLog = "-bl:$(Join-Path (Get-Location).Path 'restore-attempt-1.binlog')" +Assert-True ($leafBinaryLog.Invocations[0] -contains $expectedBinaryLog) 'A leaf-only binary log path should use the current directory.' + $retryableOutput = @( 'MauiTestProj.csproj : error NU1102: Unable to find package Microsoft.Extensions.Logging with version (>= 11.0.0-rc.2.26453.114)' 'MauiTestProj.csproj : error NU1102: - Found 606 version(s) in dotnet11 [ Nearest version: 11.0.0-rc.2.26453.107 ]' diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 index bae2d975721..0ac160dc7ef 100644 --- a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 +++ b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 @@ -123,6 +123,9 @@ function Invoke-MauiTemplateRestore $attemptArguments = @($restoreArguments) if ($BinaryLogPath) { $binaryLogDirectory = Split-Path -Parent $BinaryLogPath + if (-not $binaryLogDirectory) { + $binaryLogDirectory = (Get-Location).Path + } if ($binaryLogDirectory -and -not (Test-Path -LiteralPath $binaryLogDirectory)) { New-Item -ItemType Directory -Path $binaryLogDirectory -Force | Out-Null } From 2c0bdfcf43569309187ba2d315e1b2951a037ce0 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 10 Sep 2026 12:38:52 +0200 Subject: [PATCH 3/4] [ci] Remove MAUI restore pipeline self-tests Do not run helper self-tests in every MAUI integration job. Restrict retry classification to NuGet's exact simple prerelease lower-bound diagnostic so bounded or otherwise incompatible ranges remain hard failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../scripts/RestoreMauiTemplate.Tests.ps1 | 175 ------------------ .../scripts/RestoreMauiTemplate.ps1 | 2 +- .../yaml-templates/restore-maui-template.yaml | 4 - 3 files changed, 1 insertion(+), 180 deletions(-) delete mode 100644 build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 deleted file mode 100644 index 402e31465f3..00000000000 --- a/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1 +++ /dev/null @@ -1,175 +0,0 @@ -$ErrorActionPreference = 'Stop' -$restoreScript = Join-Path $PSScriptRoot 'RestoreMauiTemplate.ps1' -. $restoreScript -DefineOnly - -function Assert-Equal -{ - param ( - [object] $Expected, - [object] $Actual, - [string] $Message - ) - - if (-not [object]::Equals($Expected, $Actual)) { - throw "$Message Expected '$Expected', actual '$Actual'." - } -} - -function Assert-True -{ - param ( - [bool] $Condition, - [string] $Message - ) - - if (-not $Condition) { - throw $Message - } -} - -function New-CommandResult -{ - param ( - [int] $ExitCode, - [string []] $Output - ) - - return [pscustomobject] @{ - ExitCode = $ExitCode - Output = $Output - } -} - -function Invoke-TestRestore -{ - param ( - [object []] $Responses, - [int] $MaxAttempts = 4, - [string] $BinaryLogPath - ) - - $responseQueue = [Collections.Generic.Queue[object]]::new() - foreach ($response in $Responses) { - $responseQueue.Enqueue($response) - } - $invocations = [Collections.Generic.List[object]]::new() - $delays = [Collections.Generic.List[int]]::new() - - $commandInvoker = { - param ([string []] $CommandArguments) - - $invocations.Add([string []] @($CommandArguments)) - if ($responseQueue.Count -eq 0) { - throw "No command result was queued for '$($CommandArguments -join ' ')'." - } - return $responseQueue.Dequeue() - }.GetNewClosure() - $sleepAction = { - param ([int] $Seconds) - $delays.Add($Seconds) - }.GetNewClosure() - - $exitCode = Invoke-MauiTemplateRestore ` - -DotNetPath 'dotnet' ` - -Project 'MauiTestProj.csproj' ` - -NuGetConfig 'NuGet.config' ` - -BinaryLogPath $BinaryLogPath ` - -MaxAttempts $MaxAttempts ` - -InitialRetryDelaySeconds 1 ` - -MaxRetryDelaySeconds 2 ` - -CommandInvoker $commandInvoker ` - -SleepAction $sleepAction - - return [pscustomobject] @{ - ExitCode = $exitCode - Invocations = $invocations - Delays = $delays - } -} - -$success = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) - (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) -) -Assert-Equal 0 $success.ExitCode 'An immediate restore success should succeed.' -Assert-Equal 2 $success.Invocations.Count 'An immediate restore success should clear stale metadata for later restore graphs.' -Assert-True ($success.Invocations[0] -contains '--no-http-cache') 'Restore must bypass the NuGet HTTP cache.' -Assert-True ($success.Invocations[0] -contains '--force-evaluate') 'Restore must force dependency reevaluation.' -Assert-Equal 'nuget locals http-cache --clear' ($success.Invocations[1] -join ' ') 'A successful restore should clear stale metadata for subsequent restores.' -Assert-Equal 0 $success.Delays.Count 'An immediate restore success should not wait.' - -$leafBinaryLog = Invoke-TestRestore -BinaryLogPath 'restore.binlog' -Responses @( - (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) - (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) -) -$expectedBinaryLog = "-bl:$(Join-Path (Get-Location).Path 'restore-attempt-1.binlog')" -Assert-True ($leafBinaryLog.Invocations[0] -contains $expectedBinaryLog) 'A leaf-only binary log path should use the current directory.' - -$retryableOutput = @( - 'MauiTestProj.csproj : error NU1102: Unable to find package Microsoft.Extensions.Logging with version (>= 11.0.0-rc.2.26453.114)' - 'MauiTestProj.csproj : error NU1102: - Found 606 version(s) in dotnet11 [ Nearest version: 11.0.0-rc.2.26453.107 ]' -) -$recovered = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output $retryableOutput) - (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) - (New-CommandResult -ExitCode 0 -Output @('Restore succeeded.')) -) -Assert-Equal 0 $recovered.ExitCode 'A publication-skew restore should recover.' -Assert-Equal 3 $recovered.Invocations.Count 'A recovered restore should clear the cache and retry once.' -Assert-Equal 'nuget locals http-cache --clear' ($recovered.Invocations[1] -join ' ') 'The retry should clear only the NuGet HTTP cache.' -Assert-Equal 1 $recovered.Delays.Count 'A recovered restore should wait once.' - -$unrelatedFailure = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output @('error NU1301: Unable to load the service index.')) -) -Assert-Equal 1 $unrelatedFailure.ExitCode 'An unrelated restore failure should fail.' -Assert-Equal 1 $unrelatedFailure.Invocations.Count 'An unrelated restore failure should not retry.' -Assert-Equal 0 $unrelatedFailure.Delays.Count 'An unrelated restore failure should not wait.' - -$mixedFailure = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output ($retryableOutput + 'error NU1301: Unable to load the service index.')) -) -Assert-Equal 1 $mixedFailure.ExitCode 'A mixed restore failure should fail.' -Assert-Equal 1 $mixedFailure.Invocations.Count 'A mixed restore failure should not retry.' -Assert-Equal 0 $mixedFailure.Delays.Count 'A mixed restore failure should not wait.' - -$differentPackageFailure = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output @( - 'MauiTestProj.csproj : error NU1102: Unable to find package Unrelated.Package with version (>= 11.0.0-rc.2.26453.114)' - 'MauiTestProj.csproj : error NU1102: - Found 10 version(s) in dotnet11 [ Nearest version: 11.0.0-rc.2.26453.107 ]' - )) -) -Assert-Equal 1 $differentPackageFailure.ExitCode 'An unrelated missing package should fail.' -Assert-Equal 1 $differentPackageFailure.Invocations.Count 'An unrelated missing package should not retry.' - -$partiallyRetryableFailure = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output ($retryableOutput + @( - 'MauiTestProj.csproj : error NU1102: Unable to find package Microsoft.Extensions.Logging.Abstractions with version (>= 11.0.0-rc.2.26453.114)' - 'MauiTestProj.csproj : error NU1102: - Found 0 version(s) in dotnet11' - ))) -) -Assert-Equal 1 $partiallyRetryableFailure.ExitCode 'Every missing Logging package should require evidence of publication skew.' -Assert-Equal 1 $partiallyRetryableFailure.Invocations.Count 'A partially retryable failure should not retry.' - -$persistentFailure = Invoke-TestRestore -MaxAttempts 3 -Responses @( - (New-CommandResult -ExitCode 1 -Output $retryableOutput) - (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) - (New-CommandResult -ExitCode 1 -Output $retryableOutput) - (New-CommandResult -ExitCode 0 -Output @('Cleared NuGet HTTP cache.')) - (New-CommandResult -ExitCode 1 -Output $retryableOutput) -) -Assert-Equal 1 $persistentFailure.ExitCode 'A persistently missing version should remain a hard failure.' -Assert-Equal 5 $persistentFailure.Invocations.Count 'A persistent failure should stop after the bounded attempts.' -Assert-Equal 2 $persistentFailure.Delays.Count 'A persistent failure should wait only between attempts.' -Assert-Equal 1 $persistentFailure.Delays[0] 'The first retry should use the initial delay.' -Assert-Equal 2 $persistentFailure.Delays[1] 'The second retry should use the capped backoff.' - -$cacheClearFailure = Invoke-TestRestore -Responses @( - (New-CommandResult -ExitCode 1 -Output $retryableOutput) - (New-CommandResult -ExitCode 7 -Output @('Cache clear failed.')) -) -Assert-Equal 7 $cacheClearFailure.ExitCode 'A cache-clear failure should be surfaced.' -Assert-Equal 2 $cacheClearFailure.Invocations.Count 'A cache-clear failure should stop before another restore.' -Assert-Equal 0 $cacheClearFailure.Delays.Count 'A cache-clear failure should not wait.' - -Write-Host 'MAUI template restore retry tests passed.' diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 index 0ac160dc7ef..47a7bb179ba 100644 --- a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 +++ b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 @@ -35,7 +35,7 @@ function Test-RetryableMauiRestoreFailure if ($missingPackageCount -gt 0 -and -not $currentPackageHasOlderDotNet11Version) { return $false } - if ($line -notmatch '(?i)error NU1102: Unable to find package Microsoft\.Extensions\.Logging(?:\.[A-Za-z0-9.-]+)? with version \(>= \d+\.\d+\.\d+-(preview|rc|alpha|beta)[^)]+\)') { + if ($line -notmatch '(?i)error NU1102: Unable to find package Microsoft\.Extensions\.Logging(?:\.[A-Za-z0-9.-]+)? with version \(>= \d+\.\d+\.\d+-(preview|rc|alpha|beta)(?:\.[0-9A-Za-z-]+)+\)\s*$') { return $false } diff --git a/build-tools/automation/yaml-templates/restore-maui-template.yaml b/build-tools/automation/yaml-templates/restore-maui-template.yaml index 9db7945b440..25c2bb3abf5 100644 --- a/build-tools/automation/yaml-templates/restore-maui-template.yaml +++ b/build-tools/automation/yaml-templates/restore-maui-template.yaml @@ -6,10 +6,6 @@ parameters: configuration: $(XA.Build.Configuration) steps: -- powershell: | - & '${{ parameters.xaSourcePath }}/build-tools/automation/scripts/RestoreMauiTemplate.Tests.ps1' - displayName: Test MAUI template restore retry - - powershell: | if ([Environment]::OSVersion.Platform -eq "Unix") { $dotnetPath = "${{ parameters.xaSourcePath }}/bin/${{ parameters.configuration }}/dotnet/dotnet" From 9db99ad6d5ed84658ac155edefd8991e07813693 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 11 Sep 2026 16:58:06 +0200 Subject: [PATCH 4/4] [ci] Validate MAUI restore inputs Require non-empty dotnet, project, and NuGet configuration paths before starting the restore. Remove the unused definition-only mode now that the pipeline self-test is gone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../scripts/RestoreMauiTemplate.ps1 | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 index 47a7bb179ba..ae312cc564a 100644 --- a/build-tools/automation/scripts/RestoreMauiTemplate.ps1 +++ b/build-tools/automation/scripts/RestoreMauiTemplate.ps1 @@ -1,13 +1,18 @@ [CmdletBinding()] param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $DotNetPath, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Project, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $NuGetConfig, [string] $BinaryLogPath, [int] $MaxAttempts = 4, [int] $InitialRetryDelaySeconds = 15, - [int] $MaxRetryDelaySeconds = 60, - [switch] $DefineOnly + [int] $MaxRetryDelaySeconds = 60 ) Set-StrictMode -Version 3.0 @@ -67,10 +72,13 @@ function Invoke-MauiTemplateRestore { param ( [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $DotNetPath, [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Project, [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $NuGetConfig, [string] $BinaryLogPath, [int] $MaxAttempts = 4, @@ -176,16 +184,14 @@ function Invoke-MauiTemplateRestore return $lastExitCode } -if (-not $DefineOnly) { - $exitCode = Invoke-MauiTemplateRestore ` - -DotNetPath $DotNetPath ` - -Project $Project ` - -NuGetConfig $NuGetConfig ` - -BinaryLogPath $BinaryLogPath ` - -MaxAttempts $MaxAttempts ` - -InitialRetryDelaySeconds $InitialRetryDelaySeconds ` - -MaxRetryDelaySeconds $MaxRetryDelaySeconds - if ($exitCode -ne 0) { - throw "MAUI template restore failed with exit code $exitCode." - } +$exitCode = Invoke-MauiTemplateRestore ` + -DotNetPath $DotNetPath ` + -Project $Project ` + -NuGetConfig $NuGetConfig ` + -BinaryLogPath $BinaryLogPath ` + -MaxAttempts $MaxAttempts ` + -InitialRetryDelaySeconds $InitialRetryDelaySeconds ` + -MaxRetryDelaySeconds $MaxRetryDelaySeconds +if ($exitCode -ne 0) { + throw "MAUI template restore failed with exit code $exitCode." }