diff --git a/.github/docs/issue-triage.md b/.github/docs/issue-triage.md index cb0316d6f088..7819cc5e3f2e 100644 --- a/.github/docs/issue-triage.md +++ b/.github/docs/issue-triage.md @@ -678,7 +678,11 @@ Use `staged=false` only for intentional application; `/issue triage` comments are apply-mode commands. New workflows are not dispatchable until recognized on the default branch, so local compilation is not end-to-end hosted validation. -After source changes, regenerate only this workflow: +After source changes, regenerate only this workflow. Shared import frontmatter +changes (including `shared/pat_pool.md`) also require recompiling dependent locks +against the final merged imports, even when this workflow's source is unchanged. +Otherwise activation rejects the stale lock with `E009 CONFIG_HASH_MISMATCH` +before inference or publication. ```bash gh aw compile issue-triage --strict --no-check-update diff --git a/.github/docs/trigger-azdo-pipeline-setup.md b/.github/docs/trigger-azdo-pipeline-setup.md index 823b30723cec..80ebef6fcab3 100644 --- a/.github/docs/trigger-azdo-pipeline-setup.md +++ b/.github/docs/trigger-azdo-pipeline-setup.md @@ -206,6 +206,19 @@ Automated CI-fix PR validation is implemented separately by revalidation, dedupe reads, queue POSTs, retries, and reconciliation. The ten-minute job timeout therefore preserves a real three-minute reserve for summaries and an explicit failed outcome instead of a hard cancellation; +- uses exact-file, non-cone sparse checkout of the standalone dispatcher script + at the trusted `github.sha`, keeping the pinned checkout action, shallow fetch, + and disabled credential persistence. This avoids materializing unrelated + repository files; it does not guarantee checkout finishes within the deadline + or establish production checkout performance; +- keeps the validated caller budget separate from the effective remaining + budget. If preparation consumes the deadline, startup fails explicitly with + `[dispatcher-budget-exhausted]`, records the preparation/deadline stage in the + job summary, and states that no queue POST was attempted before any PR + discovery, authentication, HTTP request, or retry sleep. An expired deadline + is a failure even when no eligible work exists; it is never restarted after + checkout. Checkout or startup exceeding the hard job timeout can still cancel + the job before the script can report this diagnostic; - caps every HTTP timeout and retry sleep to the remaining shared budget. If it expires, no new requests start, completed results remain visible, all unprocessed PR/pipeline work is marked failed, and an ambiguous one-time POST diff --git a/.github/scripts/Queue-CiFixAzdoValidation.Tests.ps1 b/.github/scripts/Queue-CiFixAzdoValidation.Tests.ps1 index 937b6c749b29..4c4f527f481e 100644 --- a/.github/scripts/Queue-CiFixAzdoValidation.Tests.ps1 +++ b/.github/scripts/Queue-CiFixAzdoValidation.Tests.ps1 @@ -200,12 +200,18 @@ Describe 'Test-CiFixPrFingerprint' { } Describe 'trusted workflow configuration' { - It 'pins checkout to the reviewed v7.0.1 commit in the id-token job' { + It 'pins checkout to the reviewed v7.0.1 commit in the id-token job with line endings' -ForEach @( + @{ LineEnding = 'LF'; NewLine = "`n" } + @{ LineEnding = 'CRLF'; NewLine = "`r`n" } + ) { $workflow = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot '../workflows/ci-fix-azdo-validation.yml') + $workflow = $workflow -replace '\r?\n', $NewLine $workflow | Should -Match 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\.0\.1' $workflow | Should -Match 'ref: \$\{\{ github\.sha \}\}' $workflow | Should -Not -Match 'github\.event\.pull_request\.base\.sha' + $workflow | Should -Match 'fetch-depth: 1' + $workflow | Should -Match '(?m)^\s+sparse-checkout: \|\r?\n\s+/\.github/scripts/Queue-CiFixAzdoValidation\.ps1\r?\n\s+sparse-checkout-cone-mode: false\r?$' $workflow | Should -Match 'persist-credentials: false' $workflow | Should -Match 'id-token: write' $workflow | Should -Match 'timeout-minutes: 10' @@ -880,7 +886,7 @@ Describe 'Invoke-AzdoPipelineQueue retry safety' { $script:TransientHttpStatusCodes = @(408, 429, 500, 502, 503, 504) $script:MaxHttpAttempts = 4 $script:RetryBaseDelaySeconds = 2 - $script:DispatcherBudgetSeconds = 480 + $script:EffectiveDispatcherBudgetSeconds = 480 $script:DispatcherStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $script:DispatcherBudgetPrefix = '[dispatcher-budget-exhausted]' foreach ($definition in $FunctionDefinitions) { @@ -1517,6 +1523,121 @@ Describe 'event payload validation' { } } +Describe 'entrypoint dispatcher deadline' { + BeforeEach { + $script:oldStepSummary = $env:GITHUB_STEP_SUMMARY + $summaryPath = Join-Path $TestDrive "deadline-summary-$([Guid]::NewGuid().ToString('N')).md" + $env:GITHUB_STEP_SUMMARY = $summaryPath + $eventPath = Join-Path $TestDrive 'deadline-event.json' + $fixturePath = Join-Path $TestDrive 'deadline-fixture.json' + New-TestEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $eventPath + New-TestPullRequestFixture -PullRequests @((New-TestPullRequest)) | + ConvertTo-Json -Depth 20 | + Set-Content -LiteralPath $fixturePath + + Mock Invoke-RestMethod { throw 'HTTP, OIDC, and Azure requests must not run.' } + Mock Invoke-WebRequest { throw 'HTTP requests must not run.' } + Mock Start-Sleep { throw 'Sleep must not run.' } + } + + AfterEach { + $env:GITHUB_STEP_SUMMARY = $script:oldStepSummary + } + + It 'fails explicitly before external work when preparation exhausted the deadline ()' -ForEach @( + @{ Mode = 'live discovery'; UseFixture = $false; EmptyFixture = $false } + @{ Mode = 'eligible offline fixture'; UseFixture = $true; EmptyFixture = $false } + @{ Mode = 'no eligible offline work'; UseFixture = $true; EmptyFixture = $true } + ) { + if ($EmptyFixture) { + [pscustomobject]@{ pullRequests = @() } | + ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $fixturePath + } + $parameters = @{ + EventPath = $eventPath + Repository = 'dotnet/maui' + EventName = 'pull_request_target' + DispatcherBudgetSeconds = 420 + DispatcherDeadlineUnixSeconds = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 60 + } + if ($UseFixture) { + $parameters.PullRequestsFixturePath = $fixturePath + $parameters.DryRun = $true + } + + { & $scriptPath @parameters } | + Should -Throw '*dispatcher-budget-exhausted*preparation*No queue POST was attempted.*' + + $summary = Get-Content -Raw -LiteralPath $summaryPath + $summary | Should -Match '\[dispatcher-budget-exhausted\]' + $summary | Should -Match 'Stage: preparation / dispatcher deadline' + $summary | Should -Match 'Result: failed' + $summary | Should -Match 'before PR discovery or authentication' + $summary | Should -Match 'No queue POST was attempted\.' + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + Should -Invoke Start-Sleep -Times 0 -Exactly + } + + It 'returns a nonzero process exit with the preparation diagnostic for an expired deadline' { + $output = & pwsh -NoLogo -NoProfile -File $scriptPath ` + -EventPath $eventPath ` + -Repository dotnet/maui ` + -EventName pull_request_target ` + -PullRequestsFixturePath $fixturePath ` + -DispatcherBudgetSeconds 420 ` + -DispatcherDeadlineUnixSeconds ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 60) ` + -DryRun 2>&1 + + $LASTEXITCODE | Should -Not -Be 0 + $diagnostic = $output -join [Environment]::NewLine + $diagnostic | Should -Match '\[dispatcher-budget-exhausted\]' + $diagnostic | Should -Match 'preparation' + $diagnostic | Should -Not -Match 'variable cannot be validated' + (Get-Content -Raw -LiteralPath $summaryPath) | + Should -Match 'No queue POST was attempted\.' + } + + It 'retains caller validation that rejects a zero budget before writing a startup summary' { + { + & $scriptPath ` + -EventPath $eventPath ` + -Repository dotnet/maui ` + -EventName pull_request_target ` + -DispatcherBudgetSeconds 0 ` + -DispatcherDeadlineUnixSeconds ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 60) + } | Should -Throw "*'DispatcherBudgetSeconds'*" + + Test-Path -LiteralPath $summaryPath | Should -BeFalse + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + Should -Invoke Start-Sleep -Times 0 -Exactly + } + + It 'reaches verified offline payload generation with a valid future deadline' { + $output = & $scriptPath ` + -EventPath $eventPath ` + -Repository dotnet/maui ` + -EventName pull_request_target ` + -PullRequestsFixturePath $fixturePath ` + -DispatcherBudgetSeconds 420 ` + -DispatcherDeadlineUnixSeconds ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + 120) ` + -DryRun + + $results = @($output -join [Environment]::NewLine | ConvertFrom-Json -Depth 20) + $results.Count | Should -Be 3 + @($results.Outcome | Select-Object -Unique) | Should -Be @('dry-run') + @($results.DefinitionId | Sort-Object) | Should -Be @(302, 313, 314) + @($results.Request.sourceVersion | Select-Object -Unique) | + Should -Be @('2222222222222222222222222222222222222222') + (Get-Content -Raw -LiteralPath $summaryPath) | Should -Match 'dotnet/maui#123' + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + Should -Invoke Start-Sleep -Times 0 -Exactly + } +} + Describe 'entrypoint verification failure routing' { BeforeEach { $script:oldStepSummary = $env:GITHUB_STEP_SUMMARY diff --git a/.github/scripts/Queue-CiFixAzdoValidation.ps1 b/.github/scripts/Queue-CiFixAzdoValidation.ps1 index c279109c44fb..e87041816ddf 100644 --- a/.github/scripts/Queue-CiFixAzdoValidation.ps1 +++ b/.github/scripts/Queue-CiFixAzdoValidation.ps1 @@ -25,7 +25,7 @@ $secondsUntilDeadline = if ($DispatcherDeadlineUnixSeconds -gt 0) { else { $DispatcherBudgetSeconds } -$script:DispatcherBudgetSeconds = [Math]::Max( +$script:EffectiveDispatcherBudgetSeconds = [Math]::Max( 0, [Math]::Min($DispatcherBudgetSeconds, $secondsUntilDeadline)) $script:DispatcherStopwatch = [System.Diagnostics.Stopwatch]::StartNew() @@ -36,7 +36,7 @@ function Get-DispatcherElapsedSeconds { } function Get-DispatcherRemainingSeconds { - return [Math]::Max(0, $script:DispatcherBudgetSeconds - (Get-DispatcherElapsedSeconds)) + return [Math]::Max(0, $script:EffectiveDispatcherBudgetSeconds - (Get-DispatcherElapsedSeconds)) } function Test-IsDispatcherBudgetException { @@ -1001,6 +1001,21 @@ function Invoke-CiFixQueueWork { return $workResults.ToArray() } +if ([Math]::Floor((Get-DispatcherRemainingSeconds)) -lt 1) { + $preparationFailure = "$($script:DispatcherBudgetPrefix) Dispatcher deadline exhausted during preparation (trusted checkout / PowerShell startup), before PR discovery or authentication. No queue POST was attempted." + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + $lines = @( + '## Automated CI-fix Azure DevOps validation', + '', + '- Stage: preparation / dispatcher deadline', + '- Result: failed', + "- Details: $preparationFailure" + ) + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value ($lines -join [Environment]::NewLine) + } + throw $preparationFailure +} + if ([string]::IsNullOrWhiteSpace($EventPath) -or -not (Test-Path -LiteralPath $EventPath -PathType Leaf)) { throw 'GITHUB_EVENT_PATH must identify a supported GitHub event payload file.' } diff --git a/.github/scripts/Register-CiFixSafeOutputExpectation.Tests.ps1 b/.github/scripts/Register-CiFixSafeOutputExpectation.Tests.ps1 index 5c1586a877e1..a084c29727c9 100644 --- a/.github/scripts/Register-CiFixSafeOutputExpectation.Tests.ps1 +++ b/.github/scripts/Register-CiFixSafeOutputExpectation.Tests.ps1 @@ -1,6 +1,34 @@ #!/usr/bin/env pwsh #Requires -Modules Pester +Describe 'CI-fixer safe-output helper availability' { + It 'keeps environment guidance consistent with the existing helper allowlist' -ForEach @( + @{ Workflow = 'ci-status-fix.md' } + @{ Workflow = 'ci-status-fix-net11.md' } + ) { + $workflowPath = Join-Path (Split-Path $PSScriptRoot) "workflows/$Workflow" + $source = Get-Content -Raw -LiteralPath $workflowPath + $bashAllowlist = [regex]::Match($source, '(?m)^ bash: \[(?.*)\]\r?$') + $environment = [regex]::Match( + $source, + '(?ms)^## Environment constraints\r?\n(?.*?)(?=^## )') + + $bashAllowlist.Success | Should -BeTrue + $bashAllowlist.Groups['commands'].Value | Should -Match '"pwsh"' + $bashAllowlist.Groups['commands'].Value | Should -Not -Match '"(?:gh|python)"' + $environment.Success | Should -BeTrue + $guidance = $environment.Groups['guidance'].Value + $guidance | Should -Not -Match 'no\s+`pwsh`' + $guidance | Should -Match '`pwsh` is available' + $guidance | Should -Match 'Register-CiFixSafeOutputExpectation\.ps1' + $guidance | Should -Match 'Test-CiFixTransport\.ps1' + $guidance | Should -Match 'Hard Rule 11' + $guidance | Should -Match 'Step 5\.6' + $guidance | Should -Match 'no `gh`, no `python`' + $guidance | Should -Match 'Use `curl` \+ `jq` for all API calls' + } +} + Describe 'Register-CiFixSafeOutputExpectation' { BeforeEach { $script:outputDirectory = Join-Path $TestDrive "expectations-$([Guid]::NewGuid().ToString('N'))" diff --git a/.github/workflows/ci-fix-azdo-validation.yml b/.github/workflows/ci-fix-azdo-validation.yml index 5ed011fdd0b8..ee490666a86e 100644 --- a/.github/workflows/ci-fix-azdo-validation.yml +++ b/.github/workflows/ci-fix-azdo-validation.yml @@ -43,6 +43,9 @@ jobs: ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false + sparse-checkout: | + /.github/scripts/Queue-CiFixAzdoValidation.ps1 + sparse-checkout-cone-mode: false - name: Queue Azure DevOps PR validation shell: pwsh diff --git a/.github/workflows/ci-status-fix-net11.lock.yml b/.github/workflows/ci-status-fix-net11.lock.yml index de7366bf296f..f40280e84ca5 100644 --- a/.github/workflows/ci-status-fix-net11.lock.yml +++ b/.github/workflows/ci-status-fix-net11.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d683d78d7591e4c6ffd537540bf44fb64e9fba8ce2161051d4c5d4c87069ffaf","body_hash":"403c30218073a3acd2ea3bc73e7608ec7801b89ab53c93d6a4ce279e0901db2f","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d683d78d7591e4c6ffd537540bf44fb64e9fba8ce2161051d4c5d4c87069ffaf","body_hash":"95dc5f5d929a9bae2e8a5dd77f919d305e0332be2051af91a4aa9d778ff0f44a","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} # 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":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"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.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"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.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/ci-status-fix-net11.md b/.github/workflows/ci-status-fix-net11.md index 87384dce0c43..070a69f76cfb 100644 --- a/.github/workflows/ci-status-fix-net11.md +++ b/.github/workflows/ci-status-fix-net11.md @@ -2205,8 +2205,13 @@ These look like permission errors but are physical: - OData `$top` must be encoded as `%24top` in URLs. - Each bash call runs in a fresh subshell. Persist state to `/tmp/gh-aw/agent/`. -- Bash allowlist per frontmatter `tools.bash`: no `gh`, no `pwsh`, no - `python`. Use `curl` + `jq` for all API calls. +- Bash allowlist per frontmatter `tools.bash`: no `gh`, no `python`. + Use `curl` + `jq` for all API calls. +- `pwsh` is available for deterministic safe-output expectation registration + (`.github/scripts/Register-CiFixSafeOutputExpectation.ps1`) and transport + validation/registration (`.github/scripts/Test-CiFixTransport.ps1`). Run these + helpers as required by Hard Rule 11 and Step 5.6; never bypass their fail-closed + registration or transport checks. ## Output discipline diff --git a/.github/workflows/ci-status-fix.lock.yml b/.github/workflows/ci-status-fix.lock.yml index 9f3f2e891444..d8286e6226c3 100644 --- a/.github/workflows/ci-status-fix.lock.yml +++ b/.github/workflows/ci-status-fix.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0a2fab5e8553740e2264d6db88e423885eb49c8aeff820d870d6b1c44b3bf1ba","body_hash":"f02e70a8b091b54d4f7fc31fc3cce44993c9eec8444b727d2857c76d19e29739","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0a2fab5e8553740e2264d6db88e423885eb49c8aeff820d870d6b1c44b3bf1ba","body_hash":"ba9a142d27c0d34a4625ce6c45e0186fee85a6c98defd81a0934f74acf6eb126","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} # 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":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"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.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"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.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/ci-status-fix.md b/.github/workflows/ci-status-fix.md index f0a6cd4db73f..61f8a0c943b2 100644 --- a/.github/workflows/ci-status-fix.md +++ b/.github/workflows/ci-status-fix.md @@ -2185,8 +2185,13 @@ These look like permission errors but are physical: - OData `$top` must be encoded as `%24top` in URLs. - Each bash call runs in a fresh subshell. Persist state to `/tmp/gh-aw/agent/`. -- Bash allowlist per frontmatter `tools.bash`: no `gh`, no `pwsh`, no - `python`. Use `curl` + `jq` for all API calls. +- Bash allowlist per frontmatter `tools.bash`: no `gh`, no `python`. + Use `curl` + `jq` for all API calls. +- `pwsh` is available for deterministic safe-output expectation registration + (`.github/scripts/Register-CiFixSafeOutputExpectation.ps1`) and transport + validation/registration (`.github/scripts/Test-CiFixTransport.ps1`). Run these + helpers as required by Hard Rule 11 and Step 5.6; never bypass their fail-closed + registration or transport checks. ## Output discipline diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index 93820db10242..13332f77c5cc 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d2c371bf924eb254532ec722f31d817ebdb42fd4e5565b4952a40e09e465e0d1","body_hash":"836bfb1ec50ea6b63ad7686e9377512eac62162c211080befa84a3b9d8ee2982","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"eb116514f337eb247c8767becceaefdd9cfb69da65044098b5cdc510e7c2d153","body_hash":"836bfb1ec50ea6b63ad7686e9377512eac62162c211080befa84a3b9d8ee2982","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-6.1-sol","engine_versions":{"copilot":"1.0.79"}} # 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/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"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":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"skills":[".github/skills/issue-triage-labels"],"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.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"}]} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs index d4250379a386..71c17fd42b96 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs @@ -169,13 +169,28 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc return; } - if (!handler.PlatformView.IsLaidOut || handler.PlatformView.IsLayoutRequested) + // A view that has been laid out at least once has geometry worth scrolling against: the + // platform clamps the offsets to the last completed layout. IsLayoutRequested must not gate + // this decision - see WaitToScrollOnLayout for why waiting on it never ends. + if (!handler.PlatformView.IsLaidOut) + { + WaitToScrollOnLayout(handler, request); + + return; + } + + ServeScrollTo(handler, request); + } + + // Serves the request against the platform view's current geometry, without consulting the layout + // flags: the platform clamps the offsets to what it can measure, so serving is safe - and, unlike + // deferring, terminating - even for a view that has never been laid out. + static void ServeScrollTo(IScrollViewHandler handler, ScrollToRequest request) + { + var context = handler.PlatformView.Context; + + if (context == null) { - handler.PlatformView.Post(() => - { - if (handler.IsConnected()) - MapRequestScrollTo(handler, scrollView, args); - }); return; } @@ -192,6 +207,93 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc }); } + const int LayoutWaitTimeoutMillis = 4000; + + // Waits for the platform view's first layout, then serves 'request' - once, and only once. + // + // Event-driven and bounded, unlike the flag-polled re-post it replaces: ViewRootImpl swallows + // requestLayout() while it is handling a layout-inside-layout request (the + // mHandlingLayoutInLayoutRequest guard), which leaves IsLayoutRequested set with no traversal + // ever scheduled again. A retry loop gated on that flag therefore spins at full looper + // throughput - the livelock - where an event listener cannot, because it runs exactly once per + // traversal and stops being registered at all as soon as it fires. + // + // Deferral is bounded so that it can never park a request the way the retry loop did: whichever + // signal arrives first serves the request, and a view that never lays out is served by the + // timeout. Nothing re-enters MapRequestScrollTo from here, so a deferred request cannot re-arm + // the wait behind itself. + static void WaitToScrollOnLayout(IScrollViewHandler handler, ScrollToRequest request) + { + var platformView = handler.PlatformView; + var observer = platformView.ViewTreeObserver; + var waiter = new LayoutWaiter(platformView, handler, request); + + if (observer is { IsAlive: true }) + { + observer.AddOnGlobalLayoutListener(waiter); + } + + platformView.PostDelayed(waiter.OnTimeout, LayoutWaitTimeoutMillis); + + // Arm the layout this is waiting for. A view that has never been laid out normally has a + // traversal on its way already; a latched IsLayoutRequested flag is not one, so it is left + // alone - requesting again would only re-latch it, which is how the retry loop got stuck. + if (!platformView.IsLayoutRequested) + { + platformView.RequestLayout(); + } + } + + sealed class LayoutWaiter : Java.Lang.Object, ViewTreeObserver.IOnGlobalLayoutListener + { + readonly MauiScrollView _view; + readonly IScrollViewHandler _handler; + readonly ScrollToRequest _request; + int _fired; + + public LayoutWaiter(MauiScrollView view, IScrollViewHandler handler, ScrollToRequest request) + { + _view = view; + _handler = handler; + _request = request; + } + + public void OnGlobalLayout() + { + // Fires for every layout of the window, not just ours: the wait is only satisfied once + // the view that deferred has itself been laid out. + if (_view.IsLaidOut) + { + Fire(); + } + } + + public void OnTimeout() => + Fire(); + + void Fire() + { + // One-shot: the timeout is still pending when the layout event wins the race (and vice + // versa), and the request must be served exactly once either way. + if (System.Threading.Interlocked.Exchange(ref _fired, 1) != 0) + { + return; + } + + var observer = _view.ViewTreeObserver; + + if (observer is { IsAlive: true }) + { + observer.RemoveOnGlobalLayoutListener(this); + } + + if (_handler.IsConnected()) + { + ServeScrollTo(_handler, _request); + } + } + } + /* Problem 1: Android treats Padding differently than what we want for MAUI; Padding creates space _around_ the scrollable area, rather than padding the content inside of it. diff --git a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs index 3f1763ba184c..ff868e0bac9d 100644 --- a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.Android.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Android.Views; using Android.Widget; @@ -315,6 +316,222 @@ public async Task VerticalandHorizontalScrollbarFadingInitializesCorrectlyOnBoth Assert.Equal(expected, result); } + // Mirrors ScrollViewHandler.LayoutWaitTimeoutMillis (private there). The fallback cannot fire + // before its own bound, so a request completing inside it was served by the layout event and + // one completing at/after it can only have been served by the fallback - which is how the two + // tests below tell the two waiter outcomes apart instead of both accepting "it eventually ran". + // Keep in sync with the handler. + static readonly TimeSpan LayoutWaitBound = TimeSpan.FromSeconds(4); + + // Regression test for the ScrollToAsync layout wait (MapRequestScrollTo). + // Two waiter outcomes have to be covered, because deferring is only correct if it still + // terminates and still serves exactly once: + // * the view is not laid out yet when the request arrives, and its first layout must + // serve the pending request - once, inside the bound (so: by the event, not the timer), + // and not again when the fallback later fires; + // * ordinary requests arriving while the view sits in IsLaidOut && IsLayoutRequested - + // the state the flag-polled retry could never leave - are served instead of parked. + // The latched state is armed with platform APIs only: a GlobalLayout callback issues its + // RequestLayout() inside the same performTraversals call stack, where ViewRootImpl's + // mHandlingLayoutInLayoutRequest guard swallows the follow-up traversal scheduling. + [Fact] + [Category(TestCategory.ScrollView)] + public async Task ScrollToRequestDeferredBeforeFirstLayoutIsServedByThatLayout() + { + var scrollView = new ScrollFinishedCountingScrollViewStub(); + var handler = await InvokeOnMainThreadAsync(() => CreateHandler(scrollView)); + var platformView = handler.PlatformView; + + ViewGroup windowRoot = null!; + FrameLayout holder = null!; + var observer = (global::Android.Views.ViewTreeObserver?)null; + global::System.EventHandler? layoutHook = null; + var armHook = false; + + try + { + // Not laid out yet, but attached to a live window: posts and the ViewTreeObserver + // work, while no layout of this view can resolve the wait without our say-so. + await InvokeOnMainThreadAsync(() => + { + windowRoot = (ViewGroup)MauiContext.Context!.GetActivity()! + .FindViewById(global::Android.Resource.Id.Content)!; + + holder = new FrameLayout(MauiContext.Context); + windowRoot.AddView(holder, new ViewGroup.LayoutParams(100, 100)); + holder.AddView(platformView, new FrameLayout.LayoutParams(100, 100)); + platformView.Visibility = ViewStates.Gone; + }); + + await InvokeOnMainThreadAsync(() => + { + Assert.False(platformView.IsLaidOut); + + ScrollViewHandler.MapRequestScrollTo(handler, scrollView, new ScrollToRequest(0, 30, true)); + + // Deferred, not silently dropped, and not served against geometry that is not there yet. + Assert.Equal(0, scrollView.ScrollFinishedCount); + }); + + // Make the view real and let the first layout pass happen under the latched flag. + await InvokeOnMainThreadAsync(() => + { + observer = platformView.ViewTreeObserver; + + if (observer is { IsAlive: true }) + { + layoutHook = (_, _) => + { + if (armHook) + { + platformView.RequestLayout(); + } + }; + + observer.GlobalLayout += layoutHook; + } + + platformView.Visibility = ViewStates.Visible; + armHook = true; + platformView.RequestLayout(); + }); + + // The deferred request is served by that layout - the only thing that can serve it. + // Timing matters here: the fallback cannot fire before its own bound, so completing + // inside it proves the layout event served the request, not the timeout (which the + // other test covers). + var servedAt = DateTime.UtcNow; + var deadline = servedAt.AddSeconds(6); + + while (DateTime.UtcNow < deadline && scrollView.ScrollFinishedCount == 0) + { + await Task.Delay(50); + } + + var servedIn = DateTime.UtcNow - servedAt; + + Assert.True(await InvokeOnMainThreadAsync(() => platformView.IsLaidOut)); + Assert.Equal(1, scrollView.ScrollFinishedCount); + Assert.True(servedIn < LayoutWaitBound, $"{nameof(ScrollToRequestDeferredBeforeFirstLayoutIsServedByThatLayout)}: served by the fallback timer ({servedIn.TotalMilliseconds:F0} ms), not by the layout event."); + + // And a request arriving in the latched state is served straight away: polling the + // layout flags is what left the previous retry parked here indefinitely. + await InvokeOnMainThreadAsync(() => + { + Assert.True(platformView.IsLayoutRequested); + + ScrollViewHandler.MapRequestScrollTo(handler, scrollView, new ScrollToRequest(0, 60, true)); + Assert.Equal(2, scrollView.ScrollFinishedCount); + }); + + // A fallback timer armed while the request was waiting must never serve a second time. + await Task.Delay(4500); + Assert.Equal(2, scrollView.ScrollFinishedCount); + } + finally + { + await InvokeOnMainThreadAsync(() => + { + armHook = false; + + if (observer is { IsAlive: true } && layoutHook is not null) + { + observer.GlobalLayout -= layoutHook; + } + + windowRoot?.RemoveView(holder); + }); + } + } + + // The same request for a view that is never laid out still has to terminate: within the wait's bound, + // exactly once, and without re-arming. A view that is GONE once attached is the state where no + // layout of that view can ever resolve the wait, which is the state the retry loop it replaces could + // never leave. + [Fact] + [Category(TestCategory.ScrollView)] + public async Task ScrollToRequestForNeverLaidOutViewTerminatesOnceWithoutRetrying() + { + var scrollView = new ScrollFinishedCountingScrollViewStub(); + var handler = await InvokeOnMainThreadAsync(() => CreateHandler(scrollView)); + var platformView = handler.PlatformView; + + ViewGroup windowRoot = null!; + FrameLayout holder = null!; + DateTime requestedAt = default; + + try + { + await InvokeOnMainThreadAsync(async () => + { + // Gone, but attached to a live window: posts run and the ViewTreeObserver is alive, yet + // Android never lays this view out, so the wait can only end on its own terms. + windowRoot = (ViewGroup)MauiContext.Context!.GetActivity()! + .FindViewById(global::Android.Resource.Id.Content)!; + + holder = new FrameLayout(MauiContext.Context); + windowRoot.AddView(holder, new ViewGroup.LayoutParams(10, 10)); + holder.AddView(platformView, new FrameLayout.LayoutParams(10, 10)); + platformView.Visibility = ViewStates.Gone; + + // Let the attach traversal settle so the wait is armed after it, not during it. + var settled = new TaskCompletionSource(); + platformView.Post(new Java.Lang.Runnable(() => settled.TrySetResult(true))); + await settled.Task; + + Assert.False(platformView.IsLaidOut); + + // Measured from here: this is when the handler would arm its fallback. + requestedAt = DateTime.UtcNow; + + ScrollViewHandler.MapRequestScrollTo(handler, scrollView, new ScrollToRequest(0, 30, true)); + + // Not served against geometry that does not exist yet. + Assert.Equal(0, scrollView.ScrollFinishedCount); + }); + + // Off the UI thread, so the looper is free: 8 s covers the wait's own bound with margin. + var deadline = DateTime.UtcNow.AddSeconds(8); + + while (DateTime.UtcNow < deadline && scrollView.ScrollFinishedCount == 0) + { + await Task.Delay(50); + } + + var servedIn = DateTime.UtcNow - requestedAt; + + Assert.False(platformView.IsLaidOut); + Assert.Equal(1, scrollView.ScrollFinishedCount); + + // And it can only have been the fallback that served it: the timer cannot fire before its + // bound, and the view was never laid out, so no layout event existed to serve earlier. + Assert.True(servedIn >= LayoutWaitBound - TimeSpan.FromMilliseconds(500), + $"{nameof(ScrollToRequestForNeverLaidOutViewTerminatesOnceWithoutRetrying)}: served in {servedIn.TotalMilliseconds:F0} ms, before the fallback bound - a layout event served this view, so the timeout branch went untested."); + + // A second timeout period: the request must not be parked again, nor served twice. + await Task.Delay(4500); + Assert.Equal(1, scrollView.ScrollFinishedCount); + } + finally + { + if (windowRoot is not null && holder?.Parent is not null) + { + await InvokeOnMainThreadAsync(() => windowRoot.RemoveView(holder)); + } + } + } + + // ScrollViewStub.ScrollFinished() throws, and the public method cannot be overridden, so the + // interface is re-implemented here to observe how many times a served request reported back. + class ScrollFinishedCountingScrollViewStub : ScrollViewStub, IScrollView + { + int _scrollFinishedCount; + + public int ScrollFinishedCount => Volatile.Read(ref _scrollFinishedCount); + + void IScrollView.ScrollFinished() => Interlocked.Increment(ref _scrollFinishedCount); + } + [Fact] public async Task MauiScrollViewGetsFullHeightInHorizontalOrientation() {