diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 new file mode 100644 index 000000000000..b71503d14c2b --- /dev/null +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -0,0 +1,154 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$AgentOutputPath = $env:GH_AW_AGENT_OUTPUT, + [string]$StateDirectory = $env:LEAK_DEDUP_STATE_DIR, + [string]$Repository = $env:GITHUB_REPOSITORY +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +if ([string]::IsNullOrWhiteSpace($Repository)) { + throw 'GITHUB_REPOSITORY is required.' +} +if ([string]::IsNullOrWhiteSpace($StateDirectory)) { + throw 'LEAK_DEDUP_STATE_DIR is required.' +} + +$agentOutput = Read-RegularJsonFile -Path $AgentOutputPath +$createItems = @($agentOutput.items | Where-Object { $_.type -eq 'create_pull_request' }) +if ($createItems.Count -eq 0) { + Write-Host 'No create_pull_request safe-output requested; final leak de-dup gate is not applicable.' + return +} +if ($createItems.Count -ne 1) { + throw "Expected exactly one create_pull_request item, found $($createItems.Count)." +} + +$item = $createItems[0] +$title = [string]$item.title +if (-not $title.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + throw "The create-pull-request title must start with the literal '[leak-fix] ' prefix." +} + +$api = Get-CanonicalLeakApi -Title $title +if ([string]::IsNullOrWhiteSpace($api)) { + throw "Could not derive a canonical Type.Member from create-pull-request title '$title'." +} + +$fixMatches = [regex]::Matches(([string]$item.body), '(?m)^[ \t]*Fixes #(?[1-9][0-9]*)\b') +if ($fixMatches.Count -ne 1) { + throw 'The PR body must contain exactly one canonical Fixes line.' +} + +$issueNumber = [int]$fixMatches[0].Groups['number'].Value +$repo = [regex]::Escape($Repository) +$issue = [regex]::Escape([string]$issueNumber) +$targetRefsMatches = [regex]::Matches( + ([string]$item.body), + "(?m)^[ \t]*Refs:[ \t]*$repo#$issue\b" +) +if ($targetRefsMatches.Count -ne 1) { + throw "The PR body must contain exactly one exact-repository Refs line for issue #$issueNumber." +} + +$statePath = Join-Path $StateDirectory 'dedup-state.json' +$state = Read-RegularJsonFile -Path $statePath +Assert-LeakDedupState ` + -State $state ` + -IssueNumber $issueNumber ` + -Api $api ` + -Repository $Repository + +$merged = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--limit', '1000', + '--search', '"[leak-fix]" in:title', + '--json', 'number,title,body,baseRefName,mergedAt,url' + ) +) +if ($merged.Count -ge 1000) { + throw "Merged [leak-fix] search returned $($merged.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + +$authoritativeMerged = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests $merged ` + -Context 'Final merged leak-fix de-dup search' +) +$eligibleMerged = @($authoritativeMerged | Where-Object { + $null -ne $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) + }) +$mergedReverts = @( + Get-RelevantMergedLeakReverts ` + -Repository $Repository ` + -TargetPullRequests $eligibleMerged +) + +$open = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'open', + '--limit', '1000', + '--search', '"[leak-fix]" in:title', + '--json', 'number,title,body,baseRefName,mergedAt,url' + ) +) +if ($open.Count -ge 1000) { + throw "Open [leak-fix] search returned $($open.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + +$closed = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'closed', + '--limit', '1000', + '--search', '"[leak-fix]" in:title', + '--json', 'number,title,body,baseRefName,mergedAt' + ) +) +if ($closed.Count -ge 1000) { + throw "Closed [leak-fix] search returned $($closed.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} +$authoritativeClosed = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests $closed ` + -Context 'Final closed leak-fix attempt-cap search' +) +# The cap is one aggregate budget across both authoritative lanes. A fix merged or attempted +# in main or inflight/current represents the same canonical leak work; release lanes do not. +$closedAttempts = @($authoritativeClosed | Where-Object { + $referencesIssue = Test-LeakPrReferencesIssue ` + -Body ([string]$_.body) ` + -IssueNumber $issueNumber ` + -Repository $Repository + $null -eq $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and + ($referencesIssue -or + (Get-CanonicalExistingLeakApi -Title ([string]$_.title)) -ceq $api) + } | Sort-Object number -Unique) +if ($closedAttempts.Count -ge 3) { + throw "Final leak-fix attempt-cap gate blocked PR creation: $($closedAttempts.Count) closed-unmerged attempts already reference issue #$issueNumber or canonical API '$api'." +} + +$result = Get-LeakFixFinalDedupResult ` + -IssueNumber $issueNumber ` + -Api $api ` + -Repository $Repository ` + -MergedPullRequests $merged ` + -OpenPullRequests $open ` + -MergedRevertPullRequests $mergedReverts + +if ($result.Blocked) { + throw "Final leak-fix de-dup gate blocked PR creation: $($result.Reason)." +} + +Write-Host "Final leak-fix de-dup gate passed for issue #$issueNumber ($api): $($result.Reason)." diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 new file mode 100644 index 000000000000..24beb469d6a7 --- /dev/null +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -0,0 +1,133 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$AgentOutputPath = $env:GH_AW_AGENT_OUTPUT, + [string]$Repository = $env:GITHUB_REPOSITORY +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +if ([string]::IsNullOrWhiteSpace($Repository)) { + throw 'GITHUB_REPOSITORY is required.' +} + +$agentOutput = Read-RegularJsonFile -Path $AgentOutputPath +$createItems = @($agentOutput.items | Where-Object { $_.type -eq 'create_issue' }) +if ($createItems.Count -eq 0) { + Write-Host 'No create_issue safe-output requested; final leak-hunter de-dup gate is not applicable.' + return +} +if ($createItems.Count -gt 8) { + throw "Expected at most eight create_issue items, found $($createItems.Count)." +} + +$requestedItems = [System.Collections.Generic.List[object]]::new() +$requestedTitles = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal +) +$requestedApis = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal +) +foreach ($item in $createItems) { + $title = [string]$item.title + if (-not $title.StartsWith('[leak-scan] ', [StringComparison]::Ordinal)) { + throw "Every create-issue title must start with the literal '[leak-scan] ' prefix." + } + $api = Get-CanonicalLeakApi -Title $title + if ([string]::IsNullOrWhiteSpace($api)) { + throw "Could not derive a canonical Type.Member from create-issue title '$title'." + } + if (-not $requestedTitles.Add($title)) { + throw "Multiple create-issue outputs use the same exact title '$title'." + } + if (-not $requestedApis.Add($api)) { + throw "Multiple create-issue outputs use the same canonical API '$api'. Emit at most one issue per canonical API in each safe-output batch." + } + $requestedItems.Add([pscustomobject]@{ + Api = $api + Item = $item + }) +} + +$openIssues = @( + Invoke-LeakGhJson -Arguments @( + 'issue', 'list', + '--repo', $Repository, + '--search', '"[leak-scan]" in:title', + '--state', 'open', + '--label', 'agentic-workflows', + '--limit', '1000', + '--json', 'number,title,body,url' + ) +) +if ($openIssues.Count -ge 1000) { + throw "Open [leak-scan] search returned $($openIssues.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + +$merged = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--limit', '1000', + '--search', '"[leak-fix]" in:title', + '--json', 'number,title,body,baseRefName,mergedAt,url' + ) +) +if ($merged.Count -ge 1000) { + throw "Merged [leak-fix] search returned $($merged.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + +$authoritativeMerged = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests $merged ` + -Context 'Final merged leak-fix de-dup search' +) +$eligibleMerged = @($authoritativeMerged | Where-Object { + $null -ne $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) + }) +$mergedReverts = @( + Get-RelevantMergedLeakReverts ` + -Repository $Repository ` + -TargetPullRequests $eligibleMerged +) +$effectivelyReverted = @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository $Repository ` + -FixPullRequests $eligibleMerged ` + -MergedRevertPullRequests $mergedReverts +) +$reverted = [System.Collections.Generic.HashSet[int]]::new() +foreach ($number in $effectivelyReverted) { + [void]$reverted.Add($number) +} +$eligibleMerged = @($eligibleMerged | Where-Object { + -not $reverted.Contains([int]$_.number) + }) + +# Validation is intentionally batch-atomic. Do not rewrite agent output at this trusted +# boundary: any stale item aborts before Process Safe Outputs, and distinct items retry next run. +foreach ($requested in $requestedItems) { + $api = [string]$requested.Api + $openApiMatches = @($openIssues | Where-Object { + $issueTitle = [string]$_.title + $issueTitle.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and + (Get-CanonicalExistingLeakApi -Title $issueTitle) -ceq $api + }) + if ($openApiMatches.Count -gt 0) { + throw "Final leak-hunter de-dup gate blocked issue creation for '$api': same-API open issue match $($openApiMatches.number -join ', '). The safe-output batch is rejected atomically; other items can retry on the next scheduled run." + } + + $mergedApiMatches = @($eligibleMerged | Where-Object { + (Get-CanonicalExistingLeakApi -Title ([string]$_.title)) -ceq $api + }) + if ($mergedApiMatches.Count -gt 0) { + throw "Final leak-hunter de-dup gate blocked issue creation for '$api': same-API merged fix match $($mergedApiMatches.number -join ', '). The safe-output batch is rejected atomically; other items can retry on the next scheduled run." + } +} + +$apis = @($requestedItems | ForEach-Object { $_.Api } | Sort-Object -Unique) +Write-Host "Final leak-hunter de-dup gate passed for APIs: $($apis -join ', ')." diff --git a/.github/scripts/Get-CanonicalLeakApi.ps1 b/.github/scripts/Get-CanonicalLeakApi.ps1 new file mode 100644 index 000000000000..b3b6ea9f0581 --- /dev/null +++ b/.github/scripts/Get-CanonicalLeakApi.ps1 @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Title, + [switch]$ExistingTitle +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$api = if ($ExistingTitle) { + Get-CanonicalExistingLeakApi -Title $Title +} else { + Get-CanonicalLeakApi -Title $Title +} +if (-not [string]::IsNullOrWhiteSpace($api)) { + Write-Output $api +} diff --git a/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 new file mode 100644 index 000000000000..32cd0cf49f3e --- /dev/null +++ b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 @@ -0,0 +1,44 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$MergedFixTsvPath, + [Parameter(Mandatory = $true)][string]$MergedRevertsJsonPath, + [Parameter(Mandatory = $true)][string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$fixPullRequests = @( + Get-Content -LiteralPath $MergedFixTsvPath | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + $fields = $_ -split "`t", 4 + if ($fields.Count -lt 3 -or + $fields[1] -notmatch '^[1-9][0-9]*$' -or + [string]::IsNullOrWhiteSpace($fields[2])) { + throw "Malformed merged-fix TSV row: $_" + } + [pscustomobject]@{ + number = [int]$fields[1] + baseRefName = $fields[2] + } + } +) + +$reverts = @( + Get-Content -LiteralPath $MergedRevertsJsonPath -Raw | + ConvertFrom-Json | + Where-Object { $null -ne $_.mergedAt } +) + +$effectiveReverted = @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository $Repository ` + -FixPullRequests $fixPullRequests ` + -MergedRevertPullRequests $reverts +) + +Set-Content -LiteralPath $OutputPath -Value $effectiveReverted diff --git a/.github/scripts/Get-RelevantMergedLeakReverts.ps1 b/.github/scripts/Get-RelevantMergedLeakReverts.ps1 new file mode 100644 index 000000000000..70d4dc3b7a20 --- /dev/null +++ b/.github/scripts/Get-RelevantMergedLeakReverts.ps1 @@ -0,0 +1,36 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$MergedFixTsvPath, + [Parameter(Mandatory = $true)][string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$fixPullRequests = @( + Get-Content -LiteralPath $MergedFixTsvPath | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + $fields = $_ -split "`t", 4 + if ($fields.Count -lt 3 -or + $fields[1] -notmatch '^[1-9][0-9]*$' -or + [string]::IsNullOrWhiteSpace($fields[2])) { + throw "Malformed merged-fix TSV row: $_" + } + [pscustomobject]@{ + number = [int]$fields[1] + baseRefName = $fields[2] + } + } +) + +$reverts = @( + Get-RelevantMergedLeakReverts ` + -Repository $Repository ` + -TargetPullRequests $fixPullRequests +) +ConvertTo-Json -InputObject @($reverts) -Depth 5 | + Set-Content -LiteralPath $OutputPath diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 new file mode 100644 index 000000000000..49d5e2b36107 --- /dev/null +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -0,0 +1,1905 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + + function New-LeakPr { + param( + [int]$Number, + [string]$Title, + [string]$Body = '', + [string]$Base = 'main', + [bool]$Merged = $true + ) + + [pscustomobject]@{ + number = $Number + title = $Title + body = $Body + baseRefName = $Base + mergedAt = if ($Merged) { '2026-08-10T00:00:00Z' } else { $null } + url = "https://github.com/dotnet/maui/pull/$Number" + } + } +} + +Describe 'native gh invocation' { + It 'pins native-command errors to the structured exit-code path' { + $module = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') + $helper = [regex]::Match( + $module, + '(?s)function Invoke-LeakGhJson \{.*?\n\}' + ).Value + + $preferenceIndex = $helper.IndexOf('$PSNativeCommandUseErrorActionPreference = $false') + $invokeIndex = $helper.IndexOf('$output = & gh @Arguments 2>&1') + + $preferenceIndex | Should -BeGreaterOrEqual 0 + $invokeIndex | Should -BeGreaterOrEqual 0 + $preferenceIndex | Should -BeLessThan $invokeIndex + } + + Context 'bounded transient retries' { + BeforeEach { + $global:leakGhAttemptCount = 0 + $global:leakGhResponses = [System.Collections.Generic.Queue[object]]::new() + $global:leakGhDelays = [System.Collections.Generic.List[int]]::new() + $global:leakGhNow = [DateTimeOffset]::FromUnixTimeSeconds(2000000000) + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:leakGhAttemptCount++ + $response = $global:leakGhResponses.Dequeue() + $global:LASTEXITCODE = [int]$response.ExitCode + if (-not [string]::IsNullOrWhiteSpace([string]$response.Stderr)) { + Write-Error ([string]$response.Stderr) -ErrorAction Continue + } + Write-Output ([string]$response.Stdout) + } + } + + AfterEach { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable leakGhAttemptCount, leakGhResponses, leakGhDelays, leakGhNow ` + -Scope Global -ErrorAction SilentlyContinue + } + + It 'uses the exponential fallback for transient failures without server timing' { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'HTTP 503: Service Unavailable' + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"value":42}' + }) + + $result = Invoke-LeakGhJson ` + -Arguments @('api', 'test') ` + -RetryBaseDelaySeconds 2 ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + + $result.value | Should -Be 42 + $global:leakGhAttemptCount | Should -Be 2 + $global:leakGhDelays | Should -Be @(2) + } + + It 'honors numeric Retry-After timing case-insensitively' { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'HTTP 403: forbidden; rEtRy-AfTeR: 17' + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"value":42}' + }) + + $result = Invoke-LeakGhJson ` + -Arguments @('api', 'test') ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + + $result.value | Should -Be 42 + $global:leakGhDelays | Should -Be @(17) + } + + It 'honors Retry-After HTTP dates and rate-limit reset timestamps' { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'HTTP 403: forbidden; Retry-After: Wed, 18 May 2033 03:33:32 GMT' + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"kind":"date"}' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'HTTP 403: forbidden; X-RaTeLiMiT-ReSeT=2000000013' + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"kind":"reset"}' + }) + + $dateResult = Invoke-LeakGhJson ` + -Arguments @('api', 'date') ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + $resetResult = Invoke-LeakGhJson ` + -Arguments @('api', 'reset') ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + + $dateResult.kind | Should -Be 'date' + $resetResult.kind | Should -Be 'reset' + $global:leakGhDelays | Should -Be @(12, 13) + } + + It 'handles malformed, negative, past, and excessive metadata safely' { + @( + 'HTTP 429 rate limit; Retry-After: later; X-RateLimit-Reset: unknown' + 'HTTP 429 rate limit; Retry-After: -9; X-RateLimit-Reset: -5' + 'HTTP 403: forbidden; X-RateLimit-Reset: 1999999995' + 'HTTP 429 rate limit; Retry-After: 999999; X-RateLimit-Reset: 253402300799' + ) | ForEach-Object { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = $_ + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"value":42}' + }) + } + + 1..4 | ForEach-Object { + $result = Invoke-LeakGhJson ` + -Arguments @('api', "case-$_") ` + -RetryBaseDelaySeconds 2 ` + -MaximumServerDelaySeconds 120 ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + $result.value | Should -Be 42 + } + + $global:leakGhDelays | Should -Be @(2, 2, 120) + } + + It 'fails closed after exhausting the bounded transient retry budget' { + 1..3 | ForEach-Object { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'read: connection reset by peer' + Stdout = '' + }) + } + + { + Invoke-LeakGhJson ` + -Arguments @('api', 'test') ` + -MaximumAttempts 3 ` + -RetryBaseDelaySeconds 2 ` + -DelayAction { + param([int]$Seconds) + $global:leakGhDelays.Add($Seconds) + } ` + -UtcNowProvider { $global:leakGhNow } + } | Should -Throw '*failed with exit code 1 after 3 attempt(s)*' + + $global:leakGhAttemptCount | Should -Be 3 + $global:leakGhDelays | Should -Be @(2, 4) + } + + It 'does not retry a permanent gh failure' { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 1 + Stderr = 'HTTP 401: Bad credentials' + Stdout = '' + }) + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{"unexpected":true}' + }) + + { + Invoke-LeakGhJson ` + -Arguments @('api', 'test') ` + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } + } | Should -Throw '*failed with exit code 1 after 1 attempt(s)*' + + $global:leakGhAttemptCount | Should -Be 1 + $global:leakGhResponses.Count | Should -Be 1 + } + + It 'does not retry successful empty or invalid JSON responses' { + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '' + }) + { + Invoke-LeakGhJson ` + -Arguments @('api', 'empty') ` + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } + } | Should -Throw '*returned an empty response*' + $global:leakGhAttemptCount | Should -Be 1 + + $global:leakGhResponses.Enqueue([pscustomobject]@{ + ExitCode = 0 + Stderr = '' + Stdout = '{' + }) + { + Invoke-LeakGhJson ` + -Arguments @('api', 'invalid') ` + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } + } | Should -Throw '*returned invalid JSON*' + $global:leakGhAttemptCount | Should -Be 2 + } + } +} + +Describe 'shared regular JSON file validation' { + It 'reads a regular bounded JSON file' { + $path = Join-Path $TestDrive 'valid.json' + '{"value":42}' | Set-Content -LiteralPath $path + + (Read-RegularJsonFile -Path $path).value | Should -Be 42 + } + + It 'rejects missing, empty, oversized, and invalid JSON files' { + { + Read-RegularJsonFile -Path (Join-Path $TestDrive 'missing.json') + } | Should -Throw '*Required JSON file is missing*' + + $emptyPath = Join-Path $TestDrive 'empty.json' + Set-Content -LiteralPath $emptyPath -Value '' + { + Read-RegularJsonFile -Path $emptyPath + } | Should -Throw '*empty or too large*' + + $oversizedPath = Join-Path $TestDrive 'oversized.json' + Set-Content -LiteralPath $oversizedPath -Value ('x' * (1MB + 1)) -NoNewline + { + Read-RegularJsonFile -Path $oversizedPath + } | Should -Throw '*empty or too large*' + + $invalidPath = Join-Path $TestDrive 'invalid.json' + Set-Content -LiteralPath $invalidPath -Value '{' + { + Read-RegularJsonFile -Path $invalidPath + } | Should -Throw '*Invalid JSON*' + } + + It 'rejects a symbolic-link JSON file' { + $target = Join-Path $TestDrive 'target.json' + $link = Join-Path $TestDrive 'link.json' + '{"value":42}' | Set-Content -LiteralPath $target + New-Item -ItemType SymbolicLink -Path $link -Target $target | Out-Null + + { + Read-RegularJsonFile -Path $link + } | Should -Throw '*Refusing symbolic-link JSON file*' + } + + It 'is defined only by the shared module used by both gates' { + $fixGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $hunterGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' + ) -Raw + + (Get-Command Read-RegularJsonFile).ModuleName | Should -Be 'LeakWorkflowDedup' + $fixGate | Should -Not -Match 'function Read-RegularJsonFile' + $hunterGate | Should -Not -Match 'function Read-RegularJsonFile' + } +} + +Describe 'authoritative leak-fix branch scope' { + It 'selects main and inflight/current as one scope while excluding release branches' { + $selected = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests @( + (New-LeakPr -Number 41 -Title '[leak-fix] Fix First.Api leak' -Base 'main') + (New-LeakPr -Number 42 -Title '[leak-fix] Fix Second.Api leak' -Base 'inflight/current') + (New-LeakPr -Number 43 -Title '[leak-fix] Fix Third.Api leak' -Base 'release/10.0.1xx-sr9') + ) ` + -Context 'test branch scope' + ) + + ($selected.number -join ',') | Should -Be '41,42' + } + + It 'fails closed when baseRefName is missing or malformed' { + $missing = [pscustomobject]@{ + number = 44 + title = '[leak-fix] Fix Missing.Api leak' + } + $malformed = New-LeakPr ` + -Number 45 ` + -Title '[leak-fix] Fix Malformed.Api leak' ` + -Base ' main ' + + { + Select-LeakAuthoritativePullRequests ` + -PullRequests @($missing) ` + -Context 'test branch scope' + } | Should -Throw '*missing baseRefName*' + { + Select-LeakAuthoritativePullRequests ` + -PullRequests @($malformed) ` + -Context 'test branch scope' + } | Should -Throw '*malformed baseRefName*' + } +} + +Describe 'fresh-shell de-dup state' { + It 'fails closed when persisted identity does not match the requested PR' { + $state = [pscustomobject]@{ + issue_number = 42 + api = 'Picker.ItemsSource' + repository = 'dotnet/maui' + different_mechanism_prs = @() + } + + { + Assert-LeakDedupState ` + -State $state ` + -IssueNumber 43 ` + -Api 'Picker.ItemsSource' ` + -Repository 'dotnet/maui' + } | Should -Throw '*does not match PR issue*' + } + + It 'rejects agent-authored different-mechanism overrides' { + $state = [pscustomobject]@{ + issue_number = 42 + api = 'Picker.ItemsSource' + repository = 'dotnet/maui' + different_mechanism_prs = @( + [pscustomobject]@{ number = 100; basis = 'too short' } + ) + } + + { + Assert-LeakDedupState ` + -State $state ` + -IssueNumber 42 ` + -Api 'Picker.ItemsSource' ` + -Repository 'dotnet/maui' + } | Should -Throw '*do not accept agent-authored different-mechanism overrides*' + } +} + +Describe 'canonical leak API title parsing' { + It 'extracts the API only from the anchored leak-scan title position' { + Get-CanonicalLeakApi ` + -Title '[leak-scan] Microsoft.Maui.Controls.Picker.ItemsSource — collection retention' | + Should -Be 'Picker.ItemsSource' + } + + It 'extracts the API only from the anchored leak-fix title position' { + Get-CanonicalLeakApi ` + -Title '[leak-fix] Fix Microsoft.Maui.Controls.Picker.ItemsSource memory leak' | + Should -Be 'Picker.ItemsSource' + } + + It 'accepts supported punctuation immediately after the anchored API' { + @( + '[leak-fix] Fix Picker.ItemsSource, clear stale subscriptions' + '[leak-fix] Fix Picker.ItemsSource: clear stale subscriptions' + '[leak-fix] Fix Picker.ItemsSource-clear stale subscriptions' + '[leak-fix] Fix Picker.ItemsSource–clear stale subscriptions' + '[leak-fix] Fix Picker.ItemsSource—clear stale subscriptions' + '[leak-fix] Fix Picker.ItemsSource(clear stale subscriptions)' + '[leak-fix] Fix Picker.ItemsSource.' + ) | ForEach-Object { + Get-CanonicalLeakApi -Title $_ | Should -Be 'Picker.ItemsSource' + } + } + + It 'preserves non-MAUI qualification to prevent namespace collisions' { + $foo = Get-CanonicalLeakApi ` + -Title '[leak-fix] Fix Foo.Bar.CollectionView.ItemsSource leak' + $baz = Get-CanonicalLeakApi ` + -Title '[leak-fix] Fix Baz.Qux.CollectionView.ItemsSource leak' + + $foo | Should -Be 'Foo.Bar.CollectionView.ItemsSource' + $baz | Should -Be 'Baz.Qux.CollectionView.ItemsSource' + $foo | Should -Not -Be $baz + } + + It 'keeps short and legacy Microsoft.Maui-qualified keys stable' { + Get-CanonicalLeakApi -Title '[leak-fix] Fix Picker.ItemsSource leak' | + Should -Be 'Picker.ItemsSource' + Get-CanonicalLeakApi ` + -Title '[leak-fix] Fix Microsoft.Maui.Controls.Picker.ItemsSource leak' | + Should -Be 'Picker.ItemsSource' + } + + It 'rejects a URL before an otherwise valid API' { + (Get-CanonicalLeakApi ` + -Title '[leak-fix] Investigate https://github.com/dotnet/maui/issues/123 for Picker.ItemsSource') | + Should -BeNullOrEmpty + } + + It 'rejects an earlier namespace token in a malformed title' { + (Get-CanonicalLeakApi ` + -Title '[leak-fix] Investigate Microsoft.Maui.Controls before Picker.ItemsSource') | + Should -BeNullOrEmpty + } + + It 'rejects tagged titles that do not follow the expected title grammar' { + (Get-CanonicalLeakApi -Title '[leak-fix] Picker.ItemsSource memory leak') | + Should -BeNullOrEmpty + (Get-CanonicalLeakApi -Title '[leak-scan] Investigate Picker.ItemsSource retention') | + Should -BeNullOrEmpty + (Get-CanonicalLeakApi -Title '[leak-fix] Fix Picker.ItemsSource/Other retention') | + Should -BeNullOrEmpty + } + + It 'keeps legacy compatibility out of strict new-output parsing' { + (Get-CanonicalLeakApi ` + -Title '[leak-scan] Shell BackButtonBehavior.Command leaks via ICommand') | + Should -BeNullOrEmpty + (Get-CanonicalLeakApi ` + -Title '[leak-fix] Fix Shell BackButtonBehavior.Command memory leak') | + Should -BeNullOrEmpty + } + + It 'recognizes the known Shell prefix only for existing issue and fix titles' { + Get-CanonicalExistingLeakApi ` + -Title '[leak-scan] Shell BackButtonBehavior.Command leaks via ICommand' | + Should -Be 'BackButtonBehavior.Command' + Get-CanonicalExistingLeakApi ` + -Title '[leak-fix] Fix Shell BackButtonBehavior.Command memory leak' | + Should -Be 'BackButtonBehavior.Command' + } + + It 'does not scan URLs or arbitrary later identifiers in existing titles' { + @( + '[leak-scan] Investigate BackButtonBehavior.Command retention' + '[leak-scan] Shell investigate BackButtonBehavior.Command retention' + '[leak-scan] Shell https://github.com/dotnet/maui/issues/36345 BackButtonBehavior.Command' + '[leak-fix] Fix Shell details at https://example.test/BackButtonBehavior.Command' + ) | ForEach-Object { + (Get-CanonicalExistingLeakApi -Title $_) | Should -BeNullOrEmpty + } + } +} + +Describe 'trusted final duplicate gate' { + It 'conservatively blocks same-API matches without an independent override' { + $existing = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` + -Body 'Fixes #10' + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($existing) ` + -OpenPullRequests @() + + $result.Blocked | Should -BeTrue + $result.ApiMatches.number | Should -Be 100 + } + + It 'uses ordinal API identity so exact C# casing dedups while casing-only identifiers remain distinct' { + $existing = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' + + $exact = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($existing) ` + -OpenPullRequests @() + $caseVariant = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.gradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($existing) ` + -OpenPullRequests @() + + $exact.Blocked | Should -BeTrue + $caseVariant.Blocked | Should -BeFalse + } + + It 'blocks the known legacy form when it appears on an existing fix' { + $existing = New-LeakPr ` + -Number 104 ` + -Title '[leak-fix] Fix Shell BackButtonBehavior.Command memory leak' + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'BackButtonBehavior.Command' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($existing) ` + -OpenPullRequests @() + + $result.Blocked | Should -BeTrue + $result.ApiMatches.number | Should -Be 104 + } + + It 'blocks a same-API PR that appeared after Step 3' { + $newOpen = New-LeakPr ` + -Number 101 ` + -Title '[leak-fix] Fix Microsoft.Maui.Controls.GradientBrush.GradientStops reset leak' ` + -Merged $false + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @() ` + -OpenPullRequests @($newOpen) + + $result.Blocked | Should -BeTrue + $result.ApiMatches.number | Should -Be 101 + } + + It 'ignores a same-API open PR targeting an unrelated release branch' { + $releaseOpen = New-LeakPr ` + -Number 103 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" ` + -Base 'release/10.0.1xx-sr9' ` + -Merged $false + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @() ` + -OpenPullRequests @($releaseOpen) + + $result.Blocked | Should -BeFalse + $result.DirectMatches.Count | Should -Be 0 + $result.ApiMatches.Count | Should -Be 0 + } + + It 'always blocks a direct issue reference' { + $direct = New-LeakPr ` + -Number 102 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($direct) ` + -OpenPullRequests @() + + $result.Blocked | Should -BeTrue + $result.DirectMatches.number | Should -Be 102 + } + + It 'does not treat an effectively reverted merged fix as a duplicate' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Restore collection cleanup' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + $revert = New-LeakPr ` + -Number 200 ` + -Title 'Revert leak fix' ` + -Body 'Reverts dotnet/maui#100' + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'GradientBrush.GradientStops' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($fix) ` + -OpenPullRequests @() ` + -MergedRevertPullRequests @($revert) + + $result.Blocked | Should -BeFalse + $result.EffectivelyReverted | Should -Be @(100) + } + + It 'blocks a cyclic fix conservatively while still resolving unrelated candidates' { + $cyclicFix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' ` + -Body 'Fixes #10' + $unrelatedFix = New-LeakPr ` + -Number 110 ` + -Title '[leak-fix] Fix ListView.RefreshCommand leak' ` + -Body 'Fixes #11' + $reverts = @( + New-LeakPr ` + -Number 200 ` + -Title 'Revert Picker fix and cyclic peer' ` + -Body "Reverts #100`nReverts #300" + New-LeakPr ` + -Number 300 ` + -Title 'Revert cyclic peer' ` + -Body 'Reverts #200' + New-LeakPr ` + -Number 210 ` + -Title 'Revert unrelated fix' ` + -Body 'Reverts #110' + ) + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'Picker.ItemsSource' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($cyclicFix, $unrelatedFix) ` + -OpenPullRequests @() ` + -MergedRevertPullRequests $reverts + + $result.Blocked | Should -BeTrue + $result.ApiMatches.number | Should -Be 100 + $result.EffectivelyReverted | Should -Be @(110) + } + + It 'honors a definite terminal reverter alongside a cycle-entangled sibling' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' ` + -Body 'Fixes #10' + $reverts = @( + New-LeakPr ` + -Number 200 ` + -Title 'Cycle-entangled sibling' ` + -Body "Reverts #100`nReverts #300" + New-LeakPr ` + -Number 300 ` + -Title 'Cycle peer' ` + -Body 'Reverts #200' + New-LeakPr ` + -Number 201 ` + -Title 'Definite terminal sibling' ` + -Body 'Reverts #100' + ) + + $result = Get-LeakFixFinalDedupResult ` + -IssueNumber 20 ` + -Api 'Picker.ItemsSource' ` + -Repository 'dotnet/maui' ` + -MergedPullRequests @($fix) ` + -OpenPullRequests @() ` + -MergedRevertPullRequests $reverts + + $result.Blocked | Should -BeFalse + $result.EffectivelyReverted | Should -Be @(100) + } +} + +Describe 'effective recursive revert state' { + It 'memoizes ambiguous states at cycle and propagation return paths' { + $module = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' + ) -Raw + + ([regex]::Matches( + $module, + '\$memo\[\$PullRequestNumber\] = \$ambiguousState' + )).Count | Should -Be 2 + } + + It 'keeps an unreverted fix active' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests @() + ).Count | Should -Be 0 + } + + It 'excludes a fix after one active revert' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert leak fix' -Body 'Reverts dotnet/maui#100' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100) + } + + It 'accepts a repository-local revert reference' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert leak fix' -Body 'Reverts #100' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100) + } + + It 'accepts markdown formatting around a repository-local revert reference' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert leak fix' -Body '> - **Reverts #100**' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100) + } + + It 'rejects a revert reference qualified to another repository' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert unrelated fix' -Body 'Reverts dotnet/runtime#100' + ) + + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ).Count | Should -Be 0 + } + + It 'reinstates a fix after its revert is itself reverted' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert leak fix' -Body 'Reverts dotnet/maui#100' + New-LeakPr -Number 300 -Title 'Revert the revert' -Body 'Reverts dotnet/maui#200' + ) + + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ).Count | Should -Be 0 + } + + It 'handles a deeper odd effective chain' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert A' -Body 'Reverts dotnet/maui#100' + New-LeakPr -Number 300 -Title 'Revert A again' -Body 'Reverts dotnet/maui#200' + New-LeakPr -Number 400 -Title 'Revert A re-revert' -Body 'Reverts dotnet/maui#300' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100) + } + + It 'keeps a fix reverted when multiple independent sibling reverts remain active' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert A' -Body 'Reverts dotnet/maui#100' + New-LeakPr -Number 201 -Title 'Revert B' -Body 'Reverts dotnet/maui#100' + ) + + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ) | Should -Be @(100) + } + + It 'keeps a fix reverted while any independent sibling revert remains active' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + $reverts = @( + New-LeakPr -Number 200 -Title 'Revert A' -Body 'Reverts dotnet/maui#100' + New-LeakPr -Number 201 -Title 'Revert B' -Body 'Reverts dotnet/maui#100' + New-LeakPr -Number 300 -Title 'Restore only A' -Body 'Reverts dotnet/maui#200' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100) + } + + It 'ignores a servicing-branch revert of a main fix' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' ` + -Base main + $releaseRevert = New-LeakPr ` + -Number 200 ` + -Title 'Revert leak fix for servicing' ` + -Body 'Reverts dotnet/maui#100' ` + -Base 'release/10.0.1xx-sr9' + + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests @($releaseRevert) + ).Count | Should -Be 0 + } + + It 'scopes main and inflight revert chains independently' { + $mainFix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' ` + -Base main + $inflightFix = New-LeakPr ` + -Number 110 ` + -Title '[leak-fix] Fix ListView.RefreshCommand leak' ` + -Base 'inflight/current' + $reverts = @( + New-LeakPr ` + -Number 200 ` + -Title 'Revert main fix' ` + -Body 'Reverts dotnet/maui#100' ` + -Base main + New-LeakPr ` + -Number 210 ` + -Title 'Unrelated main revert of inflight PR number' ` + -Body 'Reverts dotnet/maui#110' ` + -Base main + New-LeakPr ` + -Number 220 ` + -Title 'Revert inflight fix' ` + -Body 'Reverts dotnet/maui#110' ` + -Base 'inflight/current' + ) + + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($mainFix, $inflightFix) ` + -MergedRevertPullRequests $reverts | + Should -Be @(100, 110) + } +} + +Describe 'branch-scoped merged revert discovery' { + BeforeEach { + $script:discoveryCalls = [System.Collections.Generic.List[object]]::new() + $script:discoveryRowsByBase = @{} + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:LASTEXITCODE = 0 + $searchIndex = [Array]::IndexOf($GhArgs, '--search') + $search = $GhArgs[$searchIndex + 1] + $baseIndex = [Array]::IndexOf($GhArgs, '--base') + $base = $GhArgs[$baseIndex + 1] + $script:discoveryCalls.Add([pscustomobject]@{ + Search = $search + Base = $base + }) + $rows = if ($script:discoveryRowsByBase.ContainsKey($base)) { + @($script:discoveryRowsByBase[$base]) + } else { + @() + } + Write-Output (ConvertTo-Json -InputObject $rows -Depth 5) + } + } + + AfterAll { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + } + + It 'recursively discovers only explicit same-branch reverters of the target set' { + $script:discoveryRowsByBase['main'] = @( + New-LeakPr ` + -Number 200 ` + -Title 'Back out cleanup without Revert in the title' ` + -Body 'Reverts #100' + New-LeakPr ` + -Number 201 ` + -Title 'Unrelated mention' ` + -Body 'Reverts were discussed for #100 but not performed' + New-LeakPr ` + -Number 202 ` + -Title 'Other repository reference' ` + -Body 'Reverts other/repository#100' + New-LeakPr ` + -Number 203 ` + -Title 'Wrong branch reference' ` + -Body 'Reverts #100' ` + -Base 'release/10.0.1xx-sr9' + New-LeakPr ` + -Number 300 ` + -Title 'Restore prior behavior' ` + -Body 'Reverts dotnet/maui#200' + ) + + $result = @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + ) + ) + + $result.number | Should -Be @(200, 300) + $script:discoveryCalls.Count | Should -Be 1 + $script:discoveryCalls[0].Search | Should -Be 'Reverts in:body' + $script:discoveryCalls[0].Base | Should -Be 'main' + } + + It 'fails closed when a branch-scoped snapshot reaches its result ceiling' { + $script:discoveryRowsByBase['main'] = @( + New-LeakPr -Number 200 -Title 'First' -Body 'Reverts #100' + New-LeakPr -Number 201 -Title 'Second' -Body 'Reverts #100' + ) + + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + ) ` + -SearchLimit 2 + } | Should -Throw "*Branch-scoped merged-revert search for 'main'*2-result ceiling*" + } + + It 'keeps more than 30 initial seeds to one bounded branch snapshot query' { + $targets = @(1000..1030 | ForEach-Object { + [pscustomobject]@{ number = $_; baseRefName = 'main' } + }) + + $result = @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests $targets ` + -MaximumSearchQueries 1 + ) + + $result.Count | Should -Be 0 + $script:discoveryCalls.Count | Should -Be 1 + $script:discoveryCalls[0].Search.Length | Should -BeLessOrEqual 256 + } + + It 'keeps more than 100 initial seeds and recursive reverters to one snapshot query' { + $targets = @(1000..1100 | ForEach-Object { + [pscustomobject]@{ number = $_; baseRefName = 'main' } + }) + $script:discoveryRowsByBase['main'] = @( + New-LeakPr -Number 2000 -Title 'Relevant revert' -Body 'Reverts #1000' + New-LeakPr -Number 2001 -Title 'Recursive revert' -Body 'Reverts #2000' + ) + + $result = @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests $targets ` + -MaximumSearchQueries 1 + ) + + $result.number | Should -Be @(2000, 2001) + $script:discoveryCalls.Count | Should -Be 1 + } + + It 'fails closed before searching when distinct branches exceed the query budget' { + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + [pscustomobject]@{ + number = 101 + baseRefName = 'inflight/current' + } + ) ` + -MaximumSearchQueries 1 + } | Should -Throw '*requires 2 branch-scoped searches*1-query safety budget*' + + $script:discoveryCalls.Count | Should -Be 0 + } + + It 'fails closed before searching when the effective query exceeds its length ceiling' { + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ + number = 100 + baseRefName = ('long-branch-' + ('x' * 220)) + } + ) + } | Should -Throw '*exceeds GitHub*s 256-character Search API query ceiling*' + + $script:discoveryCalls.Count | Should -Be 0 + } + + It 'fails closed when recursive discovery exhausts the aggregate traversal budget' { + $script:discoveryRowsByBase['main'] = @( + New-LeakPr -Number 200 -Title 'First revert' -Body 'Reverts #100' + New-LeakPr -Number 300 -Title 'Second revert' -Body 'Reverts #200' + New-LeakPr -Number 400 -Title 'Third revert' -Body 'Reverts #300' + ) + + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + ) ` + -MaximumTraversalPullRequests 3 + } | Should -Throw '*exhausted the 3-PR aggregate traversal safety budget*' + + $script:discoveryCalls.Count | Should -Be 1 + } +} + +Describe 'workflow enforcement boundary' { + It 'fails closed when the early merged-fix search reaches the GitHub Search API ceiling' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/leak-fixer.md') -Raw + $stepStart = $workflow.IndexOf('# (a) Exact [leak-fix] PRs already MERGED') + $stepEnd = $workflow.IndexOf('# Canonicalize every merged PR title', $stepStart) + $step = $workflow.Substring($stepStart, $stepEnd - $stepStart) + + $rawWrite = $step.IndexOf('> /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json') + $ceilingCheck = $step.IndexOf('if test "$MERGED_RAW_COUNT" -ge 1000') + $filteredWrite = $step.IndexOf('> /tmp/gh-aw/agent/merged-leak-fix-prs.json') + + $step | Should -Match '--state merged --limit 1000' + ($rawWrite -ge 0) | Should -BeTrue + ($ceilingCheck -gt $rawWrite) | Should -BeTrue + ($filteredWrite -gt $ceilingCheck) | Should -BeTrue + } + + It 'uses the full Search API window and fails closed for open leak-scan issue de-dup' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw + $stepStart = $workflow.IndexOf("# This workflow's own open [leak-scan] issues") + $stepEnd = $workflow.IndexOf('# Exact [leak-fix] PRs already MERGED', $stepStart) + $step = $workflow.Substring($stepStart, $stepEnd - $stepStart) + + $rawWrite = $step.IndexOf('> /tmp/gh-aw/agent/my-open-leakscan.json') + $ceilingCheck = $step.IndexOf('if test "$OPEN_LEAKSCAN_COUNT" -ge 1000') + $dedupRead = $step.IndexOf("jq -r '.[].title") + + $step | Should -Match '--state open --label agentic-workflows --limit 1000' + ($rawWrite -ge 0) | Should -BeTrue + ($ceilingCheck -gt $rawWrite) | Should -BeTrue + ($dedupRead -gt $ceilingCheck) | Should -BeTrue + } + + It 'keeps source and trusted attempt-cap branch scope in parity' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/leak-fixer.md') -Raw + $gate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $stepStart = $workflow.IndexOf('# (d) Closed-unmerged attempts') + $stepEnd = $workflow.IndexOf("`n" + '```', $stepStart) + $step = $workflow.Substring($stepStart, $stepEnd - $stepStart) + + $step | Should -Match '--json number,title,body,baseRefName,mergedAt' + $gate | Should -Match "'--json', 'number,title,body,baseRefName,mergedAt'" + @($step, $gate) | ForEach-Object { + $_ | Should -Match 'Select-LeakAuthoritativePullRequests' + $_ | Should -Match 'one aggregate budget across both authoritative lanes' + } + } + + It 'keeps trusted merged branch validation in parity across both final gates' { + $fixGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $hunterGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' + ) -Raw + + @($fixGate, $hunterGate) | ForEach-Object { + $selectorIndex = $_.IndexOf('$authoritativeMerged = @(') + $eligibilityIndex = $_.IndexOf( + '$eligibleMerged = @($authoritativeMerged | Where-Object' + ) + + $selectorIndex | Should -BeGreaterOrEqual 0 + $eligibilityIndex | Should -BeGreaterThan $selectorIndex + $_.Substring($selectorIndex, $eligibilityIndex - $selectorIndex) | + Should -Match 'Select-LeakAuthoritativePullRequests' + $_ | Should -Not -Match '\[string\]\$_\.baseRefName\s+-in' + } + } + + It 'wires the final check into safe-output steps rather than prompt-only enforcement' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/leak-fixer.md') -Raw + + $workflow | Should -Match '(?s)safe-outputs:.*steps:.*Assert-LeakFixSafeOutputGate\.ps1' + $workflow | Should -Match 'dedup-state\.json' + $workflow | Should -Match 'github\.event\.repository\.default_branch' + $workflow | Should -Match 'RUNNER_TEMP/leak-fix-safe-output' + $workflow | Should -Not -Match 'run: \.github/scripts/Assert-LeakFixSafeOutputGate\.ps1' + $workflow | Should -Match 'refusing unsupported empty-API de-dup before build/test work' + ([regex]::Matches( + $workflow, + 'select\(\.baseRefName == "main" or \.baseRefName == "inflight/current"\)' + )).Count | Should -BeGreaterOrEqual 3 + } + + It 'wires a trusted final live refresh into the hunter safe-output boundary' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw + $lock = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.lock.yml') -Raw + + $workflow | Should -Match '(?s)safe-outputs:.*steps:.*Assert-LeakHunterSafeOutputGate\.ps1.*create-issue:' + $workflow | Should -Match '(?s)jobs:\s+safe_outputs:\s+permissions:\s+pull-requests: read' + $workflow | Should -Match 'github\.event\.repository\.default_branch' + $workflow | Should -Match 'GITHUB_WORKSPACE.*trusted-leak-hunter' + $workflow | Should -Match 'persist-credentials: false' + $workflow | Should -Not -Match 'run: \.github/scripts/Assert-LeakHunterSafeOutputGate\.ps1' + $workflow | Should -Match "contains\(needs\.agent\.outputs\.output_types, 'create_issue'\)" + $lock | Should -Match '(?ms)^ safe_outputs:.*?^ permissions:.*?^ pull-requests: read$' + } + + It 'documents recursive any-active-reverter semantics' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw + + $workflow | Should -Match 'any active same-branch direct reverter' + $workflow | Should -Match 'independent sibling reverts\s+never cancel each other' + $workflow | Should -Not -Match 'combined by parity|combine by parity' + } + + It 'keeps hunter batch instructions aligned with the canonical-API gate' { + $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw + + $workflow | Should -Match 'at most\s+one output per canonical rooting API in the current batch' + $workflow | Should -Match 'defer the others to a later run' + $workflow | Should -Not -Match 'distinct mechanisms on one API are separate leaks' + } + + It 'uses the shared anchored API parser in every workflow parser path' { + $hunter = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw + $fixer = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/leak-fixer.md') -Raw + + ([regex]::Matches($hunter, 'Get-CanonicalLeakApi\.ps1')).Count | Should -Be 2 + ([regex]::Matches($fixer, 'Get-CanonicalLeakApi\.ps1')).Count | Should -Be 6 + ([regex]::Matches( + $hunter, + 'Get-CanonicalLeakApi\.ps1 -Title "\$TITLE" -ExistingTitle' + )).Count | Should -Be 2 + ([regex]::Matches( + $fixer, + 'Get-CanonicalLeakApi\.ps1 -Title "\$TITLE" -ExistingTitle' + )).Count | Should -Be 6 + $hunter | Should -Not -Match 'awk.*A-Za-z_' + $fixer | Should -Not -Match 'awk.*A-Za-z_' + } + + It 'allows pwsh for fixer agent bash calls' { + $fixer = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/leak-fixer.md') -Raw + + $fixer | Should -Match '(?m)^ bash: \[[^\r\n]*"pwsh"\]$' + } + + It 'defines shared rate-aware query and aggregate traversal budgets for every caller' { + $module = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' + ) -Raw + $wrapper = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Get-RelevantMergedLeakReverts.ps1' + ) -Raw + $fixGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $hunterGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' + ) -Raw + + $module | Should -Match '\$MaximumSearchQueries = 2' + $module | Should -Match '\$MaximumTraversalPullRequests = 2000' + @($wrapper, $fixGate, $hunterGate) | ForEach-Object { + $_ | Should -Match 'Get-RelevantMergedLeakReverts' + $_ | Should -Not -Match 'MaximumSearchQueries' + $_ | Should -Not -Match 'MaximumTraversalPullRequests' + } + } + + It 'uses constant-size branch snapshots with exact local revert verification' { + $module = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' + ) -Raw + $fixGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $hunterGate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' + ) -Raw + $hunter = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md' + ) -Raw + $fixer = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/leak-fixer.md' + ) -Raw + + $module | Should -Match "\`$searchQuery = 'Reverts in:body'" + $module | Should -Match "'--base', \`$base" + $module | Should -Match 'Get-LeakRevertTargets' + $module | Should -Not -Match 'Reverts.*#\$\{targetNumber\}.*in:body' + $fixGate | Should -Match 'Get-RelevantMergedLeakReverts' + $hunterGate | Should -Match 'Get-RelevantMergedLeakReverts' + $hunter | Should -Match 'Get-RelevantMergedLeakReverts\.ps1' + $fixer | Should -Match 'Get-RelevantMergedLeakReverts\.ps1' + @($hunter, $fixer) | ForEach-Object { + $documentation = $_ -replace '\r?\n[ \t]*#[ \t]?', ' ' + $documentation | + Should -Match 'one bounded closed-PR snapshot per authoritative base branch' + $documentation | Should -Match 'constant `Reverts in:body` query' + $documentation | Should -Match '256-character Search API query ceiling' + $documentation | Should -Match '1000-result snapshot ceiling' + $documentation | Should -Match 'bounded transient retries' + $documentation | Should -Match 'capped server-directed rate-limit delays' + $documentation | Should -Match '1000-discovery and 2000-PR aggregate bounds' + $documentation | + Should -Not -Match 'target-scoped quer(?:y|ies)|unique target searches' + } + } + + Context 'safe-output gate script' { + BeforeEach { + $script:agentOutput = Join-Path $TestDrive 'agent_output.json' + $script:stateDirectory = Join-Path $TestDrive 'agent' + New-Item -ItemType Directory -Path $script:stateDirectory -Force | Out-Null + @{ + items = @( + @{ + type = 'create_pull_request' + title = '[leak-fix] Fix GradientBrush.GradientStops reset leak' + body = "Fixes #20`nRefs: dotnet/maui#20" + branch = 'leak-fix/issue-20' + } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + @{ + issue_number = 20 + api = 'GradientBrush.GradientStops' + repository = 'dotnet/maui' + different_mechanism_prs = @() + } | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') + + $global:mockMerged = @() + $global:mockReverts = @() + $global:mockOpen = @() + $global:mockClosed = @() + $global:mockGhExitCode = 0 + $global:mockGhStderr = '' + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:LASTEXITCODE = $global:mockGhExitCode + if (-not [string]::IsNullOrWhiteSpace($global:mockGhStderr)) { + Write-Error $global:mockGhStderr -ErrorAction Continue + } + if ($global:mockGhExitCode -ne 0) { + Write-Output 'mock gh failure' + return + } + $stateIndex = [Array]::IndexOf($GhArgs, '--state') + $state = $GhArgs[$stateIndex + 1] + $searchIndex = [Array]::IndexOf($GhArgs, '--search') + $search = $GhArgs[$searchIndex + 1] + if ($state -eq 'merged') { + if ($search -eq 'Reverts in:body') { + Write-Output (ConvertTo-Json -InputObject @($global:mockReverts) -Depth 5) + } else { + Write-Output (ConvertTo-Json -InputObject @($global:mockMerged) -Depth 5) + } + } elseif ($state -eq 'closed') { + Write-Output (ConvertTo-Json -InputObject @($global:mockClosed) -Depth 5) + } else { + Write-Output (ConvertTo-Json -InputObject @($global:mockOpen) -Depth 5) + } + } + } + + AfterAll { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable mockMerged, mockReverts, mockOpen, mockClosed, mockGhExitCode, mockGhStderr ` + -Scope Global -ErrorAction SilentlyContinue + } + + It 'rejects an untagged create-pull-request title' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = 'Fix GradientBrush.GradientStops reset leak' + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*must start with the literal*prefix*' + } + + It 'accepts a tagged create-pull-request title' { + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'accepts supported punctuation after the canonical API' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-fix] Fix GradientBrush.GradientStops, clear reset subscriptions' + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'rejects a tagged title whose API is not in the expected position' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-fix] Investigate https://github.com/dotnet/maui/issues/20 for GradientBrush.GradientStops' + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*Could not derive a canonical Type.Member*' + } + + It 'rejects the legacy form when an agent emits it as new PR output' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-fix] Fix Shell GradientBrush.GradientStops reset leak' + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*Could not derive a canonical Type.Member*' + } + + It 'accepts an additional exact-repository Refs citation for an API-match PR' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].body = "Fixes #20`nRefs: dotnet/maui#20`nRefs: dotnet/maui#501" + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'fails closed before mutation when live metadata has a direct issue match' { + $global:mockMerged = @( + New-LeakPr ` + -Number 500 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*blocked PR creation*direct issue-reference match*' + } + + It 'fails closed when the final GitHub fetch fails' { + $global:mockGhExitCode = 1 + $global:mockGhStderr = "auth warning`n$([char]27)[31mred" + + $message = try { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + throw 'Expected the gh failure to stop the gate.' + } catch { + $_.Exception.Message + } + + $message | Should -Match 'failed with exit code 1' + $message | Should -Match 'Output: auth warning' + $message | Should -Not -Match "[`r`n$([char]27)]" + } + + It 'parses successful JSON without mixing benign gh stderr into stdout' { + $global:mockGhStderr = 'benign gh warning' + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'rejects an agent-authored different-mechanism state override' { + @{ + issue_number = 20 + api = 'GradientBrush.GradientStops' + repository = 'dotnet/maui' + different_mechanism_prs = @( + @{ + number = 501 + basis = 'Agent-authored mechanism claim' + } + ) + } | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*do not accept agent-authored different-mechanism overrides*' + } + + It 'blocks a live same-API match despite an agent-authored body disclosure' { + $global:mockMerged = @( + New-LeakPr ` + -Number 501 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` + -Body 'Fixes #10' + ) + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].body = @" +Fixes #20 +Refs: dotnet/maui#20 + +## Same-API comparisons +Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim +"@ + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*blocked PR creation*same-API match: 501*' + } + + It 'aggregates main and inflight/current attempts while excluding release lanes' { + $global:mockClosed = @( + New-LeakPr -Number 601 -Title '[leak-fix] Fix Other.Api leak' ` + -Body 'Fixes #20' -Merged $false + New-LeakPr -Number 602 -Title '[leak-fix] Fix GradientBrush.GradientStops leak' ` + -Body 'Fixes #10' -Base 'inflight/current' -Merged $false + New-LeakPr -Number 603 -Title '[leak-fix] Fix Other.Api leak again' ` + -Body 'Refs: dotnet/maui#20' -Merged $false + New-LeakPr -Number 604 -Title '[leak-fix] Fix GradientBrush.GradientStops release leak' ` + -Body 'Fixes #20' -Base 'release/10.0.1xx-sr9' -Merged $false + ) + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*attempt-cap gate blocked PR creation: 3 closed-unmerged attempts*' + } + + It 'fails closed when a closed attempt is missing baseRefName' { + $global:mockClosed = @( + [pscustomobject]@{ + number = 605 + title = '[leak-fix] Fix GradientBrush.GradientStops leak' + body = 'Fixes #20' + mergedAt = $null + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*missing baseRefName*' + } + + It 'fails closed when a closed attempt has malformed baseRefName' { + $global:mockClosed = @( + New-LeakPr ` + -Number 606 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops leak' ` + -Body 'Fixes #20' ` + -Base ' main ' ` + -Merged $false + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*malformed baseRefName*' + } + + It 'allows a release-only open PR even when it directly references the issue' { + $global:mockOpen = @( + New-LeakPr ` + -Number 502 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" ` + -Base 'release/10.0.1xx-sr9' ` + -Merged $false + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'allows a re-file after the matching merged fix was effectively reverted' { + $global:mockMerged = @( + New-LeakPr ` + -Number 503 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + ) + $global:mockReverts = @( + New-LeakPr ` + -Number 504 ` + -Title 'Back out the collection cleanup' ` + -Body 'Reverts dotnet/maui#503' + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + } + + Context 'hunter safe-output gate script' { + BeforeEach { + $script:hunterAgentOutput = Join-Path $TestDrive 'hunter_agent_output.json' + @{ + items = @( + @{ + type = 'create_issue' + title = '[leak-scan] GradientBrush.GradientStops — reset leak' + body = 'AI-generated leak report' + } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:hunterAgentOutput + + $global:mockHunterOpenIssues = @() + $global:mockHunterMerged = @() + $global:mockHunterReverts = @() + $global:mockHunterGhExitCode = 0 + $global:mockHunterGhStderr = '' + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:LASTEXITCODE = $global:mockHunterGhExitCode + if (-not [string]::IsNullOrWhiteSpace($global:mockHunterGhStderr)) { + Write-Error $global:mockHunterGhStderr -ErrorAction Continue + } + if ($global:mockHunterGhExitCode -ne 0) { + Write-Output 'mock gh failure' + return + } + if ($GhArgs[0] -eq 'issue') { + Write-Output (ConvertTo-Json -InputObject @($global:mockHunterOpenIssues) -Depth 5) + return + } + $searchIndex = [Array]::IndexOf($GhArgs, '--search') + $search = $GhArgs[$searchIndex + 1] + if ($search -eq 'Reverts in:body') { + Write-Output (ConvertTo-Json -InputObject @($global:mockHunterReverts) -Depth 5) + } else { + Write-Output (ConvertTo-Json -InputObject @($global:mockHunterMerged) -Depth 5) + } + } + } + + AfterAll { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable mockHunterOpenIssues, mockHunterMerged, mockHunterReverts, ` + mockHunterGhExitCode, mockHunterGhStderr ` + -Scope Global -ErrorAction SilentlyContinue + } + + It 'accepts issue emission when the final live refresh has no match' { + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'rejects a malformed issue title instead of deriving a later API token' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-scan] Investigate Microsoft.Maui.Controls before GradientBrush.GradientStops' + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*Could not derive a canonical Type.Member*' + } + + It 'rejects the legacy form when an agent emits it as new output' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-scan] Shell BackButtonBehavior.Command — reset leak' + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*Could not derive a canonical Type.Member*' + } + + It 'rejects differently titled issues for the same canonical API in one output batch' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items += [pscustomobject]@{ + type = 'create_issue' + title = '[leak-scan] GradientBrush.GradientStops — detach teardown leak' + body = 'Second AI-generated leak report' + } + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*same canonical API 'GradientBrush.GradientStops'*" + } + + It 'keeps casing-only C# APIs distinct while the exact-casing batch contract still dedups' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items += [pscustomobject]@{ + type = 'create_issue' + title = '[leak-scan] GradientBrush.gradientStops — distinct C# API casing' + body = 'Second AI-generated leak report' + } + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'blocks issue emission when a matching fix merged after the pre-agent snapshot' { + $global:mockHunterMerged = @( + New-LeakPr ` + -Number 701 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*701*" + } + + It 'fails closed before mutation when merged metadata is missing baseRefName' { + $global:mockHunterMerged = @( + [pscustomobject]@{ + number = 704 + title = '[leak-fix] Fix GradientBrush.GradientStops reset leak' + body = 'Fixes #20' + mergedAt = '2026-08-10T00:00:00Z' + url = 'https://github.com/dotnet/maui/pull/704' + } + ) + $before = Get-Content -LiteralPath $script:hunterAgentOutput -Raw + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*Final merged leak-fix de-dup search PR #704 is missing baseRefName*' + + Get-Content -LiteralPath $script:hunterAgentOutput -Raw | + Should -BeExactly $before + } + + It 'fails closed before mutation when merged metadata has malformed baseRefName' { + $global:mockHunterMerged = @( + New-LeakPr ` + -Number 705 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Base ' main ' + ) + $before = Get-Content -LiteralPath $script:hunterAgentOutput -Raw + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*Final merged leak-fix de-dup search PR #705 has malformed baseRefName*' + + Get-Content -LiteralPath $script:hunterAgentOutput -Raw | + Should -BeExactly $before + } + + It 'continues to exclude release-only merged fixes from hunter de-dup' { + $global:mockHunterMerged = @( + New-LeakPr ` + -Number 706 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Base 'release/10.0.1xx-sr9' + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'blocks agent-authored different-mechanism evidence for a same-API fix' { + $global:mockHunterMerged = @( + New-LeakPr ` + -Number 701 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' + ) + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items[0].body = @" +AI-generated leak report + +## Same-API comparisons +Same-API comparison: dotnet/maui#701 | Different mechanism: Agent-authored claim +"@ + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*701*" + } + + It 'blocks a same-API open issue without accepting an override' { + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 702 + title = '[leak-scan] GradientBrush.GradientStops — teardown leak' + body = 'Existing scanner issue' + url = 'https://github.com/dotnet/maui/issues/702' + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*702*" + } + + It 'blocks a legacy Shell-prefixed same-API open issue' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-scan] BackButtonBehavior.Command — reset leak' + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 36345 + title = '[leak-scan] Shell BackButtonBehavior.Command leaks via strong ICommand' + body = 'Existing legacy scanner issue' + url = 'https://github.com/dotnet/maui/issues/36345' + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*blocked issue creation for 'BackButtonBehavior.Command'*36345*" + } + + It 'rejects a mixed batch atomically when one item becomes stale' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json + $output.items += [pscustomobject]@{ + type = 'create_issue' + title = '[leak-scan] Button.Clicked — event subscription leak' + body = 'Second AI-generated leak report' + } + $output | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $script:hunterAgentOutput + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 703 + title = '[leak-scan] Button.Clicked — existing event subscription leak' + body = 'Existing scanner issue' + url = 'https://github.com/dotnet/maui/issues/703' + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw "*blocked issue creation for 'Button.Clicked'*rejected atomically*" + + $unchanged = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | + ConvertFrom-Json + @($unchanged.items).Count | Should -Be 2 + @($unchanged.items.title) | Should -Contain ( + '[leak-scan] GradientBrush.GradientStops — reset leak' + ) + @($unchanged.items.title) | Should -Contain ( + '[leak-scan] Button.Clicked — event subscription leak' + ) + } + + It 'parses successful hunter JSON without mixing benign gh stderr into stdout' { + $global:mockHunterGhStderr = 'benign gh warning' + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + + It 'fails closed when a hunter gh query fails' { + $global:mockHunterGhExitCode = 1 + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*failed with exit code 1*' + } + } +} diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 new file mode 100644 index 000000000000..70e1bfb01d88 --- /dev/null +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -0,0 +1,738 @@ +function ConvertTo-CanonicalLeakApi { + param([Parameter(Mandatory = $true)][string]$Api) + + $segments = $Api.Split('.') + if ($segments.Count -eq 2 -or + $Api.StartsWith('Microsoft.Maui.', [StringComparison]::Ordinal)) { + return "$($segments[-2]).$($segments[-1])" + } + return $Api +} + +function Get-CanonicalLeakApi { + param([AllowEmptyString()][string]$Title) + + if ([string]::IsNullOrWhiteSpace($Title)) { + return $null + } + + $normalized = ($Title -replace "[`r`n]+", ' ').Trim() + $identifierChain = '[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+' + $apiBoundary = '(?=[ \t,:()\-\u2013\u2014]|$|\.(?=[ \t]|$))' + $match = if ($normalized.StartsWith('[leak-scan] ', [StringComparison]::Ordinal)) { + [regex]::Match($normalized, "^\[leak-scan\][ `t]+(?$identifierChain)$apiBoundary") + } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + [regex]::Match($normalized, "^\[leak-fix\][ `t]+Fix[ `t]+(?$identifierChain)$apiBoundary") + } else { + return $null + } + if (-not $match.Success) { + return $null + } + + return ConvertTo-CanonicalLeakApi -Api $match.Groups['api'].Value +} + +function Get-CanonicalExistingLeakApi { + param([AllowEmptyString()][string]$Title) + + $api = Get-CanonicalLeakApi -Title $Title + if (-not [string]::IsNullOrWhiteSpace($api)) { + return $api + } + if ([string]::IsNullOrWhiteSpace($Title)) { + return $null + } + + # The original hunter emitted this exact Shell context token before a short API. + # Keep compatibility anchored to that known form rather than scanning later tokens. + $normalized = ($Title -replace "[`r`n]+", ' ').Trim() + $shortApi = '[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*' + $apiBoundary = '(?=[ \t,:()\-\u2013\u2014]|$|\.(?=[ \t]|$))' + $match = if ($normalized.StartsWith('[leak-scan] ', [StringComparison]::Ordinal)) { + [regex]::Match( + $normalized, + "^\[leak-scan\][ `t]+Shell[ `t]+(?$shortApi)$apiBoundary" + ) + } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + [regex]::Match( + $normalized, + "^\[leak-fix\][ `t]+Fix[ `t]+Shell[ `t]+(?$shortApi)$apiBoundary" + ) + } else { + return $null + } + if (-not $match.Success) { + return $null + } + + return ConvertTo-CanonicalLeakApi -Api $match.Groups['api'].Value +} + +function Read-RegularJsonFile { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Required JSON file is missing: $Path" + } + $item = Get-Item -LiteralPath $Path -Force + if ($item.LinkType) { + throw "Refusing symbolic-link JSON file: $Path" + } + $raw = Get-Content -LiteralPath $Path -Raw + if ([string]::IsNullOrWhiteSpace($raw) -or $raw.Length -gt 1MB) { + throw "JSON file is empty or too large: $Path" + } + try { + return $raw | ConvertFrom-Json + } catch { + throw "Invalid JSON in '$Path': $($_.Exception.Message)" + } +} + +function Get-NormalizedLeakBaseRefName { + param( + [Parameter(Mandatory = $true)][AllowNull()][object]$PullRequest, + [Parameter(Mandatory = $true)][string]$Context + ) + + if ($null -eq $PullRequest) { + throw "$Context contains a null PR record with missing baseRefName." + } + + $number = [string]$PullRequest.number + $record = if ([string]::IsNullOrWhiteSpace($number)) { + 'PR record' + } else { + "PR #$number" + } + $baseProperty = $PullRequest.PSObject.Properties['baseRefName'] + if ($null -eq $baseProperty) { + throw "$Context $record is missing baseRefName." + } + + $rawBase = $baseProperty.Value + if ($rawBase -isnot [string]) { + throw "$Context $record has malformed baseRefName: expected a string." + } + + $base = $rawBase.Normalize([System.Text.NormalizationForm]::FormC) + $components = @($base.Split('/')) + $hasInvalidComponent = @($components | Where-Object { + $_.StartsWith('.', [StringComparison]::Ordinal) -or + $_.EndsWith('.lock', [StringComparison]::Ordinal) + }).Count -gt 0 + $hasInvalidSyntax = + [string]::IsNullOrWhiteSpace($base) -or + $base -match '[\x00-\x20\x7f~^:?*\[\\]' -or + $base.Contains('..', [StringComparison]::Ordinal) -or + $base.Contains('@{', [StringComparison]::Ordinal) -or + $base.StartsWith('/', [StringComparison]::Ordinal) -or + $base.EndsWith('/', [StringComparison]::Ordinal) -or + $base.Contains('//', [StringComparison]::Ordinal) -or + $base.EndsWith('.', [StringComparison]::Ordinal) -or + $base -ceq '@' -or + $hasInvalidComponent + if ($hasInvalidSyntax) { + throw "$Context $record has malformed baseRefName." + } + + return $base +} + +function Select-LeakAuthoritativePullRequests { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$PullRequests, + [Parameter(Mandatory = $true)][string]$Context + ) + + foreach ($pullRequest in $PullRequests) { + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest $pullRequest ` + -Context $Context + if ($base -ceq 'main' -or $base -ceq 'inflight/current') { + Write-Output $pullRequest + } + } +} + +function Test-LeakPrReferencesIssue { + param( + [AllowEmptyString()][string]$Body, + [Parameter(Mandatory = $true)][int]$IssueNumber, + [Parameter(Mandatory = $true)][string]$Repository + ) + + $text = $Body ?? '' + $repo = [regex]::Escape($Repository) + return $text -match "(?m)^[ `t]*Fixes #$IssueNumber\b" -or + $text -match "(?m)^[ `t]*Refs:[ `t]*$repo#$IssueNumber\b" +} + +function Get-LeakRevertTargets { + param( + [AllowEmptyString()][string]$Body, + [Parameter(Mandatory = $true)][string]$Repository + ) + + $repo = [regex]::Escape($Repository) + $markdownPrefix = '(?:>[ \t]*)?(?:[-+*][ \t]+)?(?:\*{1,2}|_{1,2})?' + $pattern = "(?m)^[ `t]*$markdownPrefix" + + "Reverts[ `t]+(?:$repo#|#)(?[1-9][0-9]*)\b" + return @([regex]::Matches(($Body ?? ''), $pattern) | + ForEach-Object { [int]$_.Groups['number'].Value } | + Sort-Object -Unique) +} + +function Test-IsTransientLeakGhFailure { + param([AllowEmptyString()][string]$Detail) + + return [bool]($Detail -match '(?i)(?:\b(?:primary |secondary )?rate limit\b|\bretry-after\b\s*[:=]|\b(?:x[-_ ]?rate[-_ ]?limit[-_ ]?reset|rate[-_ ]?limit[-_ ]?reset)\b\s*[:=]|\bHTTP(?:/[0-9.]+)?[ :]+(?:429|502|503|504)\b|\b(?:Bad Gateway|Service Unavailable|Gateway Timeout)\b|\b(?:i/o |TLS handshake )?timeout\b|\btimed out\b|\bconnection reset(?: by peer)?\b|\bunexpected EOF\b)') +} + +function Get-LeakServerRetryDelaySeconds { + param( + [AllowEmptyString()][string]$Detail, + [Parameter(Mandatory = $true)][DateTimeOffset]$UtcNow, + [ValidateRange(0, 300)][int]$MaximumDelaySeconds + ) + + if ([string]::IsNullOrWhiteSpace($Detail)) { + return $null + } + + $delays = [System.Collections.Generic.List[double]]::new() + $numberStyles = [Globalization.NumberStyles]::AllowLeadingSign + $invariantCulture = [Globalization.CultureInfo]::InvariantCulture + $retryAfterNumberPattern = + '(?i)\bretry-after\b\s*[:=]\s*["'']?(?[+-]?[0-9]{1,20})["'']?(?=$|[\s,;}])' + foreach ($match in [regex]::Matches($Detail, $retryAfterNumberPattern)) { + [long]$seconds = 0 + if ([long]::TryParse( + $match.Groups['value'].Value, + $numberStyles, + $invariantCulture, + [ref]$seconds + ) -and $seconds -ge 0) { + $delays.Add([double]$seconds) + } + } + + $retryAfterDatePattern = + '(?i)\bretry-after\b\s*[:=]\s*["'']?(?[a-z]{3},\s+[0-9]{2}\s+[a-z]{3}\s+[0-9]{4}\s+[0-9]{2}:[0-9]{2}:[0-9]{2}\s+gmt)["'']?(?=$|[\s,;}])' + $dateStyles = [Globalization.DateTimeStyles]::AllowWhiteSpaces -bor + [Globalization.DateTimeStyles]::AssumeUniversal -bor + [Globalization.DateTimeStyles]::AdjustToUniversal + foreach ($match in [regex]::Matches($Detail, $retryAfterDatePattern)) { + $retryAt = [DateTimeOffset]::MinValue + if ([DateTimeOffset]::TryParseExact( + $match.Groups['value'].Value, + 'r', + $invariantCulture, + $dateStyles, + [ref]$retryAt + )) { + $delays.Add([Math]::Max( + [double]0, + ($retryAt - $UtcNow.ToUniversalTime()).TotalSeconds + )) + } + } + + $rateLimitResetPattern = + '(?i)\b(?:x[-_ ]?rate[-_ ]?limit[-_ ]?reset|rate[-_ ]?limit[-_ ]?reset)\b\s*[:=]\s*["'']?(?[+-]?[0-9]{1,20})["'']?(?=$|[\s,;}])' + foreach ($match in [regex]::Matches($Detail, $rateLimitResetPattern)) { + [long]$resetEpochSeconds = 0 + if (-not [long]::TryParse( + $match.Groups['value'].Value, + $numberStyles, + $invariantCulture, + [ref]$resetEpochSeconds + ) -or $resetEpochSeconds -lt 0) { + continue + } + + try { + $resetAt = [DateTimeOffset]::FromUnixTimeSeconds($resetEpochSeconds) + } catch { + continue + } + $delays.Add([Math]::Max( + [double]0, + ($resetAt - $UtcNow.ToUniversalTime()).TotalSeconds + )) + } + + if ($delays.Count -eq 0) { + return $null + } + + $serverDelaySeconds = ($delays | Measure-Object -Maximum).Maximum + return [int][Math]::Min( + [double]$MaximumDelaySeconds, + [Math]::Ceiling([Math]::Max([double]0, [double]$serverDelaySeconds)) + ) +} + +function Invoke-LeakGhJson { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [ValidateRange(1, 5)][int]$MaximumAttempts = 3, + [ValidateRange(0, 60)][int]$RetryBaseDelaySeconds = 2, + [ValidateRange(0, 300)][int]$MaximumServerDelaySeconds = 120, + [ValidateNotNull()][scriptblock]$DelayAction = { + param([int]$Seconds) + Start-Sleep -Seconds $Seconds + }, + [ValidateNotNull()][scriptblock]$UtcNowProvider = { + [DateTimeOffset]::UtcNow + } + ) + + # Preserve structured exit-code handling if a future host flips the native-command default. + $PSNativeCommandUseErrorActionPreference = $false + for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { + $output = & gh @Arguments 2>&1 + $exitCode = $LASTEXITCODE + + $stdout = (@($output | Where-Object { + $_ -isnot [System.Management.Automation.ErrorRecord] + }) | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + $stderr = (@($output | Where-Object { + $_ -is [System.Management.Automation.ErrorRecord] + }) | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + + if ($exitCode -eq 0) { + if ([string]::IsNullOrWhiteSpace($stdout)) { + throw "'gh $($Arguments -join ' ')' returned an empty response." + } + try { + return $stdout | ConvertFrom-Json + } catch { + throw "'gh $($Arguments -join ' ')' returned invalid JSON: $($_.Exception.Message)" + } + } + + $detail = (@($stderr, $stdout) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' ' + $detail = ($detail -replace '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '?' ` + -replace '[\r\n]+', ' ').Trim() + if ($detail.Length -gt 2000) { + $detail = "$($detail.Substring(0, 2000))..." + } + $message = "'gh $($Arguments -join ' ')' failed with exit code $exitCode after $attempt attempt(s)." + if (-not [string]::IsNullOrWhiteSpace($detail)) { + $message = "$message Output: $detail" + } + + if ((Test-IsTransientLeakGhFailure -Detail $detail) -and + $attempt -lt $MaximumAttempts) { + try { + $nowValues = @(& $UtcNowProvider) + if ($nowValues.Count -ne 1 -or $null -eq $nowValues[0]) { + throw 'The retry clock must return exactly one non-null value.' + } + $utcNow = [DateTimeOffset]$nowValues[0] + } catch { + throw "$message Unable to read the retry clock: $($_.Exception.Message)" + } + $serverDelaySeconds = Get-LeakServerRetryDelaySeconds ` + -Detail $detail ` + -UtcNow $utcNow ` + -MaximumDelaySeconds $MaximumServerDelaySeconds + $delaySource = 'server rate-limit metadata' + if ($null -eq $serverDelaySeconds) { + $delaySource = 'exponential fallback' + $delaySeconds = [int]( + $RetryBaseDelaySeconds * [Math]::Pow(2, $attempt - 1) + ) + } else { + $delaySeconds = [int]$serverDelaySeconds + } + Write-Warning "$message Retrying in $delaySeconds second(s) using $delaySource." + if ($delaySeconds -gt 0) { + & $DelayAction $delaySeconds + } + continue + } + + throw $message + } + + throw "'gh $($Arguments -join ' ')' exhausted its retry budget unexpectedly." +} + +function Get-RelevantMergedLeakReverts { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$TargetPullRequests, + [ValidateRange(1, 1000)][int]$SearchLimit = 1000, + [ValidateRange(1, 1000)][int]$MaximumDiscoveredPullRequests = 1000, + [ValidateRange(1, 2)][int]$MaximumSearchQueries = 2, + [ValidateRange(1, 2000)][int]$MaximumTraversalPullRequests = 2000 + ) + + $queue = [System.Collections.Generic.Queue[object]]::new() + $branches = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + $branchByNumber = @{} + foreach ($target in $TargetPullRequests) { + $number = 0 + if (-not [int]::TryParse([string]$target.number, [ref]$number) -or $number -le 0) { + throw "Invalid revert-discovery target PR number '$($target.number)'." + } + $base = [string]$target.baseRefName + if ([string]::IsNullOrWhiteSpace($base)) { + throw "Revert-discovery target PR #$number is missing baseRefName." + } + if ($branchByNumber.ContainsKey($number)) { + if ([string]$branchByNumber[$number] -cne $base) { + throw "Revert-discovery target PR #$number has conflicting base branches." + } + continue + } + if ($branchByNumber.Count -ge $MaximumTraversalPullRequests) { + throw "Relevant merged-revert discovery exhausted the $MaximumTraversalPullRequests-PR aggregate traversal safety budget while loading seeds." + } + $branchByNumber[$number] = $base + [void]$branches.Add($base) + $queue.Enqueue([pscustomobject]@{ + number = $number + baseRefName = $base + }) + } + + if ($branches.Count -gt $MaximumSearchQueries) { + throw "Relevant merged-revert discovery requires $($branches.Count) branch-scoped searches, exceeding the $MaximumSearchQueries-query safety budget." + } + + # One constant-size snapshot per eligible base branch keeps request count independent + # of seed count. At 100 results/page, two 1000-result snapshots stay below the normal + # 30 authenticated Search API requests/minute limit and fail closed at either ceiling. + $searchQuery = 'Reverts in:body' + $maximumSearchQueryLength = 256 + $revertersByTarget = @{} + foreach ($base in @($branches | Sort-Object)) { + $effectiveQuery = "repo:$Repository is:pr is:merged base:$base $searchQuery" + if ($effectiveQuery.Length -gt $maximumSearchQueryLength) { + throw "Branch-scoped merged-revert query for '$base' exceeds GitHub's $maximumSearchQueryLength-character Search API query ceiling." + } + + $rows = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--base', $base, + '--limit', [string]$SearchLimit, + '--search', $searchQuery, + '--json', 'number,title,body,baseRefName,mergedAt' + ) + ) + if ($rows.Count -ge $SearchLimit) { + throw "Branch-scoped merged-revert search for '$base' returned $($rows.Count) rows at its $SearchLimit-result ceiling." + } + + foreach ($row in $rows) { + if ($null -eq $row.mergedAt -or + [string]$row.baseRefName -cne $base) { + continue + } + + $reverter = 0 + if (-not [int]::TryParse([string]$row.number, [ref]$reverter) -or + $reverter -le 0) { + throw "Invalid merged-revert search result PR number '$($row.number)'." + } + foreach ($targetNumber in @( + Get-LeakRevertTargets ` + -Body ([string]$row.body) ` + -Repository $Repository + )) { + if (-not $revertersByTarget.ContainsKey($targetNumber)) { + $revertersByTarget[$targetNumber] = + [System.Collections.Generic.List[object]]::new() + } + $revertersByTarget[$targetNumber].Add($row) + } + } + } + + $traversed = [System.Collections.Generic.HashSet[int]]::new() + $discovered = @{} + while ($queue.Count -gt 0) { + $target = $queue.Dequeue() + $targetNumber = [int]$target.number + if (-not $traversed.Add($targetNumber) -or + -not $revertersByTarget.ContainsKey($targetNumber)) { + continue + } + foreach ($row in $revertersByTarget[$targetNumber]) { + if ([string]$row.baseRefName -cne [string]$target.baseRefName) { + continue + } + + $reverter = [int]$row.number + if (-not $discovered.ContainsKey($reverter)) { + if ($discovered.Count -ge $MaximumDiscoveredPullRequests) { + throw "Relevant merged-revert discovery exceeded the $MaximumDiscoveredPullRequests-PR safety bound." + } + if (-not $branchByNumber.ContainsKey($reverter)) { + if ($branchByNumber.Count -ge $MaximumTraversalPullRequests) { + throw "Relevant merged-revert discovery exhausted the $MaximumTraversalPullRequests-PR aggregate traversal safety budget." + } + $branchByNumber[$reverter] = [string]$row.baseRefName + } elseif ([string]$branchByNumber[$reverter] -cne + [string]$row.baseRefName) { + throw "Discovered merged-revert PR #$reverter has conflicting base branches." + } + $discovered[$reverter] = $row + $queue.Enqueue([pscustomobject]@{ + number = $reverter + baseRefName = [string]$row.baseRefName + }) + } + } + } + + return @($discovered.Values | Sort-Object number) +} + +function Assert-LeakDedupState { + param( + [Parameter(Mandatory = $true)]$State, + [Parameter(Mandatory = $true)][int]$IssueNumber, + [Parameter(Mandatory = $true)][string]$Api, + [Parameter(Mandatory = $true)][string]$Repository + ) + + if ($State.issue_number -ne $IssueNumber) { + throw "De-dup state issue_number '$($State.issue_number)' does not match PR issue #$IssueNumber." + } + if ($State.api -cne $Api) { + throw "De-dup state API '$($State.api)' does not match PR API '$Api'." + } + if ($State.repository -cne $Repository) { + throw "De-dup state repository '$($State.repository)' does not match '$Repository'." + } + if ('different_mechanism_prs' -notin $State.PSObject.Properties.Name -or + $null -eq $State.different_mechanism_prs) { + throw "De-dup state is missing required array 'different_mechanism_prs'." + } + + if (@($State.different_mechanism_prs).Count -ne 0) { + throw 'Trusted de-dup gates do not accept agent-authored different-mechanism overrides.' + } +} + +function Get-LeakFixFinalDedupResult { + param( + [Parameter(Mandatory = $true)][int]$IssueNumber, + [Parameter(Mandatory = $true)][string]$Api, + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedPullRequests, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$OpenPullRequests, + [AllowEmptyCollection()][object[]]$MergedRevertPullRequests = @() + ) + + $authoritativeMerged = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests $MergedPullRequests ` + -Context 'Merged leak-fix de-dup search' + ) + $eligibleMerged = @($authoritativeMerged | Where-Object { + $null -ne $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) + }) + $effectivelyReverted = @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository $Repository ` + -FixPullRequests $eligibleMerged ` + -MergedRevertPullRequests $MergedRevertPullRequests + ) + $reverted = [System.Collections.Generic.HashSet[int]]::new() + foreach ($number in $effectivelyReverted) { + [void]$reverted.Add($number) + } + $eligibleMerged = @($eligibleMerged | Where-Object { + -not $reverted.Contains([int]$_.number) + }) + $authoritativeOpen = @( + Select-LeakAuthoritativePullRequests ` + -PullRequests $OpenPullRequests ` + -Context 'Open leak-fix de-dup search' + ) + $eligibleOpen = @($authoritativeOpen | Where-Object { + ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) + }) + $eligible = @($eligibleMerged + $eligibleOpen) + + $directMatches = @($eligible | Where-Object { + Test-LeakPrReferencesIssue ` + -Body ([string]$_.body) ` + -IssueNumber $IssueNumber ` + -Repository $Repository + } | Sort-Object number -Unique) + + $apiMatches = @($eligible | Where-Object { + (Get-CanonicalExistingLeakApi -Title ([string]$_.title)) -ceq $Api + } | Sort-Object number -Unique) + + $blocked = $directMatches.Count -gt 0 -or $apiMatches.Count -gt 0 + $reason = if ($directMatches.Count -gt 0) { + "direct issue-reference match: $($directMatches.number -join ', ')" + } elseif ($apiMatches.Count -gt 0) { + "same-API match: $($apiMatches.number -join ', ')" + } else { + 'no live direct-reference or same-API duplicate matches' + } + + return [pscustomobject]@{ + Blocked = $blocked + Reason = $reason + DirectMatches = $directMatches + ApiMatches = $apiMatches + EffectivelyReverted = $effectivelyReverted + } +} + +function Get-EffectiveRevertedPullRequestNumbers { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$FixPullRequests, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedRevertPullRequests + ) + + $revertersByTarget = @{} + $branchByNumber = @{} + $fixNumbers = [System.Collections.Generic.List[int]]::new() + foreach ($fix in $FixPullRequests) { + $number = 0 + if (-not [int]::TryParse([string]$fix.number, [ref]$number) -or $number -le 0) { + throw "Invalid merged-fix PR number '$($fix.number)'." + } + $base = [string]$fix.baseRefName + if ([string]::IsNullOrWhiteSpace($base)) { + throw "Merged-fix PR #$number is missing baseRefName." + } + if ($branchByNumber.ContainsKey($number) -and + $branchByNumber[$number] -cne $base) { + throw "PR #$number has conflicting base branches." + } + $branchByNumber[$number] = $base + $fixNumbers.Add($number) + } + + foreach ($revert in $MergedRevertPullRequests) { + $reverter = [int]$revert.number + if ($reverter -le 0) { + throw "Invalid merged-revert PR number '$($revert.number)'." + } + $base = [string]$revert.baseRefName + if ([string]::IsNullOrWhiteSpace($base)) { + throw "Merged-revert PR #$reverter is missing baseRefName." + } + if ($branchByNumber.ContainsKey($reverter) -and + $branchByNumber[$reverter] -cne $base) { + throw "PR #$reverter has conflicting base branches." + } + $branchByNumber[$reverter] = $base + } + + foreach ($revert in $MergedRevertPullRequests) { + $reverter = [int]$revert.number + $reverterBase = [string]$revert.baseRefName + foreach ($target in @( + Get-LeakRevertTargets ` + -Body ([string]$revert.body) ` + -Repository $Repository + )) { + if (-not $branchByNumber.ContainsKey($target) -or + $branchByNumber[$target] -cne $reverterBase) { + continue + } + if (-not $revertersByTarget.ContainsKey($target)) { + $revertersByTarget[$target] = [System.Collections.Generic.List[int]]::new() + } + if (-not $revertersByTarget[$target].Contains($reverter)) { + $revertersByTarget[$target].Add($reverter) + } + } + } + + $memo = @{} + $activeState = 'active' + $inactiveState = 'inactive' + $ambiguousState = 'ambiguous' + + function Get-EffectState { + param( + [int]$PullRequestNumber, + [System.Collections.Generic.HashSet[int]]$Visiting + ) + + if ($memo.ContainsKey($PullRequestNumber)) { + return [string]$memo[$PullRequestNumber] + } + if (-not $Visiting.Add($PullRequestNumber)) { + $memo[$PullRequestNumber] = $ambiguousState + return $ambiguousState + } + + try { + $hasAmbiguousReverter = $false + if ($revertersByTarget.ContainsKey($PullRequestNumber)) { + foreach ($reverter in $revertersByTarget[$PullRequestNumber]) { + $reverterState = Get-EffectState ` + -PullRequestNumber $reverter ` + -Visiting $Visiting + if ($reverterState -eq $activeState) { + $memo[$PullRequestNumber] = $inactiveState + return $inactiveState + } + if ($reverterState -eq $ambiguousState) { + $hasAmbiguousReverter = $true + } + } + } + + if ($hasAmbiguousReverter) { + $memo[$PullRequestNumber] = $ambiguousState + return $ambiguousState + } + $memo[$PullRequestNumber] = $activeState + return $activeState + } finally { + [void]$Visiting.Remove($PullRequestNumber) + } + } + + $effectivelyReverted = [System.Collections.Generic.List[int]]::new() + foreach ($number in $fixNumbers) { + $visiting = [System.Collections.Generic.HashSet[int]]::new() + $state = Get-EffectState ` + -PullRequestNumber $number ` + -Visiting $visiting + if ($state -eq $inactiveState) { + $effectivelyReverted.Add($number) + } + } + + return @($effectivelyReverted | Sort-Object -Unique) +} + +Export-ModuleMember -Function ` + Get-CanonicalLeakApi, ` + Get-CanonicalExistingLeakApi, ` + Read-RegularJsonFile, ` + Select-LeakAuthoritativePullRequests, ` + Test-LeakPrReferencesIssue, ` + Get-LeakRevertTargets, ` + Invoke-LeakGhJson, ` + Get-RelevantMergedLeakReverts, ` + Assert-LeakDedupState, ` + Get-LeakFixFinalDedupResult, ` + Get-EffectiveRevertedPullRequestNumbers diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 5ae2070ac203..137287acec4b 100644 --- a/.github/workflows/daily-leak-hunter.lock.yml +++ b/.github/workflows/daily-leak-hunter.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ff33bd5426196fc8d890aa23bcb9d8b0b614e4ec10b38cf74e0f4c11a32cdf08","body_hash":"08949d49bcfa73fef79f689c1a6bb83adca31f195979da27f885f1421dd93884","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-sol","engine_versions":{"copilot":"1.0.78"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"830d4229e578c2e9da1d470756f085ddc29f60108590ecf69644ba7d8bb5f3f7","body_hash":"724613e17974fbba3159ccd80d6489bbb9995ef6a932b8120813eb92818e7984","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-sol","engine_versions":{"copilot":"1.0.78"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_PAT_0","COPILOT_PAT_1","COPILOT_PAT_2","COPILOT_PAT_3","COPILOT_PAT_4","COPILOT_PAT_5","COPILOT_PAT_6","COPILOT_PAT_7","COPILOT_PAT_8","COPILOT_PAT_9","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -375,6 +375,7 @@ jobs: permissions: contents: read issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" queue: max @@ -500,6 +501,13 @@ jobs: env: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" + - env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + name: Fetch leak de-dup context (fail-closed) + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\n# This workflow's own open [leak-scan] issues (filed with the agentic-workflows label).\ngh issue list --repo \"$GITHUB_REPOSITORY\" --search '\"[leak-scan]\" in:title' \\\n --state open --label agentic-workflows --limit 1000 --json number,title,body \\\n > /tmp/gh-aw/agent/my-open-leakscan.json\nOPEN_LEAKSCAN_COUNT=$(jq 'length' /tmp/gh-aw/agent/my-open-leakscan.json)\nif test \"$OPEN_LEAKSCAN_COUNT\" -ge 1000; then\n echo \"ERROR: open [leak-scan] search returned $OPEN_LEAKSCAN_COUNT rows — at/above the GitHub Search API's 1000-result ceiling. The issue de-dup history may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq -r '.[].title | gsub(\"[\\r\\n]+\";\" \")' /tmp/gh-aw/agent/my-open-leakscan.json \\\n | while IFS= read -r TITLE; do\n pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title \"$TITLE\" -ExistingTitle\n done \\\n | sort -u \\\n > /tmp/gh-aw/agent/already-filed-apis.txt\necho \"already-filed rooting APIs:\"\ncat /tmp/gh-aw/agent/already-filed-apis.txt\n\n# Exact [leak-fix] PRs already MERGED to main/inflight/current.\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"[leak-fix]\" in:title' \\\n --json number,title,body,baseRefName,mergedAt,url \\\n > /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\n# `gh pr list --search` goes through GitHub's Search API, which caps best-match results\n# at 1000 regardless of --limit. This scanner is a permanent scheduled guard, so an\n# exact historical [leak-fix] PR can eventually fall outside a 1000-row result set while\n# the command still exits 0 with a merely-truncated (not empty) list — the earlier\n# \"did the fetch fail\" check can't catch that. Fail closed instead of silently scanning\n# an incomplete merged-fix history.\nMERGED_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json)\nif test \"$MERGED_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: 'gh pr list --state merged [leak-fix]' returned $MERGED_RAW_COUNT rows — at/above the GitHub Search API's 1000-result ceiling. The merged-fix history may be truncated (an older [leak-fix] PR could be missing from de-dup), so re-filing risk is real — aborting (fail-closed) instead of scanning a possibly-incomplete set. Narrow the query (e.g. partition by merge-date range) before the next run.\" >&2\n exit 1\nfi\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | startswith(\"[leak-fix] \")) |\n select(.baseRefName == \"main\" or .baseRefName == \"inflight/current\")]' \\\n /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\n > /tmp/gh-aw/agent/merged-leak-fix-prs.json\n\njq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \\\n /tmp/gh-aw/agent/merged-leak-fix-prs.json \\\n | while IFS=$'\\t' read -r PR TITLE BASE URL; do\n API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title \"$TITLE\" -ExistingTitle)\n if test -n \"$API\"; then\n printf '%s\\t%s\\t%s\\t%s\\t%s\\n' \"$API\" \"$PR\" \"$BASE\" \"$URL\" \"$TITLE\"\n fi\n done \\\n | sort -u \\\n > /tmp/gh-aw/agent/already-merged-fix-apis.tsv\ncut -f1 /tmp/gh-aw/agent/already-merged-fix-apis.tsv | sort -u \\\n > /tmp/gh-aw/agent/already-merged-fix-apis.txt\necho \"already-merged fix APIs:\"\ncat /tmp/gh-aw/agent/already-merged-fix-apis.tsv\n\n# A merged [leak-fix] PR is not permanent proof the fix is still active — it may since\n# have been reverted (e.g. it broke something else), in which case the shipped package\n# will still reproduce the ORIGINAL leak and skipping the API forever would be wrong.\n# GitHub revert PRs identify their target with a repository-local \"Reverts #\" or an\n# exact \"Reverts /#\" reference, optionally with normal Markdown\n# formatting. Resolve those links recursively: any active same-branch direct reverter\n# keeps its target reverted. Reverting a reverter can deactivate that reverter, but\n# independent sibling reverts never cancel each other.\n# Discover only same-branch merged PRs whose bodies explicitly revert one of the\n# relevant leak fixes from one bounded closed-PR snapshot per authoritative base branch.\n# The constant `Reverts in:body` query is limited to two branches, checked against the\n# 256-character Search API query ceiling, and fails closed at each 1000-result snapshot\n# ceiling. Snapshot fetches use bounded transient retries, honor capped server-directed\n# rate-limit delays, and fail closed after exhaustion. Exact repository-local direct\n# references are indexed once, then recursive reverter chains are traversed locally under\n# 1000-discovery and 2000-PR aggregate bounds so seed count and depth never multiply\n# Search API calls.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json\n# Resolve the EFFECTIVE state recursively, not just one hop. A merged revert toggles\n# its target only while that revert itself remains active on the SAME base branch.\n# A revert is active only when none of its own same-branch direct reverters is active;\n# any active direct reverter keeps its target reverted. Servicing-branch reverts cannot\n# alter main/inflight.\npwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedRevertsJsonPath /tmp/gh-aw/agent/merged-revert-prs.json \\\n -OutputPath /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt\nif test -s /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt; then\n echo \"excluding effectively-reverted merged-fix PRs from the permanent-proof set:\"\n cat /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt\n awk -F '\\t' 'NR==FNR{rev[$1]=1; next} !($2 in rev)' \\\n /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n > /tmp/gh-aw/agent/already-merged-fix-apis.filtered.tsv\n mv /tmp/gh-aw/agent/already-merged-fix-apis.filtered.tsv /tmp/gh-aw/agent/already-merged-fix-apis.tsv\n cut -f1 /tmp/gh-aw/agent/already-merged-fix-apis.tsv | sort -u \\\n > /tmp/gh-aw/agent/already-merged-fix-apis.txt\nfi\n" + shell: bash + - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 - name: Generate Safe Outputs Config @@ -1031,6 +1039,7 @@ jobs: environment: copilot-pat-pool permissions: actions: read + contents: read issues: write concurrency: group: "gh-aw-conclusion-daily-leak-hunter" @@ -1607,7 +1616,9 @@ jobs: runs-on: ubuntu-slim environment: copilot-pat-pool permissions: + contents: read issues: write + pull-requests: read timeout-minutes: 45 env: GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} @@ -1674,6 +1685,30 @@ jobs: GH_HOST="${GITHUB_SERVER_URL#https://}" GH_HOST="${GH_HOST#http://}" echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Checkout trusted leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: trusted-leak-hunter + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + - name: Protect trusted leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + run: | + set -euo pipefail + TRUSTED_DIR="$GITHUB_WORKSPACE/trusted-leak-hunter/.github/scripts" + test -f "$TRUSTED_DIR/Assert-LeakHunterSafeOutputGate.ps1" + test -f "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + chmod -R a-w "$TRUSTED_DIR" + shell: bash + - name: Enforce final leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + run: "& (Join-Path $env:GITHUB_WORKSPACE \"trusted-leak-hunter/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1\")" + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent_output.json + GH_TOKEN: ${{ github.token }} + shell: pwsh - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index c1a639097e55..3d15cf24fa4b 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -42,6 +42,7 @@ if: | permissions: contents: read issues: read + pull-requests: read model: gpt-5.6-sol engine: @@ -66,6 +67,129 @@ tools: checkout: fetch-depth: 50 +# gh-aw computes the built-in safe_outputs permissions from mutation handlers, but the trusted +# final gate also performs read-only PR metadata queries. Add only that missing read scope to +# the generated job; the compiler merges it with contents:read + issues:write. +jobs: + safe_outputs: + permissions: + pull-requests: read + +# Deterministic pre-pass (runs BEFORE the agent/MCP gateway starts, same job/runner, so its +# /tmp/gh-aw writes are visible to the agent's later bash calls — /tmp/gh-aw is bind-mounted +# read-write into the agent's sandbox container). This is a GENUINE job-enforced gate: `set -e` +# means a `gh` failure here fails this GH Actions step (and therefore the whole job) BEFORE the +# agent ever starts — unlike the equivalent fetch previously run as an in-prompt bash tool call, +# where a nonzero exit is only reported to the agent as a tool error and does not by itself stop +# the agent from continuing and still emitting a `create-issue` safe-output. +pre-agent-steps: + - name: Fetch leak de-dup context (fail-closed) + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p /tmp/gh-aw/agent + + # This workflow's own open [leak-scan] issues (filed with the agentic-workflows label). + gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ + --state open --label agentic-workflows --limit 1000 --json number,title,body \ + > /tmp/gh-aw/agent/my-open-leakscan.json + OPEN_LEAKSCAN_COUNT=$(jq 'length' /tmp/gh-aw/agent/my-open-leakscan.json) + if test "$OPEN_LEAKSCAN_COUNT" -ge 1000; then + echo "ERROR: open [leak-scan] search returned $OPEN_LEAKSCAN_COUNT rows — at/above the GitHub Search API's 1000-result ceiling. The issue de-dup history may be truncated, so aborting fail-closed." >&2 + exit 1 + fi + jq -r '.[].title | gsub("[\r\n]+";" ")' /tmp/gh-aw/agent/my-open-leakscan.json \ + | while IFS= read -r TITLE; do + pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle + done \ + | sort -u \ + > /tmp/gh-aw/agent/already-filed-apis.txt + echo "already-filed rooting APIs:" + cat /tmp/gh-aw/agent/already-filed-apis.txt + + # Exact [leak-fix] PRs already MERGED to main/inflight/current. + gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName,mergedAt,url \ + > /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json + # `gh pr list --search` goes through GitHub's Search API, which caps best-match results + # at 1000 regardless of --limit. This scanner is a permanent scheduled guard, so an + # exact historical [leak-fix] PR can eventually fall outside a 1000-row result set while + # the command still exits 0 with a merely-truncated (not empty) list — the earlier + # "did the fetch fail" check can't catch that. Fail closed instead of silently scanning + # an incomplete merged-fix history. + MERGED_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json) + if test "$MERGED_RAW_COUNT" -ge 1000; then + echo "ERROR: 'gh pr list --state merged [leak-fix]' returned $MERGED_RAW_COUNT rows — at/above the GitHub Search API's 1000-result ceiling. The merged-fix history may be truncated (an older [leak-fix] PR could be missing from de-dup), so re-filing risk is real — aborting (fail-closed) instead of scanning a possibly-incomplete set. Narrow the query (e.g. partition by merge-date range) before the next run." >&2 + exit 1 + fi + jq '[.[] | + select(.mergedAt != null) | + select(.title | startswith("[leak-fix] ")) | + select(.baseRefName == "main" or .baseRefName == "inflight/current")]' \ + /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.json + + jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ + /tmp/gh-aw/agent/merged-leak-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE BASE URL; do + API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + if test -n "$API"; then + printf '%s\t%s\t%s\t%s\t%s\n' "$API" "$PR" "$BASE" "$URL" "$TITLE" + fi + done \ + | sort -u \ + > /tmp/gh-aw/agent/already-merged-fix-apis.tsv + cut -f1 /tmp/gh-aw/agent/already-merged-fix-apis.tsv | sort -u \ + > /tmp/gh-aw/agent/already-merged-fix-apis.txt + echo "already-merged fix APIs:" + cat /tmp/gh-aw/agent/already-merged-fix-apis.tsv + + # A merged [leak-fix] PR is not permanent proof the fix is still active — it may since + # have been reverted (e.g. it broke something else), in which case the shipped package + # will still reproduce the ORIGINAL leak and skipping the API forever would be wrong. + # GitHub revert PRs identify their target with a repository-local "Reverts #" or an + # exact "Reverts /#" reference, optionally with normal Markdown + # formatting. Resolve those links recursively: any active same-branch direct reverter + # keeps its target reverted. Reverting a reverter can deactivate that reverter, but + # independent sibling reverts never cancel each other. + # Discover only same-branch merged PRs whose bodies explicitly revert one of the + # relevant leak fixes from one bounded closed-PR snapshot per authoritative base branch. + # The constant `Reverts in:body` query is limited to two branches, checked against the + # 256-character Search API query ceiling, and fails closed at each 1000-result snapshot + # ceiling. Snapshot fetches use bounded transient retries, honor capped server-directed + # rate-limit delays, and fail closed after exhaustion. Exact repository-local direct + # references are indexed once, then recursive reverter chains are traversed locally under + # 1000-discovery and 2000-PR aggregate bounds so seed count and depth never multiply + # Search API calls. + pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ + -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json + # Resolve the EFFECTIVE state recursively, not just one hop. A merged revert toggles + # its target only while that revert itself remains active on the SAME base branch. + # A revert is active only when none of its own same-branch direct reverters is active; + # any active direct reverter keeps its target reverted. Servicing-branch reverts cannot + # alter main/inflight. + pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ + -MergedRevertsJsonPath /tmp/gh-aw/agent/merged-revert-prs.json \ + -OutputPath /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt + if test -s /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt; then + echo "excluding effectively-reverted merged-fix PRs from the permanent-proof set:" + cat /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt + awk -F '\t' 'NR==FNR{rev[$1]=1; next} !($2 in rev)' \ + /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ + > /tmp/gh-aw/agent/already-merged-fix-apis.filtered.tsv + mv /tmp/gh-aw/agent/already-merged-fix-apis.filtered.tsv /tmp/gh-aw/agent/already-merged-fix-apis.tsv + cut -f1 /tmp/gh-aw/agent/already-merged-fix-apis.tsv | sort -u \ + > /tmp/gh-aw/agent/already-merged-fix-apis.txt + fi + network: allowed: - defaults @@ -74,6 +198,38 @@ network: - "*.blob.core.windows.net" safe-outputs: + # The pre-agent snapshot keeps the agent from wasting work on known leaks, but a fix can + # merge during the up-to-90-minute hunt. Re-fetch authoritative live metadata in the + # generated safe-output job immediately before Process Safe Outputs. A late same-API + # issue/fix rejects the entire create-issue batch before mutation; otherwise-distinct items + # retry on the next scheduled run. The gate deliberately does not rewrite agent output or + # accept agent-authored mechanism overrides. Restore it from the read-only default branch + # rather than executing workflow-dispatch-selected code with the write-capable job token. + steps: + - name: Checkout trusted leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted-leak-hunter + sparse-checkout: .github/scripts + persist-credentials: false + - name: Protect trusted leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + shell: bash + run: | + set -euo pipefail + TRUSTED_DIR="$GITHUB_WORKSPACE/trusted-leak-hunter/.github/scripts" + test -f "$TRUSTED_DIR/Assert-LeakHunterSafeOutputGate.ps1" + test -f "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + chmod -R a-w "$TRUSTED_DIR" + - name: Enforce final leak-hunter de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_issue') }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent_output.json + run: '& (Join-Path $env:GITHUB_WORKSPACE "trusted-leak-hunter/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1")' create-issue: # No auto title-prefix: the agent writes the FULL title, starting with the mode tag — # The agent writes the FULL title, starting with the tag "[leak-scan] ". Up to `max` @@ -119,11 +275,13 @@ Never push, never open a PR, never comment, never edit product or test code in t device tests and are out of scope. 4. **Skip weak-proxied code.** If the suspect uses `WeakEventManager`, `ConditionalWeakTable`, `WeakReference`, or any `Weak*Proxy`, it does not leak — move on. -5. **De-dup against THIS SCANNER's own OPEN issues.** Before filing, fetch this workflow's open - `[leak-scan]` issues and skip a leak already covered by one (same rooting API / retention - path). Do NOT suppress a candidate because AdamEssenmacher (or anyone else) has a repro/issue - for it — duplicating those is fine. A - candidate whose only prior issue from this scanner is CLOSED may be re-filed. +5. **De-dup against open scanner issues AND merged fixes.** Before testing or filing, skip a + leak already covered by this workflow's open `[leak-scan]` issue (same rooting API / + retention path), or by an exact `[leak-fix]` PR for the same API/retention path already + merged to `main` or `inflight/current`. Do NOT suppress a candidate merely because + AdamEssenmacher (or anyone else) has a repro/issue for it — duplicating those is fine. A + candidate whose only prior scanner issue is CLOSED may be re-filed only when no equivalent + supported-branch merged fix exists. 6. **Never weaken or disable anything, and never commit code.** You only READ repo source and (Pass A) ADD a throwaway test under `/tmp`. Never edit product code, never `[ActiveIssue]`/skip/mute existing tests, never push. @@ -137,41 +295,67 @@ Never push, never open a PR, never comment, never edit product or test code in t candidate with a **standalone** test that references the **shipped `Microsoft.Maui.Controls` NuGet package** from nuget.org (Step 4) — no source build, no workload, no emulator. -## Step 2 — Fetch this scanner's own OPEN issues (de-dup) +## Step 2 — Fetch open scanner issues and merged fixes (de-dup) -The only de-dup that matters is not posting a second OPEN copy of a leak THIS workflow already -filed. You do **not** care about AdamEssenmacher's repro branches or anyone else's issues — -duplicating those is explicitly fine. +Two de-dup sources matter: -Fetch this scanner's own open `[leak-scan]` issues (they are filed with the `agentic-workflows` -label) and extract the **rooting API** each one already covers: +1. this workflow's own open `[leak-scan]` issues; and +2. exact `[leak-fix]` PRs already merged to `main` or `inflight/current`. -``` -gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ - --state open --label agentic-workflows --limit 200 --json number,title,body \ - > /tmp/gh-aw/agent/my-open-leakscan.json -# The rooting API is the "Type.Member" the title names. Titles SHOULD lead with it (Step 6), -# but real runs have produced off-contract titles like "Shell BackButtonBehavior.Command …" -# (#36345) vs "BackButtonBehavior.Command: …" (#36354). A prefix-only cut keys those on -# "Shell" vs "BackButtonBehavior.Command" and re-files a duplicate. Extract the LAST dotted -# Type.Member pair of the first identifier chain: for a fully-qualified title like -# "Microsoft.Maui.Controls.Picker.ItemsSource" this yields "Picker.ItemsSource" (not the -# namespace head "Microsoft.Maui", which would over-collapse distinct leaks to one key). -jq -r '.[].title' /tmp/gh-aw/agent/my-open-leakscan.json \ - | sed -E 's/^\[leak-scan\] *//' \ - | awk '{ if (match($0, /[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+/)) { chain=substr($0,RSTART,RLENGTH); n=split(chain,seg,"."); print seg[n-1]"."seg[n] } else print }' \ - | sort -u \ - > /tmp/gh-aw/agent/already-filed-apis.txt +You do **not** care about AdamEssenmacher's repro branches or anyone else's issues by +themselves — duplicating those is explicitly fine. A merged generated fix is different: the +shipped package may still reproduce the old leak even though the fix has already landed in the +active source flow, so filing it again would only create another redundant fix PR. + +**This de-dup context was already gathered for you, before you started, by a deterministic +`pre-agent-steps` job step** (not a bash tool call you invoke) — see the workflow frontmatter. +That step runs on the plain runner (not through your sandbox) with `set -euo pipefail`, so a +`gh` failure or a Search-API 1000-result truncation fails the GitHub Actions step itself — and +therefore the whole job, before you are ever started — rather than merely returning an error +you could choose to route around. Its output already sits under `/tmp/gh-aw/agent/` (that path +is shared with your sandbox), so you only need to READ it: + +```bash echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt +echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.tsv ``` -- A candidate is **OUT** if its rooting `Type.Member` (e.g. `SwipeItemView.Command`, - `Picker.ItemsSource`) is already in `already-filed-apis.txt`, OR an open `[leak-scan]` issue - otherwise covers the same rooting API / retention path. **Check this for EVERY candidate - before you write its test** — re-filing a leak this scanner already has open (even with - different title wording) is the #1 failure mode, so be strict about matching the `Type.Member`. - -A candidate whose only prior issue from this scanner is CLOSED may be re-filed. +- `already-filed-apis.txt` — the rooting `Type.Member` of every currently-open `[leak-scan]` + issue this workflow filed (one per line). +- `already-merged-fix-apis.tsv` / `.txt` — `Type.Member PR# baseRefName URL + title` for every `[leak-fix]` PR already merged to `main` or `inflight/current` (the + `.txt` is just the first column, deduplicated). Effective revert state is resolved + recursively and per base branch from GitHub's standard + repository-local `Reverts #` or exact `Reverts /#` body reference + (normal Markdown formatting is accepted): any active same-branch direct reverter excludes + its target. Reverting a reverter can deactivate that reverter, but independent sibling + reverts never cancel each other. A servicing-branch revert cannot toggle a main/inflight + fix. Only an effectively reverted fix is treated as re-filable rather than permanent proof + the fix is still active. + +- A candidate is **OUT** if an open `[leak-scan]` issue or active merged `[leak-fix]` PR covers + the same canonical API. Use `already-filed-apis.txt` / `already-merged-fix-apis.txt` as + authoritative match signals and skip every match. **Check this for EVERY candidate before + its test.** +- Normalize each candidate with the same anchored title convention: the API must be the first + dotted identifier chain immediately after `[leak-scan] ` (or after `[leak-fix] Fix `). + Existing short `Type.Member` keys stay stable, `Microsoft.Maui.*` qualification migrates to + that short key, and other qualification is preserved to prevent namespace collisions. Then use + `grep -Fxq "$API" /tmp/gh-aw/agent/already-merged-fix-apis.txt`; do not use substring + matching. +- For a merged-fix match, print the matching row(s) from + `already-merged-fix-apis.tsv` and record + `skipped: canonical API already fixed via # to `. +- For an open issue match, skip the candidate. +- Re-filing the same canonical API under different wording/number is the primary failure mode. +- Immediately before issue mutation, a trusted safe-output step independently re-fetches open + scanner issues, merged fixes, and branch-scoped effective revert state. A late same-API + issue/fix rejects the entire `create-issue` batch before mutation. Otherwise-distinct items + remain unchanged and retry on the next scheduled run; do not treat the pre-agent snapshot + as the final authority or expect the gate to filter agent output. + +A candidate whose only prior scanner issue is CLOSED may be re-filed when no active merged fix +covers the same canonical API. # ===================== RUNTIME LEAK HUNT ===================== @@ -224,10 +408,11 @@ transient object (page / view / view-model / handler) with no teardown**, e.g.: For each candidate, write down the precise retention path `root -> ... -> transient` with file:line citations, then cross-check Step 2. **Collect EVERY -distinct candidate** across all focus areas that is not already an open `[leak-scan]` issue — -build a candidate list (aim for several). Rank them strongest-first, then confirm as many as -you can in Step 4/5. If — after a genuine sweep — there is no convincing candidate at all, stop -and create nothing (a quiet run is fine — there is no coverage-gap fallback). +distinct candidate** across all focus areas that is not already an open `[leak-scan]` issue and +does not already have a supported-branch merged `[leak-fix]` PR — build a candidate list (aim +for several). Rank them strongest-first, then confirm as many as you can in Step 4/5. If — +after a genuine sweep — there is no convincing candidate at all, stop and create nothing (a +quiet run is fine — there is no coverage-gap fallback). ## Step 4 — Write a standalone control/leaky/mitigation test (shipped package) @@ -291,13 +476,23 @@ no MAUI source build, no emulator. ## Step 6 — File the issues (Pass A — one per confirmed leak) -For **every** leak Step 5 confirmed, emit a `create-issue` safe-output (up to the 8 cap) — one -issue per distinct leak. De-dup each against open `[leak-scan]` issues AND against the other -issues you're filing this run (no two issues for the same rooting API). Each title MUST be of the -form **`[leak-scan] .`** — it MUST **lead with the canonical -rooting `Type.Member`** immediately after the tag (e.g. `[leak-scan] SwipeItemView.Command — non-weak -ICommand.CanExecuteChanged retains the control`). De-dup (Step 2) matches on that leading -`Type.Member`, so keep it stable and canonical — do not reword it run-to-run. +For **every** leak Step 5 confirmed whose canonical API has not already been selected this run, +emit a `create-issue` safe-output (up to the 8 cap) — one issue per distinct leak, with at most +one output per canonical rooting API in the current batch. If multiple confirmed retention +mechanisms share one API, emit the strongest report and defer the others to a later run rather +than producing same-API siblings together. De-dup each selected leak against open `[leak-scan]` +issues, supported-branch merged `[leak-fix]` PRs, AND the other issues you're filing this run. +Any same-API match blocks output because the trusted gate has no independent evidence that an +agent-authored mechanism comparison is correct. A trusted final gate repeats the live de-dup +immediately before mutation. It validates the up-to-eight-item batch atomically: one late +same-API match aborts every issue mutation, and otherwise-distinct reports retry on the next +scheduled run. +Each title MUST be of the form **`[leak-scan] `** — it MUST +lead with the anchored canonical API immediately after the tag. Use the stable short +`Type.Member` for `Microsoft.Maui.*` APIs (for example, `[leak-scan] SwipeItemView.Command — +non-weak ICommand.CanExecuteChanged retains the control`), but preserve non-MAUI +namespace/nesting qualification when needed to distinguish colliding `Type.Member` names. +De-dup (Step 2) matches that leading key exactly, so do not reword it run-to-run. Body (markdown): - A clear **AI-generated** banner naming this workflow. diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 658ebb91c761..ee007c122053 100644 --- a/.github/workflows/leak-fixer.lock.yml +++ b/.github/workflows/leak-fixer.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b2eed20c93dea3b323add93332da77e528c31456f9a3b7e23963e80d9c7ee255","body_hash":"942cc0b0b5adb03d5b57c0e7e387ae00479a1176c60b11ffa756d117c75ddc9c","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-sol","engine_versions":{"copilot":"1.0.78"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"904f5e7198714e63042518c4e15989be57d04d917590f91e1f831e0795018cb9","body_hash":"5526fa8be4f04e77c10bf180fd10e1abfffcd3f815cc7ad51db36a2524aeab06","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-sol","engine_versions":{"copilot":"1.0.78"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_PAT_0","COPILOT_PAT_1","COPILOT_PAT_2","COPILOT_PAT_3","COPILOT_PAT_4","COPILOT_PAT_5","COPILOT_PAT_6","COPILOT_PAT_7","COPILOT_PAT_8","COPILOT_PAT_9","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -880,6 +880,7 @@ jobs: # --allow-tool shell(mkdir) # --allow-tool shell(printf) # --allow-tool shell(pwd) + # --allow-tool shell(pwsh) # --allow-tool shell(safeoutputs:*) # --allow-tool shell(sed) # --allow-tool shell(sh) @@ -927,7 +928,7 @@ jobs: fi # shellcheck disable=SC1003,SC2016,SC2086 awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(awk)'\'' --allow-tool '\''shell(basename)'\'' --allow-tool '\''shell(bash)'\'' --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(chmod)'\'' --allow-tool '\''shell(curl:*)'\'' --allow-tool '\''shell(cut)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(dirname)'\'' --allow-tool '\''shell(dotnet:*)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(env)'\'' --allow-tool '\''shell(find)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(git:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sed)'\'' --allow-tool '\''shell(sh)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(tee)'\'' --allow-tool '\''shell(test)'\'' --allow-tool '\''shell(tr)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(awk)'\'' --allow-tool '\''shell(basename)'\'' --allow-tool '\''shell(bash)'\'' --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(chmod)'\'' --allow-tool '\''shell(curl:*)'\'' --allow-tool '\''shell(cut)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(dirname)'\'' --allow-tool '\''shell(dotnet:*)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(env)'\'' --allow-tool '\''shell(find)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(git:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(pwsh)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sed)'\'' --allow-tool '\''shell(sh)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(tee)'\'' --allow-tool '\''shell(test)'\'' --allow-tool '\''shell(tr)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -1798,6 +1799,35 @@ jobs: GH_HOST="${GITHUB_SERVER_URL#https://}" GH_HOST="${GH_HOST#http://}" echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Restore trusted leak-fix de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_pull_request') }} + run: | + set -euo pipefail + TRUSTED_DIR="$RUNNER_TEMP/leak-fix-safe-output" + mkdir -p "$TRUSTED_DIR" + gh api --method GET \ + "repos/$GITHUB_REPOSITORY/contents/.github/scripts/Assert-LeakFixSafeOutputGate.ps1" \ + -f ref="$TRUSTED_REF" \ + -H "Accept: application/vnd.github.raw+json" \ + > "$TRUSTED_DIR/Assert-LeakFixSafeOutputGate.ps1" + gh api --method GET \ + "repos/$GITHUB_REPOSITORY/contents/.github/scripts/LeakWorkflowDedup.psm1" \ + -f ref="$TRUSTED_REF" \ + -H "Accept: application/vnd.github.raw+json" \ + > "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + chmod -R a-w "$TRUSTED_DIR" + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_REF: ${{ github.event.repository.default_branch }} + shell: bash + - name: Enforce final leak-fix de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_pull_request') }} + run: "& (Join-Path $env:RUNNER_TEMP \"leak-fix-safe-output/Assert-LeakFixSafeOutputGate.ps1\")" + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent_output.json + GH_TOKEN: ${{ github.token }} + LEAK_DEDUP_STATE_DIR: /tmp/gh-aw/agent + shell: pwsh - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 2615b7a6aca6..bfba6cfa95a6 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -80,7 +80,7 @@ tools: toolsets: [pull_requests, repos, issues, search] min-integrity: approved edit: - bash: ["dotnet", "git", "gh", "find", "ls", "cat", "grep", "head", "tail", "wc", "jq", "tee", "sed", "awk", "tr", "cut", "sort", "uniq", "xargs", "echo", "date", "mkdir", "test", "env", "basename", "dirname", "bash", "sh", "chmod", "curl"] + bash: ["dotnet", "git", "gh", "find", "ls", "cat", "grep", "head", "tail", "wc", "jq", "tee", "sed", "awk", "tr", "cut", "sort", "uniq", "xargs", "echo", "date", "mkdir", "test", "env", "basename", "dirname", "bash", "sh", "chmod", "curl", "pwsh"] checkout: fetch-depth: 200 @@ -98,6 +98,44 @@ network: - "*.blob.core.windows.net" safe-outputs: + # The final duplicate check must be authoritative at the mutation boundary. A failed + # in-prompt bash call only reports a tool error to the agent; it cannot prevent the agent + # from calling create_pull_request afterward. This deterministic step runs in the generated + # safe-output job immediately before Process Safe Outputs and fails the job before any PR + # mutation when live metadata or the persisted target identity is incomplete/stale. The + # boundary validates that identity and independently re-derives direct issue/API matches, + # the closed-attempt cap, and effective revert state. Every same-API match is blocking; + # agent-authored mechanism overrides are not accepted. + steps: + - name: Restore trusted leak-fix de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_pull_request') }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_REF: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + TRUSTED_DIR="$RUNNER_TEMP/leak-fix-safe-output" + mkdir -p "$TRUSTED_DIR" + gh api --method GET \ + "repos/$GITHUB_REPOSITORY/contents/.github/scripts/Assert-LeakFixSafeOutputGate.ps1" \ + -f ref="$TRUSTED_REF" \ + -H "Accept: application/vnd.github.raw+json" \ + > "$TRUSTED_DIR/Assert-LeakFixSafeOutputGate.ps1" + gh api --method GET \ + "repos/$GITHUB_REPOSITORY/contents/.github/scripts/LeakWorkflowDedup.psm1" \ + -f ref="$TRUSTED_REF" \ + -H "Accept: application/vnd.github.raw+json" \ + > "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + chmod -R a-w "$TRUSTED_DIR" + - name: Enforce final leak-fix de-dup gate + if: ${{ contains(needs.agent.outputs.output_types, 'create_pull_request') }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent_output.json + LEAK_DEDUP_STATE_DIR: /tmp/gh-aw/agent + run: '& (Join-Path $env:RUNNER_TEMP "leak-fix-safe-output/Assert-LeakFixSafeOutputGate.ps1")' create-pull-request: # No fixed title-prefix: the agent writes the FULL title starting with "[leak-fix] " # (it fixes a runtime leak from a [leak-scan] issue). At most ONE PR per run. @@ -178,9 +216,10 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch - **Track C (review response):** any code you push must keep the PR's tests valid — re-run the affected test so Track A stays red→green. Never push a change that breaks the PR's own test just to satisfy a review. -2. **If a Track A leak is already fixed on `main`, open NO PR.** When your faithful regression - test passes on the *unpatched* source, the leak no longer reproduces — record - `skipped: already fixed on main (test green without fix)` and stop. +2. **If a Track A leak already has an equivalent `[leak-fix]` merged to `main` or + `inflight/current`, open NO PR.** Check live merged PR metadata before branch creation or + test authoring. Also stop when your faithful regression test passes on the *unpatched* + source, recording `skipped: already fixed on main (test green without fix)`. 3. **Managed scope for product fixes.** Any *product* change (Track A / a Track C code fix) must live in managed cross-platform code (`src/Controls/src`, `src/Core/src`, `src/Essentials/src`). If a `[leak-scan]` leak can only be reproduced with a platform handler / native peer, it is out @@ -334,7 +373,7 @@ a response, fall through to Step 2. If `issue_number` was provided, use it (it must be a `[leak-scan]` issue → Track A). Otherwise auto-pick: list this scanner's open `[leak-scan]` issues (oldest first) and take the first that -does NOT already have an open fix PR (Step 3 confirms). +does NOT already have an open or merged equivalent fix PR (Step 3 confirms). ```bash # Open [leak-scan] (Track A) issues, oldest first. @@ -354,49 +393,252 @@ Read the chosen issue's body in full (`gh issue view --json title,body`). Ex - the **suggested fix** shape, and - any **non-default / disabling condition**. -## Step 3 — De-dup + attempt cap (live GitHub searches) +## Step 3 — De-dup merged/open fixes + attempt cap (live GitHub searches) A fix PR carries `Fixes #` (and `Refs: /#`) in its body — that is the join key. But the same underlying leak can be filed under MULTIPLE issue numbers (duplicate `[leak-scan]` issues, or a pre-existing upstream issue), so also de-dup by the **rooting -`Type.Member`** the target names — never open a second fix for a leak already being fixed. +`Type.Member`** the target names — never open a second fix for a leak already being fixed or +already merged into `main` / `inflight/current`. + +Use the PR's live `baseRefName` as the branch authority. Do not trust a potentially stale +`Target branch:` line in its body: leak PRs can be retargeted to `inflight/current` before +merge. ```bash N= # The rooting Type.Member this issue is about (titles lead with it: "[leak-scan] Type.Member — ..."). -# Use the same extraction as daily-leak-hunter.md (last Type.Member pair of the first identifier -# chain) so off-contract / fully-qualified titles key identically on both sides of the pipeline. -API=$(gh issue view "$N" --repo "$GITHUB_REPOSITORY" --json title -q '.title' \ - | sed -E 's/^\[leak-scan\] *//' \ - | awk '{ if (match($0, /[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+/)) { chain=substr($0,RSTART,RLENGTH); n=split(chain,seg,"."); print seg[n-1]"."seg[n] } else print }') +# Use the shared anchored parser from daily-leak-hunter.md so malformed titles cannot key from +# a later URL/namespace token and fully-qualified titles normalize identically on both sides. +# Fetch the title first so a TRANSIENT fetch failure fails closed (empty title => abort) rather than +# silently yielding an empty $API, which would disable the same-API dedup scan below and let a +# duplicate [leak-fix] PR be opened under a re-filed leak. +TITLE=$(gh issue view "$N" --repo "$GITHUB_REPOSITORY" --json title -q '.title | gsub("[\r\n]+";" ")') +if test -z "$TITLE"; then + echo "ERROR: could not read issue #$N title (transient gh failure?) — aborting to avoid fail-open dedup." >&2 + exit 1 +fi +API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) echo "target rooting API: $API" -# Escape regex metacharacters (notably the '.' in Type.Member) so the jq test() calls below match -# a LITERAL "Type.Member" — otherwise "BackButtonBehavior.Command" would also match "BackButtonBehaviorXCommand". -API_RE=$(printf '%s' "$API" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') -# (a) Open [leak-fix] PR already addressing THIS issue number? -gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title' \ - --json number,title,body \ - | jq --arg n "$N" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ +if test -z "$API"; then + echo "ERROR: issue #$N title has no canonical Type.Member; refusing unsupported empty-API de-dup before build/test work." >&2 + exit 1 +fi +# Escape the repo slug (repo names may contain '.' / '-') for the exact `Refs:` match below. +REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') +# Every bash tool call starts in a fresh shell. Persist the target identity; Step 9.5 and, +# authoritatively, the safe-output mutation-boundary gate reload and validate this file. +# `different_mechanism_prs` is retained as an explicitly empty schema field — trusted gates +# reject agent-authored overrides. Missing/mismatched state is a fail-closed refusal to create +# a PR, never an empty-variable fall-through. +jq -n \ + --argjson issue_number "$N" \ + --arg api "$API" \ + --arg repository "$GITHUB_REPOSITORY" \ + '{ + issue_number: $issue_number, + api: $api, + repository: $repository, + different_mechanism_prs: [] + }' > /tmp/gh-aw/agent/dedup-state.json +# (a) Exact [leak-fix] PRs already MERGED to main/inflight/current. +# Fail-closed: a transient fetch error writes nothing, and jq on an empty pipe still emits [] +# with exit 0 — this gate would then wrongly conclude "no merged fix exists" and let leak-fixer +# create a duplicate PR (the exact outcome this workflow prevents). Split fetch from filter. +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName,mergedAt,url \ + > /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json; then + echo "ERROR: 'gh pr list --state merged [leak-fix]' failed — aborting to avoid fail-open dedup that would re-create an already-merged fix." >&2 + exit 1 +fi +MERGED_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json) +if test "$MERGED_RAW_COUNT" -ge 1000; then + echo "ERROR: 'gh pr list --state merged [leak-fix]' returned $MERGED_RAW_COUNT rows — at/above the GitHub Search API's 1000-result ceiling. The merged-fix history may be truncated, so aborting before build/test work (fail-closed)." >&2 + exit 1 +fi +jq '[.[] | + select(.mergedAt != null) | + select(.title | startswith("[leak-fix] ")) | + select(.baseRefName == "main" or .baseRefName == "inflight/current")]' \ + /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.json +jq -r '.[] | ["_", .number, .baseRefName, .title] | @tsv' \ + /tmp/gh-aw/agent/merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.tsv + +# Canonicalize every merged PR title with the same anchored parser used for the selected issue. +# This handles fully-qualified titles without accepting off-contract wording or substring matches. +jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ + /tmp/gh-aw/agent/merged-leak-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE BASE URL; do + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + if test -n "$PR_API"; then + printf '%s\t%s\t%s\t%s\t%s\n' "$PR_API" "$PR" "$BASE" "$URL" "$TITLE" + fi + done \ + | sort -u \ + > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv + +# A merged fix is not authoritative if it was later effectively reverted. Discover reverts from +# one bounded closed-PR snapshot per authoritative base branch. The constant `Reverts in:body` +# query is limited to two branches, checked against the 256-character Search API query ceiling, +# and fails closed at each 1000-result snapshot ceiling. Snapshot fetches use bounded transient +# retries, honor capped server-directed rate-limit delays, and fail closed after exhaustion. +# Exact repository-local direct references are indexed once, then recursive reverter chains are +# traversed locally under 1000-discovery and 2000-PR aggregate bounds so seed count and depth +# never multiply Search API calls. +pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/merged-leak-fix-prs.tsv \ + -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json +pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/merged-leak-fix-prs.tsv \ + -MergedRevertsJsonPath /tmp/gh-aw/agent/merged-revert-prs.json \ + -OutputPath /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt +if test -s /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt; then + jq --rawfile reverted /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt ' + ($reverted | split("\n") | map(select(length > 0) | tonumber)) as $numbers | + map(select(.number as $number | $numbers | index($number) | not)) + ' /tmp/gh-aw/agent/merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.filtered.json + cat /tmp/gh-aw/agent/merged-leak-fix-prs.filtered.json \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.json + awk -F '\t' 'NR==FNR{reverted[$1]=1; next} !($2 in reverted)' \ + /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt \ + /tmp/gh-aw/agent/merged-leak-fix-apis.tsv \ + > /tmp/gh-aw/agent/merged-leak-fix-apis.filtered.tsv + cat /tmp/gh-aw/agent/merged-leak-fix-apis.filtered.tsv \ + > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv +fi + +# Match either the selected issue reference OR the canonical rooting API. The latter catches +# duplicate scanner issue numbers such as #36539 after #36344 was already fixed by #36369. +# Anchor `Fixes #N` to the start of a body line (excludes incidental/negated text like "Does +# not Fixes #N" mid-sentence) and require `Refs:` to name THIS repo exactly (excludes +# cross-repo text like "Refs: other/repo#N", which the old "[^0-9]*" gap let through). +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | + select((.body // "") | + test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or + test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/merged-issue-fix-prs.json +awk -F '\t' -v api="$API" '$1 == api' \ + /tmp/gh-aw/agent/merged-leak-fix-apis.tsv \ + > /tmp/gh-aw/agent/merged-api-fix-prs.tsv +jq -r '.[] | "equivalent fix already merged: #\(.number) -> \(.baseRefName) \(.url) — \(.title)"' \ + /tmp/gh-aw/agent/merged-issue-fix-prs.json +awk -F '\t' '{ print "equivalent API fix already merged: #" $2 " -> " $3 " " $4 " — " $5 }' \ + /tmp/gh-aw/agent/merged-api-fix-prs.tsv +echo "merged issue-reference matches: $(jq 'length' /tmp/gh-aw/agent/merged-issue-fix-prs.json)" +echo "merged canonical-API matches: $(wc -l < /tmp/gh-aw/agent/merged-api-fix-prs.tsv | tr -d ' ')" +# (b) Open [leak-fix] PR already addressing THIS issue number? +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName \ + > /tmp/gh-aw/agent/open-fix-prs-raw.json; then + echo "ERROR: 'gh pr list --state open [leak-fix]' failed — aborting to avoid fail-open dedup that would re-file over an already-open fix." >&2 + exit 1 +fi +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | + select(.baseRefName == "main" or .baseRefName == "inflight/current") | + select(.title | startswith("[leak-fix] ")) | + select((.body // "") | + test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or + test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/open-fix-prs-raw.json \ > /tmp/gh-aw/agent/open-fix-prs.json jq 'length' /tmp/gh-aw/agent/open-fix-prs.json -# (b) Open [leak-fix] PR already fixing the SAME rooting Type.Member (any issue number)? -# [leak-fix] PR titles are "Fix . memory leak". -gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title' \ - --json number,title \ - | jq --arg api "$API_RE" '[.[] | select(.title | test("Fix +"+$api+"([. ]|$)"))]' \ +# (c) Open [leak-fix] PR already fixing the SAME rooting Type.Member (any issue number)? +# Canonicalize each open PR title with the SAME anchored extraction used for the +# merged-fix gate (a) and the target issue, then compare canonical keys exactly — do NOT +# anchor-match the raw title against $API. A fully-qualified title like "[leak-fix] Fix +# Microsoft.Maui.Controls.Picker.ItemsSource memory leak" represents the same +# Picker.ItemsSource leak but would not match an anchored "Fix Picker\.ItemsSource" regex, +# letting a second concurrent fix PR for the same leak through. +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,baseRefName \ + > /tmp/gh-aw/agent/same-api-prs-raw.json; then + echo "ERROR: 'gh pr list --state open [leak-fix]' (same-API scan) failed — aborting to avoid fail-open dedup." >&2 + exit 1 +fi +jq -r '.[] | + select(.baseRefName == "main" or .baseRefName == "inflight/current") | + select(.title | startswith("[leak-fix] ")) | + [.number, .title] | @tsv' \ + /tmp/gh-aw/agent/same-api-prs-raw.json \ + | while IFS=$'\t' read -r PR TITLE; do + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + if test "$PR_API" = "$API"; then + jq -n --arg number "$PR" --arg title "$TITLE" '{number: ($number|tonumber), title: $title}' + fi + done \ + | jq -s '.' \ > /tmp/gh-aw/agent/same-api-prs.json jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/same-api-prs.json -# (c) Closed-unmerged attempts for this issue (attempt cap = 3). -gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title' \ - --json number,title,body,mergedAt \ - | jq --arg n "$N" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ +# (d) Closed-unmerged attempts against the attempt cap (3). +# Fail-closed: a transient fetch error must not read as "0 prior attempts" and reset the cap. +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state closed --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName,mergedAt \ + > /tmp/gh-aw/agent/closed-fix-prs-raw.json; then + echo "ERROR: 'gh pr list --state closed [leak-fix]' failed — aborting so a transient error can't reset the attempt cap to 0 and re-attempt past the limit." >&2 + exit 1 +fi +# Validate every returned baseRefName before filtering. +# The cap is one aggregate budget across both authoritative lanes: main and inflight/current. +# These attempts represent the same canonical leak work; well-formed release/* (and other +# non-authoritative) lanes do not consume it. +pwsh -NoLogo -NoProfile -Command ' + $ErrorActionPreference = "Stop" + Import-Module .github/scripts/LeakWorkflowDedup.psm1 -Force + $closed = @(Get-Content -LiteralPath "/tmp/gh-aw/agent/closed-fix-prs-raw.json" -Raw | + ConvertFrom-Json) + $authoritative = @(Select-LeakAuthoritativePullRequests ` + -PullRequests $closed ` + -Context "Prompt closed leak-fix attempt-cap search") + ConvertTo-Json -InputObject $authoritative -Depth 10 +' > /tmp/gh-aw/agent/closed-fix-prs-authoritative.json +jq '[.[] | select(.title | startswith("[leak-fix] ")) | select(.mergedAt == null)]' \ + /tmp/gh-aw/agent/closed-fix-prs-authoritative.json \ + > /tmp/gh-aw/agent/closed-unmerged-fix-prs.json +# Count by BOTH this issue-number reference AND the canonical rooting Type.Member (same +# extraction as gates (a)/(c)) — otherwise the cap resets to 0 whenever the same leak is +# re-filed under a duplicate issue number, letting it burn through another 3-attempt budget +# (e.g. #36548 already carries 2 closed-unmerged IndicatorView.ItemsSource attempts that must +# still count against any newly duplicate-filed issue for that same API). +API_MATCH_NUMBERS=$(jq -r '.[] | [.number, .title] | @tsv' /tmp/gh-aw/agent/closed-unmerged-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE; do + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + test -n "$API" && test "$PR_API" = "$API" && echo "$PR" + done | jq -R 'tonumber' | jq -s '.') +jq --arg n "$N" --arg repo "$REPO_RE" --argjson apiNums "$API_MATCH_NUMBERS" '[.[] | + select(((.body // "") | + test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or + test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b")) or + (.number as $num | $apiNums | index($num) != null))]' \ + /tmp/gh-aw/agent/closed-unmerged-fix-prs.json \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json + ``` -- If an **open** fix PR already refs this issue (a) OR already fixes the same rooting - `Type.Member` (b) → `skipped: leak already being fixed` and stop (or, if `issue_number` was - explicit, just stop). Also double-check the leak isn't already fixed on `main` (Step 8 gate). +- If `jq 'length' merged-issue-fix-prs.json` is greater than `0` → that merged PR literally + carries `Fixes #` / `Refs: #` for THIS issue — record + `skipped: equivalent fix already merged via # to ` and stop. For automatic + selection, move to the next oldest issue; for explicit `issue_number`, stop the run. +- If `merged-api-fix-prs.tsv` has at least one row, stop with + `skipped: canonical API already fixed via # to `. The trusted gate cannot + independently prove an agent-authored different-mechanism assertion, so same-API overrides + are not accepted. +- If an **open** fix PR already refs this issue (b) → `skipped: leak already being fixed` and + stop (or move to the next automatic candidate). Open PRs targeting unrelated release + branches are not authority for the `main` fix lane and do not block. +- If an open fix PR already fixes the same canonical API with no direct issue reference (c) → + stop with `skipped: canonical API already being fixed`. +- Keep `different_mechanism_prs` empty. Any same-API match is a hard stop. - If **3+ closed-unmerged** attempts exist → `skipped: attempt cap reached (3)` and stop. - An issue that is already CLOSED → `skipped: issue closed` (nothing to do). @@ -548,6 +790,131 @@ test "$(cat /tmp/gh-aw/agent/commitcount.txt)" -ge 1 If nothing was committed (count `0`) → `skipped: no commit produced` and stop. +## Step 9.5 — Refresh de-dup immediately before emitting + +The Step 3 merged/open context was captured before the red/green build+test cycle +(Steps 4–9), which can run for up to 120 minutes. Another run can merge or open an equivalent +fix during that window. Refresh the live context here and stop on every current direct +issue-reference or API-only match. This bash call runs in a fresh shell: it MUST reload the +persisted target identity and fail closed if the state is missing or malformed. + +This prompt step refreshes duplicate identity signals; it is not the authoritative mutation +gate. The generated safe-output job independently re-fetches live metadata, the closed-attempt +count, and scoped effective-revert state immediately before Process Safe Outputs. Any direct +issue-reference or same-API match blocks `create_pull_request`; no mechanism decision is +persisted or accepted as an override. + +```bash +set -euo pipefail +STATE=/tmp/gh-aw/agent/dedup-state.json +test -s "$STATE" +N=$(jq -er '.issue_number | select(type == "number" and . > 0 and floor == .)' "$STATE") +API=$(jq -er '.api | select(type == "string" and test("^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$"))' "$STATE") +STATE_REPOSITORY=$(jq -er '.repository | select(type == "string" and length > 0)' "$STATE") +test "$STATE_REPOSITORY" = "$GITHUB_REPOSITORY" +REPO_RE=$(printf '%s' "$STATE_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') +jq -e '.different_mechanism_prs == []' "$STATE" > /dev/null + +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName,mergedAt,url \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json; then + echo "ERROR: final re-check 'gh pr list --state merged [leak-fix]' failed — aborting rather than risk a duplicate PR (fail-closed)." >&2 + exit 1 +fi +FINAL_MERGED_COUNT=$(jq 'length' /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json) +if test "$FINAL_MERGED_COUNT" -ge 1000; then + echo "ERROR: final merged [leak-fix] search reached the 1000-result ceiling — aborting because the de-dup history may be truncated." >&2 + exit 1 +fi +jq '[.[] | + select(.mergedAt != null) | + select(.title | startswith("[leak-fix] ")) | + select(.baseRefName == "main" or .baseRefName == "inflight/current")]' \ + /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs.json + +jq -r '.[] | ["_", .number, .baseRefName, .title] | @tsv' \ + /tmp/gh-aw/agent/final-merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs.tsv +# The shared helper enforces the same aggregate query budget as the earlier discovery pass. +pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/final-merged-leak-fix-prs.tsv \ + -OutputPath /tmp/gh-aw/agent/final-merged-revert-prs.json +pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -MergedFixTsvPath /tmp/gh-aw/agent/final-merged-leak-fix-prs.tsv \ + -MergedRevertsJsonPath /tmp/gh-aw/agent/final-merged-revert-prs.json \ + -OutputPath /tmp/gh-aw/agent/final-reverted-fix-pr-numbers.txt +if test -s /tmp/gh-aw/agent/final-reverted-fix-pr-numbers.txt; then + jq --rawfile reverted /tmp/gh-aw/agent/final-reverted-fix-pr-numbers.txt ' + ($reverted | split("\n") | map(select(length > 0) | tonumber)) as $numbers | + map(select(.number as $number | $numbers | index($number) | not)) + ' /tmp/gh-aw/agent/final-merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs.filtered.json + cat /tmp/gh-aw/agent/final-merged-leak-fix-prs.filtered.json \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs.json +fi + +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | + select((.body // "") | + test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or + test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/final-merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/final-merged-issue-fix-prs.json +jq -r '.[] | [.number, .title] | @tsv' \ + /tmp/gh-aw/agent/final-merged-leak-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE; do + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + test "$PR_API" = "$API" && printf 'merged\t%s\t%s\n' "$PR" "$TITLE" + done > /tmp/gh-aw/agent/final-merged-api-matches.tsv + +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName \ + > /tmp/gh-aw/agent/final-open-fix-prs-raw.json; then + echo "ERROR: final re-check 'gh pr list --state open [leak-fix]' failed — aborting rather than risk a duplicate PR (fail-closed)." >&2 + exit 1 +fi +FINAL_OPEN_COUNT=$(jq 'length' /tmp/gh-aw/agent/final-open-fix-prs-raw.json) +if test "$FINAL_OPEN_COUNT" -ge 1000; then + echo "ERROR: final open [leak-fix] search reached the 1000-result ceiling — aborting because the de-dup set may be truncated." >&2 + exit 1 +fi +jq '[.[] | + select(.baseRefName == "main" or .baseRefName == "inflight/current") | + select(.title | startswith("[leak-fix] "))]' \ + /tmp/gh-aw/agent/final-open-fix-prs-raw.json \ + > /tmp/gh-aw/agent/final-open-fix-prs.json +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | + select((.body // "") | + test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or + test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/final-open-fix-prs.json \ + > /tmp/gh-aw/agent/final-open-issue-fix-prs.json +jq -r '.[] | [.number, .title] | @tsv' /tmp/gh-aw/agent/final-open-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE; do + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) + test "$PR_API" = "$API" && printf 'open\t%s\t%s\n' "$PR" "$TITLE" + done > /tmp/gh-aw/agent/final-open-api-matches.tsv + +cat /tmp/gh-aw/agent/final-merged-api-matches.tsv \ + /tmp/gh-aw/agent/final-open-api-matches.tsv \ + | sort -u > /tmp/gh-aw/agent/final-api-matches.tsv + +echo "final refresh: merged issue-ref=$(jq 'length' /tmp/gh-aw/agent/final-merged-issue-fix-prs.json) open issue-ref=$(jq 'length' /tmp/gh-aw/agent/final-open-issue-fix-prs.json)" +echo "all live same-API matches (all are blocking):"; cat /tmp/gh-aw/agent/final-api-matches.tsv +``` + +- If either direct issue-reference count is greater than `0`, stop: a direct reference is + always an unconditional duplicate. +- If `final-api-matches.tsv` is non-empty, stop: every same-API match is an unconditional + duplicate because no independent trusted proof establishes a different mechanism. +- Do not rely on `exit 1` here as enforcement. Even if you route around a failed tool call or + continue anyway, the safe-output mutation-boundary step independently re-fetches the live + lists and closed-attempt count and blocks creation fail-closed. + ## Step 10 — Emit the draft `[leak-fix]` PR (Track A) > **Dry-run gate:** if `dry_run == "true"`, do NOT emit. Print `DRY RUN — would open PR` @@ -596,6 +963,7 @@ control is collected. ## Scope Managed cross-platform change → all platforms. No public API change (or: list the PublicAPI.Unshipped.txt entries added). + ``` Before emitting, re-read your body and confirm the `Target branch:` line says `main` and a