From f556bc5c25a9100f1c2ec557beb5f0e1b9bb511d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:43:16 +0200 Subject: [PATCH 01/68] Run Memory Leak Fixer every 3h instead of every 12h Increase the scheduled cadence of the Memory Leak Fixer agentic workflow from every 12h to every 3h so open [leak-scan] issues get a [leak-fix] PR (and review-feedback responses) turned around faster. Only the fixer changes; the Daily Memory Leak Hunter stays at 12h. Edited the `schedule:` directive in leak-fixer.md and recompiled the lock file with gh aw (v0.80.9): cron "20 */12 * * *" -> "20 */3 * * *". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1257a460-520a-4b7a-91d5-61f39054ea59 --- .github/workflows/leak-fixer.lock.yml | 6 +++--- .github/workflows/leak-fixer.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 82b423ba13de..1b2f54783de2 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":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"9ecc84f8fb1e8a9f80dcb4db783e0e60a50a64b5d7ee7f889469f07d534fb2bf","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -85,8 +85,8 @@ name: "Memory Leak Fixer" on: # permissions: {} # Permissions applied to pre-activation job schedule: - - cron: "20 */12 * * *" - # Friendly format: every 12h (scattered) + - cron: "20 */3 * * *" + # Friendly format: every 3h (scattered) workflow_dispatch: inputs: aw_context: diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index c321becede71..e26c5b6e3cf1 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -36,7 +36,7 @@ imports: environment: copilot-pat-pool on: - schedule: every 12h + schedule: every 3h workflow_dispatch: inputs: issue_number: @@ -152,7 +152,7 @@ safe-outputs: target: "*" # agent supplies the leak PR number required-title-prefix: "[leak-fix]" # Track C only comments on this workflow's own [leak-fix] PRs max: 1 - # Most 12h runs are idle (every open leak already has a [leak-fix] PR). Without this, + # Most 3h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" # issue every idle run. Mirror the hunter, which already opts out. noop: From 00edaf1f0ce2b1fc251b42a7a1cf0492c304271d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:49:22 +0200 Subject: [PATCH 02/68] Change Memory Leak Fixer cadence to every 6h (4x/day) Adjust the fixer schedule from every 3h to every 6h per feedback, so it runs 4 times a day. Recompiled the lock file with gh aw: cron "20 */3 * * *" -> "20 */6 * * *". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1257a460-520a-4b7a-91d5-61f39054ea59 --- .github/workflows/leak-fixer.lock.yml | 6 +++--- .github/workflows/leak-fixer.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 1b2f54783de2..a454c30afb83 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":"9ecc84f8fb1e8a9f80dcb4db783e0e60a50a64b5d7ee7f889469f07d534fb2bf","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"eb3ab9f3c4156e6f870ad3a604c4e52ccfe7b584d5497d6cdfd13779a6fe98d3","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -85,8 +85,8 @@ name: "Memory Leak Fixer" on: # permissions: {} # Permissions applied to pre-activation job schedule: - - cron: "20 */3 * * *" - # Friendly format: every 3h (scattered) + - cron: "20 */6 * * *" + # Friendly format: every 6h (scattered) workflow_dispatch: inputs: aw_context: diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index e26c5b6e3cf1..1af81eae07c7 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -36,7 +36,7 @@ imports: environment: copilot-pat-pool on: - schedule: every 3h + schedule: every 6h workflow_dispatch: inputs: issue_number: @@ -152,7 +152,7 @@ safe-outputs: target: "*" # agent supplies the leak PR number required-title-prefix: "[leak-fix]" # Track C only comments on this workflow's own [leak-fix] PRs max: 1 - # Most 3h runs are idle (every open leak already has a [leak-fix] PR). Without this, + # Most 6h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" # issue every idle run. Mirror the hunter, which already opts out. noop: From b9c61b5ad26e250ab643034927da168ca59ce7f5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:16:02 +0200 Subject: [PATCH 03/68] Add de-dup/auto-close + fixed-on-main guard to leak workflows Beyond the 6h fixer cadence, teach the two agentic memory-leak workflows to stop re-processing leaks that are already fixed on main. Leak Fixer (leak-fixer.md): - New close-issue safe-output (issues:write scoped to the safe-outputs job). - Step 3 gate (d): if a MERGED [leak-fix] PR already covers the same rooting Type.Member (or refs the issue), close the [leak-scan] issue instead of rebuilding MAUI from source. Merged fixes often reference an upstream issue number, so GitHub never auto-closed the scan issue and it was re-picked and rebuilt every run. - Step 6: when the regression test is already green on unpatched main, close the scan issue (already fixed) instead of silently skipping. - Step 10: require Fixes # to be the [leak-scan] number (upstream refs go as Refs:, never a second Fixes:) so the scan issue always auto-closes on merge. Daily Leak Hunter (daily-leak-hunter.md): - Step 2: build a fixed-on-main Type.Member set from merged [leak-fix] PRs and [leak-scan] issues closed as completed; skip re-filing those. Prevents a re-file loop, since the hunter tests the shipped 10.0.0 package where a fixed-on-main leak still reproduces. - Tighten Hard Rule 5 / Step 6 wording: only re-file leaks closed as NOT PLANNED, never those fixed on main. Recompiled both .lock.yml via gh aw compile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1257a460-520a-4b7a-91d5-61f39054ea59 --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 50 ++++++++++++---- .github/workflows/leak-fixer.lock.yml | 50 +++++++++++----- .github/workflows/leak-fixer.md | 62 +++++++++++++++++--- 4 files changed, 129 insertions(+), 35 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 607c75c1913d..a980e61c944b 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"03dd04b37a8427233cf1d403b6d08b394ab52f81e16e1d7d8ce99e22153a0192","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"c6059f80c42d3843e47edf2cf070a3d0c274b5679a5054970deaf85436b74cf1","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 29f656b28127..37926b4a9caa 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -132,11 +132,15 @@ 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 THIS SCANNER's own OPEN issues AND leaks already fixed on `main`.** Before + filing, fetch this workflow's open `[leak-scan]` issues and skip a leak already covered by one + (same rooting API / retention path). ALSO skip any leak whose rooting `Type.Member` is already + fixed on `main` — a merged `[leak-fix]` PR or a `[leak-scan]` issue closed as completed (Step 2 + builds this set). Such leaks still reproduce against the SHIPPED package until the fix ships, + so the empirical test alone can't tell — de-dup is the only guard. 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 **as not planned** (never fixed) + may be re-filed; one whose leak is fixed on `main` may not. 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. @@ -176,6 +180,24 @@ jq -r '.[].title' /tmp/gh-aw/agent/my-open-leakscan.json \ | sort -u \ > /tmp/gh-aw/agent/already-filed-apis.txt echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt + +# ── ALSO skip leaks ALREADY FIXED on main ────────────────────────────────────────────────── +# Your Step 5 confirmation runs against the SHIPPED NuGet package (pinned 10.0.0), where an +# already-MERGED fix has NOT shipped yet — so a leak that is fixed on `main` STILL reproduces +# (goes red) and would be re-filed every run forever. De-dup is the ONLY guard. Build a +# "fixed-on-main" rooting-Type.Member set from (a) every MERGED `[leak-fix]` PR title and +# (b) every `[leak-scan]` issue this scanner closed as COMPLETED (fix landed). +{ + gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ + --limit 200 --json title -q '.[].title' | grep -E '^\[leak-fix\] ' | sed -E 's/^\[leak-fix\] *(Fix +)?//' + gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ + --state closed --label agentic-workflows --limit 300 --json title,stateReason \ + -q '.[] | select(.stateReason == "COMPLETED") | .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] } }' \ + | sort -u \ + > /tmp/gh-aw/agent/fixed-on-main-apis.txt +echo "fixed-on-main (do NOT re-file) rooting APIs:"; cat /tmp/gh-aw/agent/fixed-on-main-apis.txt ``` - A candidate is **OUT** if its rooting `Type.Member` (e.g. `SwipeItemView.Command`, @@ -183,8 +205,15 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt 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 is **ALSO OUT** if its rooting `Type.Member` is in `fixed-on-main-apis.txt` + (already fixed on `main` by a merged `[leak-fix]` PR, or a `[leak-scan]` issue closed as + completed). The shipped-package test in Step 5 will STILL go red for these because the fix + hasn't shipped in a release yet — that is expected. Do NOT re-file: the fix is already on + `main` and will ship. Re-filing just recreates an issue that was deliberately closed. -A candidate whose only prior issue from this scanner is CLOSED may be re-filed. +A candidate whose only prior `[leak-scan]` issue was closed as **not planned** (i.e. never +fixed — wontfix / invalid / duplicate) may be re-filed if it still reproduces. A candidate whose +leak is in `fixed-on-main-apis.txt` (fixed on `main`) must **not** be re-filed. # ===================== RUNTIME LEAK HUNT ===================== @@ -305,10 +334,11 @@ 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 +issue per distinct leak. De-dup each against open `[leak-scan]` issues, against +`fixed-on-main-apis.txt` (Step 2 — never re-file a leak already fixed on `main`), 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. Body (markdown): diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index a454c30afb83..d0424bda4426 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":"eb3ab9f3c4156e6f870ad3a604c4e52ccfe7b584d5497d6cdfd13779a6fe98d3","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a50dcc63c4432c53ac7b3c0fbcd6d90d156a40d3744c8e0cb854b143fce7fbba","body_hash":"164e4b72f756dd75431b0757b47e9e99c37863899a471e83ccb25fd04deb6b6c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -235,24 +235,24 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' + cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' - GH_AW_PROMPT_1915128a947746b1_EOF + GH_AW_PROMPT_54a86f49a2a70409_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' + cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' - Tools: add_comment, create_pull_request, push_to_pull_request_branch, missing_tool, missing_data, noop - GH_AW_PROMPT_1915128a947746b1_EOF + Tools: add_comment, close_issue, create_pull_request, push_to_pull_request_branch, missing_tool, missing_data, noop + GH_AW_PROMPT_54a86f49a2a70409_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' + cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' - GH_AW_PROMPT_1915128a947746b1_EOF + GH_AW_PROMPT_54a86f49a2a70409_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' + cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -294,12 +294,12 @@ jobs: stop immediately and report the limitation rather than spending turns trying to work around it. - GH_AW_PROMPT_1915128a947746b1_EOF + GH_AW_PROMPT_54a86f49a2a70409_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' + cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' {{#runtime-import .github/workflows/leak-fixer.md}} - GH_AW_PROMPT_1915128a947746b1_EOF + GH_AW_PROMPT_54a86f49a2a70409_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -516,15 +516,16 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_782651390b58dac1_EOF' - {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_782651390b58dac1_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_01e0409969dac555_EOF' + {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_01e0409969dac555_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: *. Supports reply_to_id for discussion threading.", + "close_issue": " CONSTRAINTS: Maximum 1 issue(s) can be closed. Target: *.", "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Labels [\"agentic-workflows\" \"perf/memory-leak 💦\"] will be automatically added. Only these labels are allowed: [\"agentic-workflows\" \"perf/memory-leak 💦\"]. PRs will be created as drafts.", "push_to_pull_request_branch": " CONSTRAINTS: Maximum 1 push(es) can be made. The target pull request title must start with \"[leak-fix]\"." }, @@ -555,6 +556,23 @@ jobs: } } }, + "close_issue": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "optionalPositiveInteger": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, "create_pull_request": { "defaultMax": 1, "fields": { @@ -1795,7 +1813,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.blob.core.windows.net,*.githubusercontent.com,*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dev.azure.com,dist.nuget.org,docs.github.com,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 1af81eae07c7..0c34b7b77eca 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -152,6 +152,12 @@ safe-outputs: target: "*" # agent supplies the leak PR number required-title-prefix: "[leak-fix]" # Track C only comments on this workflow's own [leak-fix] PRs max: 1 + # Close a [leak-scan] issue whose leak is ALREADY FIXED on main (Step 3 gate d / Step 6) so it + # isn't re-picked and rebuilt from source every run. The agent supplies the issue number + a + # closing comment linking the merged fix. Grants issues:write only to the safe-output job. + close-issue: + target: "*" + max: 1 # Most 6h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" # issue every idle run. Mirror the hunter, which already opts out. @@ -189,9 +195,11 @@ 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 is already fixed on `main`, open NO PR — close the scan issue instead.** + When your faithful regression test passes on the *unpatched* source, or a **merged** `[leak-fix]` + PR already covers the same rooting `Type.Member` (Step 3 gate d), the leak no longer needs a + fix. Emit a `close-issue` on the `[leak-scan]` issue (AI-attributed comment linking the fix) + and record `skipped: already fixed on main`. Do NOT leave it open to be re-picked and rebuilt. 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 @@ -205,8 +213,8 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch add the missing `-=` / teardown on the unload path. Prefer the minimal, idiomatic change that matches how the surrounding code already hardens similar subscriptions. 6. **One action per run.** Either respond to ONE PR's review (Track C) OR open ONE new draft PR - (Track A/B) — never both, never two of a kind. Honour the de-dup / attempt-cap / already- - addressed gates so you never repeat yourself. + (Track A/B) OR close ONE already-fixed `[leak-scan]` issue (Step 3d / Step 6) — never two. + Honour the de-dup / attempt-cap / already-addressed gates so you never repeat yourself. 7. **AI attribution.** Every PR body and every comment must clearly state it was generated by this workflow. @@ -405,8 +413,30 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in: | jq --arg n "$N" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json +# (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? +# Merged fixes often reference an UPSTREAM issue (e.g. "Fixes #35775") instead of this +# [leak-scan] number, so GitHub never auto-closed this scan issue — leaving it to be +# re-picked and rebuilt from source every run. Detect the merged fix by BOTH the issue-ref +# AND the rooting Type.Member so we can close this issue instead of rebuilding. +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ + --json number,title,body \ + | jq --arg n "$N" --arg api "$API_RE" \ + '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ + > /tmp/gh-aw/agent/merged-fix-prs.json +jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json ``` +- If a **merged** `[leak-fix]` PR already fixes this issue number OR the same rooting + `Type.Member` (d) → the leak is **already on `main`**. Do NOT create a branch or rebuild. + Emit exactly one `close-issue` safe-output on THIS `[leak-scan]` issue `#` with a closing + comment (AI-attributed, linking the merged PR), e.g.: + > 🔍 **AI-generated action** (Memory Leak Fixer) — this leak is already fixed on `main` by + > # (``). That PR's `Fixes:` line referenced a different issue + > number, so this `[leak-scan]` issue stayed open and kept being re-processed. Closing it to + > stop the rebuild loop. Note: it may still reproduce in the shipped NuGet package until the + > fix ships in a release. + + This is the run's single action — stop after emitting `close-issue`. - 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). @@ -484,7 +514,16 @@ Interpret: - **Test FAILS** (the `WaitForCollect` assert trips: object still alive) → good, the test catches the leak. Proceed to Step 7. - **Test PASSES on unpatched source** → the leak is already fixed on `main`. Per Hard Rule 2, - open NO PR: record `skipped: already fixed on main (test green without fix)` and stop. + open NO PR. This issue must not be re-picked and rebuilt every run, so emit exactly one + `close-issue` safe-output on the `[leak-scan]` issue `#` with an AI-attributed closing + comment — e.g.: + > 🔍 **AI-generated action** (Memory Leak Fixer) — the regression test for this leak is + > already GREEN on unpatched `main` (no fix applied), so this leak is already fixed. Closing + > this `[leak-scan]` issue so it isn't re-processed and rebuilt from source each run. Note: it + > may still reproduce in the shipped NuGet package until the fix ships in a release. + + Then record `skipped: already fixed on main (test green without fix)` and stop. Emitting the + `close-issue` is this run's single action. - **Restore 403 / feed unreachable** → should not happen now (`pkgs.dev.azure.com`, `*.blob.core.windows.net`, `*.nuget.org` are allowlisted). If it still does, record `skipped: build env cannot restore (feed blocked)` and stop — do NOT emit a PR. @@ -570,7 +609,10 @@ If nothing was committed (count `0`) → `skipped: no commit produced` and stop. Emit exactly one `create-pull-request` safe-output. Do NOT set a `base` field (the `base-branch: main` config pins it). Source `branch` MUST be `leak-fix/issue-`. Title MUST start with the literal tag **`[leak-fix] `** followed by e.g. `Fix memory leak -(Fixes #)`. +(Fixes #)`, where **`` is the `[leak-scan]` issue number you selected in Step 2** — not a +pre-existing upstream issue number. The `Fixes #` join key MUST point at the `[leak-scan]` +issue so GitHub auto-closes it on merge; otherwise the scan issue is orphaned and the fixer +re-picks and rebuilds it every run. The body MUST start with the required MAUI testing note (verbatim, no block-quote on the first two lines as shown), then the details: @@ -585,6 +627,9 @@ two lines as shown), then the details: Fixes # Refs: /# + Target branch: main Attempt: /3 @@ -612,7 +657,8 @@ PublicAPI.Unshipped.txt entries added). ``` Before emitting, re-read your body and confirm the `Target branch:` line says `main` and a -`Fixes #` line is present. If not, drop the attempt: `skipped: PR self-check failed`. +`Fixes #` line is present **whose `` is the `[leak-scan]` issue number selected in Step 2** +(not an upstream issue). If not, drop the attempt: `skipped: PR self-check failed`. If no PR was emitted this run (already-fixed, gated out, or validation failed), produce no output — that is an acceptable outcome. Otherwise you have produced exactly one validated, From 3966e53cfe5b5816fde9fb8a0b52e1c9cdd19b52 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:49:04 +0200 Subject: [PATCH 04/68] Gate new leak-fixer close-issue emissions behind dry_run The two auto-close paths added in this PR (Step 3 gate (d) merged-PR de-dup, and Step 6 test-green-on-main) emitted close-issue without an explicit dry-run gate, unlike Track C (Step R) and Step 10 which each restate one. A dry_run could therefore still close a scan issue. Add the same '> Dry-run gate:' guard to both paths and extend the global dry_run rule to list 'close', so dry_run is a true no-side-effect mode. Recompiled leak-fixer.lock.yml (body_hash only; prose is read from the committed .md at runtime). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1257a460-520a-4b7a-91d5-61f39054ea59 --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index d0424bda4426..625d4bc6de31 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":"a50dcc63c4432c53ac7b3c0fbcd6d90d156a40d3744c8e0cb854b143fce7fbba","body_hash":"164e4b72f756dd75431b0757b47e9e99c37863899a471e83ccb25fd04deb6b6c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a50dcc63c4432c53ac7b3c0fbcd6d90d156a40d3744c8e0cb854b143fce7fbba","body_hash":"6137686c25d38cc70111bfc1d44cd88f380fadc3374c4c34e3064835c450f603","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 0c34b7b77eca..28840a5b2146 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -224,7 +224,7 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch (Track A). If blank (e.g. the scheduled run), do Track C first (Step R), then auto-pick a `[leak-scan]` target in Step 2. - `dry_run` (optional, default false): if `"true"`, do the full local work but **emit no - safe-output** — print what you *would* push/comment/open to the run log instead. + safe-output** — print what you *would* push/comment/open/close to the run log instead. ```bash mkdir -p /tmp/gh-aw/agent @@ -436,6 +436,9 @@ jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent > stop the rebuild loop. Note: it may still reproduce in the shipped NuGet package until the > fix ships in a release. + > **Dry-run gate:** if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` + > and the closing comment to the run log instead, then stop. + This is the run's single action — stop after emitting `close-issue`. - 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 @@ -522,6 +525,9 @@ Interpret: > this `[leak-scan]` issue so it isn't re-processed and rebuilt from source each run. Note: it > may still reproduce in the shipped NuGet package until the fix ships in a release. + > **Dry-run gate:** if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` + > and the closing comment to the run log instead, then stop. + Then record `skipped: already fixed on main (test green without fix)` and stop. Emitting the `close-issue` is this run's single action. - **Restore 403 / feed unreachable** → should not happen now (`pkgs.dev.azure.com`, From 218f4656ba44233e6920449c886f62baddb20667 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:30:59 +0200 Subject: [PATCH 05/68] Address review: provenance-gate leak-workflow issue closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respond to the Copilot + adversarial review on the leak-fixer / daily-leak-hunter tuning PR: - close-issue safe-output now carries deterministic guards (required-title-prefix "[leak-scan] " + required-labels [agentic-workflows]), so a hallucinated/injected issue number pointing at an unrelated issue is rejected by the validator, not merely bounded by max:1. (Security) - Fixer Step 6: a regression test that is green on unpatched main no longer closes the [leak-scan] issue — a single green result from a freshly-authored repro is not proof. Automatic closure is reserved for the provenance-backed path only (Step 3 gate (d): a merged [leak-fix] PR). (Regression/data-loss) - Hunter fixed-on-main set is now built from MERGED [leak-fix] PRs ONLY; dropped the "[leak-scan] closed as COMPLETED" source (a close reason is not proof a fix landed and could permanently suppress a still-valid leak). (Data-loss) - Hunter Type.Member extractor keeps a normalized-title fallback key for off-contract titles instead of silently dropping them. - Fixer de-dup/auto-close gates: add --limit 200 to gh pr list so older merged [leak-fix] PRs aren't missed (default page size is 30). - Reworded the issues:write comment (write access lives in the isolated safe-outputs/conclusion jobs; the agent stays read-only). - Clarified the PR-body Refs/Fixes placeholder (Refs points at an OPTIONAL separate upstream issue , never the [leak-scan] #). Recompiled both workflows with gh aw v0.80.9 (0 errors, 0 warnings); lock diffs are the close_issue guard (fixer) and the prompt body_hash (both) only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 66 ++++++++++-------- .github/workflows/leak-fixer.lock.yml | 12 ++-- .github/workflows/leak-fixer.md | 70 +++++++++++--------- 4 files changed, 86 insertions(+), 64 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index a980e61c944b..2310a1890217 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"c6059f80c42d3843e47edf2cf070a3d0c274b5679a5054970deaf85436b74cf1","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"0472288bf924dd8dd383a9e8f15b25c2357260f8cc19035b903a3fc96194dedd","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 37926b4a9caa..126b58b7a78d 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -135,12 +135,14 @@ Never push, never open a PR, never comment, never edit product or test code in t 5. **De-dup against THIS SCANNER's own OPEN issues AND leaks already fixed on `main`.** Before filing, fetch this workflow's open `[leak-scan]` issues and skip a leak already covered by one (same rooting API / retention path). ALSO skip any leak whose rooting `Type.Member` is already - fixed on `main` — a merged `[leak-fix]` PR or a `[leak-scan]` issue closed as completed (Step 2 - builds this set). Such leaks still reproduce against the SHIPPED package until the fix ships, - so the empirical test alone can't tell — de-dup is the only guard. 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 **as not planned** (never fixed) - may be re-filed; one whose leak is fixed on `main` may not. + fixed on `main` by a **merged `[leak-fix]` PR** (Step 2 builds this set — a merged fix PR is + durable, workflow-owned proof a fix landed; a close *reason* alone is not). Such leaks still + reproduce against the SHIPPED package until the fix ships, so the empirical test alone can't + tell — this provenance-gated de-dup is the only guard. 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 with **no merged `[leak-fix]` PR** + (any close reason) may be re-filed if it still reproduces; one whose leak is fixed on `main` by + a merged `[leak-fix]` PR may not. 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. @@ -181,20 +183,31 @@ jq -r '.[].title' /tmp/gh-aw/agent/my-open-leakscan.json \ > /tmp/gh-aw/agent/already-filed-apis.txt echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt -# ── ALSO skip leaks ALREADY FIXED on main ────────────────────────────────────────────────── +# ── ALSO skip leaks ALREADY FIXED on main — MERGED [leak-fix] PRs ONLY ─────────────────────── # Your Step 5 confirmation runs against the SHIPPED NuGet package (pinned 10.0.0), where an # already-MERGED fix has NOT shipped yet — so a leak that is fixed on `main` STILL reproduces -# (goes red) and would be re-filed every run forever. De-dup is the ONLY guard. Build a -# "fixed-on-main" rooting-Type.Member set from (a) every MERGED `[leak-fix]` PR title and -# (b) every `[leak-scan]` issue this scanner closed as COMPLETED (fix landed). -{ - gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ - --limit 200 --json title -q '.[].title' | grep -E '^\[leak-fix\] ' | sed -E 's/^\[leak-fix\] *(Fix +)?//' - gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ - --state closed --label agentic-workflows --limit 300 --json title,stateReason \ - -q '.[] | select(.stateReason == "COMPLETED") | .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] } }' \ +# (goes red) and would be re-filed every run forever. De-dup is the ONLY guard. Build the +# "fixed-on-main" rooting-Type.Member set from **MERGED `[leak-fix]` PR titles ONLY** — a merged +# fix PR is durable, workflow-owned provenance that a fix ACTUALLY LANDED on `main`. Do NOT trust +# an issue's close *reason*: a `[leak-scan]` issue closed as COMPLETED records only that SOMEONE +# closed it, not that a fix merged (a maintainer can close a still-reproducing issue as +# completed), so treating "closed as completed" as fixed would permanently suppress a still-valid +# leak. Worst case here (a human-fixed leak with no [leak-fix] PR) is a single bounded re-file +# that the OPEN-issue de-dup above then catches — far safer than permanent data loss. +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ + --limit 300 --json title -q '.[].title' \ + | grep -E '^\[leak-fix\] ' | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ + | 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 { + # Off-contract title with no dotted Type.Member: keep a durable normalized-title + # fallback key (prefixed "title:") instead of SILENTLY DROPPING it, so the known-fixed + # leak still surfaces with a de-dup identity for the agent to match against. + key=tolower($0); gsub(/[^a-z0-9]+/, "-", key); gsub(/^-+|-+$/, "", key); + if (key != "") print "title:" key + } + }' \ | sort -u \ > /tmp/gh-aw/agent/fixed-on-main-apis.txt echo "fixed-on-main (do NOT re-file) rooting APIs:"; cat /tmp/gh-aw/agent/fixed-on-main-apis.txt @@ -206,14 +219,15 @@ echo "fixed-on-main (do NOT re-file) rooting APIs:"; cat /tmp/gh-aw/agent/fixed- 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 is **ALSO OUT** if its rooting `Type.Member` is in `fixed-on-main-apis.txt` - (already fixed on `main` by a merged `[leak-fix]` PR, or a `[leak-scan]` issue closed as - completed). The shipped-package test in Step 5 will STILL go red for these because the fix - hasn't shipped in a release yet — that is expected. Do NOT re-file: the fix is already on - `main` and will ship. Re-filing just recreates an issue that was deliberately closed. - -A candidate whose only prior `[leak-scan]` issue was closed as **not planned** (i.e. never -fixed — wontfix / invalid / duplicate) may be re-filed if it still reproduces. A candidate whose -leak is in `fixed-on-main-apis.txt` (fixed on `main`) must **not** be re-filed. + (already fixed on `main` by a **merged `[leak-fix]` PR**). The shipped-package test in Step 5 + will STILL go red for these because the fix hasn't shipped in a release yet — that is expected. + Do NOT re-file: the fix is already on `main` and will ship. + +A candidate whose only prior `[leak-scan]` issue was **closed with no merged `[leak-fix]` PR** +(closed as not planned — wontfix / invalid / duplicate — OR closed as completed by a maintainer +without a landed fix) may be re-filed if it still reproduces: a close *reason* is not proof the +leak is gone. A candidate whose leak is in `fixed-on-main-apis.txt` (fixed on `main` by a merged +`[leak-fix]` PR) must **not** be re-filed. # ===================== RUNTIME LEAK HUNT ===================== diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 625d4bc6de31..77267a0453e5 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":"a50dcc63c4432c53ac7b3c0fbcd6d90d156a40d3744c8e0cb854b143fce7fbba","body_hash":"6137686c25d38cc70111bfc1d44cd88f380fadc3374c4c34e3064835c450f603","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"344b22f01c459a945bb42691467c137756074256f842f7090617c10d20c95108","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -516,16 +516,16 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_01e0409969dac555_EOF' - {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_01e0409969dac555_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7f124cc8961e87c6_EOF' + {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"required_labels":["agentic-workflows"],"required_title_prefix":"[leak-scan] ","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_7f124cc8961e87c6_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: *. Supports reply_to_id for discussion threading.", - "close_issue": " CONSTRAINTS: Maximum 1 issue(s) can be closed. Target: *.", + "close_issue": " CONSTRAINTS: Maximum 1 issue(s) can be closed. Target: *. Only issues with title prefix \"[leak-scan] \" can be closed.", "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Labels [\"agentic-workflows\" \"perf/memory-leak 💦\"] will be automatically added. Only these labels are allowed: [\"agentic-workflows\" \"perf/memory-leak 💦\"]. PRs will be created as drafts.", "push_to_pull_request_branch": " CONSTRAINTS: Maximum 1 push(es) can be made. The target pull request title must start with \"[leak-fix]\"." }, @@ -1813,7 +1813,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.blob.core.windows.net,*.githubusercontent.com,*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dev.azure.com,dist.nuget.org,docs.github.com,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"required_labels\":[\"agentic-workflows\"],\"required_title_prefix\":\"[leak-scan] \",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 28840a5b2146..e11765724607 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -152,11 +152,18 @@ safe-outputs: target: "*" # agent supplies the leak PR number required-title-prefix: "[leak-fix]" # Track C only comments on this workflow's own [leak-fix] PRs max: 1 - # Close a [leak-scan] issue whose leak is ALREADY FIXED on main (Step 3 gate d / Step 6) so it - # isn't re-picked and rebuilt from source every run. The agent supplies the issue number + a - # closing comment linking the merged fix. Grants issues:write only to the safe-output job. + # Close an ORPHANED [leak-scan] issue whose leak is ALREADY FIXED on main by a MERGED + # [leak-fix] PR (Step 3 gate (d)) so it isn't re-picked and rebuilt from source every run. + # The agent supplies the issue number + a closing comment linking the merged fix. Write + # access lives ONLY in the isolated safe-outputs/conclusion jobs — the agent itself stays + # read-only. Deterministic guards: the safe-output validator will ONLY close an issue whose + # title starts with "[leak-scan] " AND that carries this workflow's own `agentic-workflows` + # label, so a hallucinated or injected number pointing at an unrelated issue is rejected + # outright (not merely bounded by max:1). close-issue: target: "*" + required-title-prefix: "[leak-scan] " + required-labels: [agentic-workflows] max: 1 # Most 6h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" @@ -195,11 +202,14 @@ 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 — close the scan issue instead.** - When your faithful regression test passes on the *unpatched* source, or a **merged** `[leak-fix]` - PR already covers the same rooting `Type.Member` (Step 3 gate d), the leak no longer needs a - fix. Emit a `close-issue` on the `[leak-scan]` issue (AI-attributed comment linking the fix) - and record `skipped: already fixed on main`. Do NOT leave it open to be re-picked and rebuilt. +2. **If a Track A leak is already fixed on `main`, open NO PR.** Close the scan issue **only** + when a **merged** `[leak-fix]` PR already covers the same rooting `Type.Member` (Step 3 gate + (d)) — that merged PR is the provenance a fix actually landed. Emit a `close-issue` on the + `[leak-scan]` issue (AI-attributed comment linking the merged fix) and record + `skipped: already fixed on main`. If instead your freshly-authored regression test merely + passes on the *unpatched* source (Step 6) with **no** merged fix PR, that is NOT proof the + leak is gone — do NOT close it; record `skipped: test green on unpatched main (issue left + open)` and move on. 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 @@ -213,7 +223,7 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch add the missing `-=` / teardown on the unload path. Prefer the minimal, idiomatic change that matches how the surrounding code already hardens similar subscriptions. 6. **One action per run.** Either respond to ONE PR's review (Track C) OR open ONE new draft PR - (Track A/B) OR close ONE already-fixed `[leak-scan]` issue (Step 3d / Step 6) — never two. + (Track A/B) OR close ONE already-fixed `[leak-scan]` issue (Step 3 gate (d)) — never two. Honour the de-dup / attempt-cap / already-addressed gates so you never repeat yourself. 7. **AI attribution.** Every PR body and every comment must clearly state it was generated by this workflow. @@ -395,20 +405,20 @@ echo "target rooting API: $API" # 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' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title' --limit 200 \ --json number,title,body \ | jq --arg n "$N" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ > /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' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title' --limit 200 \ --json number,title \ | jq --arg api "$API_RE" '[.[] | select(.title | test("Fix +"+$api+"([. ]|$)"))]' \ > /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' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title' --limit 200 \ --json number,title,body,mergedAt \ | jq --arg n "$N" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ > /tmp/gh-aw/agent/closed-fix-prs.json @@ -418,7 +428,7 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # [leak-scan] number, so GitHub never auto-closed this scan issue — leaving it to be # re-picked and rebuilt from source every run. Detect the merged fix by BOTH the issue-ref # AND the rooting Type.Member so we can close this issue instead of rebuilding. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' --limit 200 \ --json number,title,body \ | jq --arg n "$N" --arg api "$API_RE" \ '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ @@ -516,20 +526,16 @@ Interpret: - **Test FAILS** (the `WaitForCollect` assert trips: object still alive) → good, the test catches the leak. Proceed to Step 7. -- **Test PASSES on unpatched source** → the leak is already fixed on `main`. Per Hard Rule 2, - open NO PR. This issue must not be re-picked and rebuilt every run, so emit exactly one - `close-issue` safe-output on the `[leak-scan]` issue `#` with an AI-attributed closing - comment — e.g.: - > 🔍 **AI-generated action** (Memory Leak Fixer) — the regression test for this leak is - > already GREEN on unpatched `main` (no fix applied), so this leak is already fixed. Closing - > this `[leak-scan]` issue so it isn't re-processed and rebuilt from source each run. Note: it - > may still reproduce in the shipped NuGet package until the fix ships in a release. - - > **Dry-run gate:** if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` - > and the closing comment to the run log instead, then stop. - - Then record `skipped: already fixed on main (test green without fix)` and stop. Emitting the - `close-issue` is this run's single action. +- **Test PASSES on unpatched source** → your regression test does not reproduce the leak on + `main`. Per Hard Rule 2, open NO PR. **Do NOT close the `[leak-scan]` issue here.** A single + green result from a test *you just authored* is **not** proof the leak is fixed — the repro may + simply have failed to recreate the retention path (wrong root, too little GC pressure, a typo'd + assertion). Automatic issue closure is reserved for the **provenance-backed** path only — + Step 3 gate (d), where a **merged `[leak-fix]` PR** for the same leak is the evidence. Here, + just record `skipped: test green on unpatched main (no PR, issue left open)` and stop. If the + leak really is fixed on `main`, gate (d) will close the scan issue once the merged fix is + detected; if the test was a bad repro, leaving the issue open lets a later run try again — far + safer than closing a still-live leak on weak evidence. - **Restore 403 / feed unreachable** → should not happen now (`pkgs.dev.azure.com`, `*.blob.core.windows.net`, `*.nuget.org` are allowlisted). If it still does, record `skipped: build env cannot restore (feed blocked)` and stop — do NOT emit a PR. @@ -632,10 +638,12 @@ two lines as shown), then the details: > 🤖 **AI-generated PR** — produced automatically by the **Memory Leak Fixer** agentic workflow from issue #. The regression test below was observed to FAIL on unpatched `main` and PASS with this fix, on the runner. Please review carefully before merging. Fixes # -Refs: /# - + +Refs: /# Target branch: main Attempt: /3 From 25e2ec7b958bc078ab8dfd5504f07b19191c60e4 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:41:05 +0200 Subject: [PATCH 06/68] Fix prompt-internal consistency from Copilot re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 3 Copilot re-review comments (all prompt-text internal consistency, no behavior change): - leak-fixer.md: add close-issue to the safe-output overview list (it was missing from the create-pull-request/push/add-comment sentence). - leak-fixer.md: Step 3 intro no longer claims the body carries Refs # for the [leak-scan] issue — Fixes # is the sole scan-issue join/auto-close key; Refs points at a separate upstream issue. Note gate (a) still searches Fixes|Refs for backward-compat with older fix PRs. - daily-leak-hunter.md: candidate-OUT prose now documents matching the title: fallback keys via the SAME normalization used to build them, so off-contract fixed leaks are actually de-duped (previously the title: keys were unused). Recompiled with gh aw v0.80.9: 0 errors/0 warnings, body_hash-only lock change (frontmatter_hash unchanged, no permission drift). Normalization parity between the awk key-writer and the documented candidate-normalization verified empirically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 6 +++++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 15 +++++++++------ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 2310a1890217..a5d3d03b3ee7 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"0472288bf924dd8dd383a9e8f15b25c2357260f8cc19035b903a3fc96194dedd","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"4b3e261bc72f7594f02ee47620e70e5365cc709da277b9d4782d986cda877c25","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 126b58b7a78d..27356b378857 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -221,7 +221,11 @@ echo "fixed-on-main (do NOT re-file) rooting APIs:"; cat /tmp/gh-aw/agent/fixed- - A candidate is **ALSO OUT** if its rooting `Type.Member` is in `fixed-on-main-apis.txt` (already fixed on `main` by a **merged `[leak-fix]` PR**). The shipped-package test in Step 5 will STILL go red for these because the fix hasn't shipped in a release yet — that is expected. - Do NOT re-file: the fix is already on `main` and will ship. + Do NOT re-file: the fix is already on `main` and will ship. For an **off-contract candidate + whose title has no dotted `Type.Member`**, apply the SAME normalization the merged-PR list uses + (lower-case, replace each run of non-alphanumerics with `-`, trim leading/trailing `-`) and + treat the candidate as OUT if `title:` is in `fixed-on-main-apis.txt`. That is how the + `title:` fallback keys are matched, so off-contract fixed leaks aren't re-filed either. A candidate whose only prior `[leak-scan]` issue was **closed with no merged `[leak-fix]` PR** (closed as not planned — wontfix / invalid / duplicate — OR closed as completed by a maintainer diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 77267a0453e5..ef961cb7d685 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":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"344b22f01c459a945bb42691467c137756074256f842f7090617c10d20c95108","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"6670be959ebf55ccf6e39af856eaadcf14031c3151a92a9ee5a8f563f7227065","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index e11765724607..0627340ddf21 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -190,8 +190,8 @@ You produce **only `[leak-fix]` PRs for empirically-proven leaks** — there is The PR base is always `main`. You build MAUI **from source** to validate. All scratch state goes under `/tmp/gh-aw/agent/` (each bash call is a fresh subshell; persist what you need). Your only -writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch`, `add-comment`) -— never push with raw git, never edit anything outside the fix/test. +writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch`, `add-comment`, +`close-issue`) — never push with raw git, never edit anything outside the fix/test. ## Hard rules — non-negotiable @@ -387,10 +387,13 @@ Read the chosen issue's body in full (`gh issue view --json title,body`). Ex ## Step 3 — De-dup + 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. +A fix PR carries `Fixes #` in its body (where `` is the `[leak-scan]` issue) — that is the +sole scan-issue join / auto-close key. An optional `Refs: /#` line may also +be present, but it points at a SEPARATE pre-existing upstream issue, never at `#`. Because the +same underlying leak can still be filed under MULTIPLE issue numbers (duplicate `[leak-scan]` +issues, or a pre-existing upstream issue), also de-dup by the **rooting `Type.Member`** the target +names — never open a second fix for a leak already being fixed. (The gate (a) search below matches +`Fixes` **or** `Refs` against `#` for backward-compatibility with older fix PRs.) ```bash N= From e85dd9134d3159fef901f0a8edde3f7b2b00fb40 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:47:18 +0200 Subject: [PATCH 07/68] Align Hard Rule 2 with gate (d) closure conditions Copilot re-review: Hard Rule 2 said the fixer closes a [leak-scan] issue only when a merged [leak-fix] PR covers the same rooting Type.Member, but Step 3 gate (d) also closes when a merged PR references the scan issue NUMBER (Fixes/Refs #). Widen Hard Rule 2 to state both OR-conditions so the agent doesn't skip the auto-close path for orphaned scan issues already linked by number. Both are merged-PR-backed, so the provenance-only-closure invariant is preserved. Prompt-only: gh aw v0.80.9 recompile is 0 errors/0 warnings, body_hash-only (frontmatter_hash unchanged, no permission drift). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index ef961cb7d685..75c0c087fda6 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":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"6670be959ebf55ccf6e39af856eaadcf14031c3151a92a9ee5a8f563f7227065","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"9ebec08537d884e715d50cda81f3e6712858416328df8524d092a0f94eb40746","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 0627340ddf21..44a0d3136c52 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -203,8 +203,9 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch 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.** Close the scan issue **only** - when a **merged** `[leak-fix]` PR already covers the same rooting `Type.Member` (Step 3 gate - (d)) — that merged PR is the provenance a fix actually landed. Emit a `close-issue` on the + when a **merged** `[leak-fix]` PR either references this scan issue number (`Fixes`/`Refs #`) + OR already covers the same rooting `Type.Member` (Step 3 gate (d)) — that merged PR is the + provenance a fix actually landed. Emit a `close-issue` on the `[leak-scan]` issue (AI-attributed comment linking the merged fix) and record `skipped: already fixed on main`. If instead your freshly-authored regression test merely passes on the *unpatched* source (Step 6) with **no** merged fix PR, that is NOT proof the From c16e059828044a46da55f7dda8944c5256e0a179 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:48:54 +0200 Subject: [PATCH 08/68] Unify green-test skipped record string across Hard Rule 2 and Step 6 Self-audit consistency: Hard Rule 2 recorded 'skipped: test green on unpatched main (issue left open)' while Step 6 recorded '...(no PR, issue left open)' for the same condition. Unify on the Step 6 form so the agent emits one canonical record string. Prompt-only: gh aw v0.80.9 recompile 0 errors/0 warnings, body_hash-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 75c0c087fda6..1efdfdd76259 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":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"9ebec08537d884e715d50cda81f3e6712858416328df8524d092a0f94eb40746","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"d39af3e842341663a7441770ce9aee19f4d4090ae38fff20860e9cc6e0bea7c6","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 44a0d3136c52..6cf4339288b5 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -209,8 +209,8 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch `[leak-scan]` issue (AI-attributed comment linking the merged fix) and record `skipped: already fixed on main`. If instead your freshly-authored regression test merely passes on the *unpatched* source (Step 6) with **no** merged fix PR, that is NOT proof the - leak is gone — do NOT close it; record `skipped: test green on unpatched main (issue left - open)` and move on. + leak is gone — do NOT close it; record `skipped: test green on unpatched main (no PR, issue + left open)` and move on. 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 From a9231b64a97377a820d78fae11d11d8ecbeb530f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:56:26 +0200 Subject: [PATCH 09/68] Scope [leak-fix] de-dup/close searches to label:agentic-workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot re-review (provenance): the fixer de-dup/close gates (a/b/c/d) and the hunter fixed-on-main set searched by the '[leak-fix]' title substring only, so a manually-created PR titled [leak-fix] (not workflow-owned) could false-match — blocking a real leak, inflating the attempt cap, or (worst) closing a [leak-scan] issue. Scope all six [leak-fix] PR searches to label:agentic-workflows (the label this workflow's create-pull-request output applies), so only workflow-owned fixes count as provenance. [leak-scan] issue searches are unchanged. Empirically verified: 'gh pr list --search "[leak-fix]" in:title label:agentic-workflows' returns only the 3 genuine workflow PRs, excluding non-workflow [leak-fix]-titled PRs (e.g. #36252/#35417/#22673) that the unfiltered search matched. Also (consistency, from the same review): - Step 3 intro parenthetical no longer says a green-on-unpatched-main test means 'already fixed' — aligned with Step 6 (green => no PR, but not proof, no auto-close; closure only via a merged fix PR / gate (d)). - Added provenance notes to the gate (d) and hunter fixed-on-main comments. Prompt-only + description text: gh aw v0.80.9 recompile 0 errors/0 warnings; fixer lock changes only WORKFLOW_DESCRIPTION text + body_hash (no permission/tool drift), hunter lock body_hash-only, actions-lock.json unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 6 ++++-- .github/workflows/leak-fixer.lock.yml | 9 +++++---- .github/workflows/leak-fixer.md | 18 ++++++++++-------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index a5d3d03b3ee7..45076fae6fe1 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"4b3e261bc72f7594f02ee47620e70e5365cc709da277b9d4782d986cda877c25","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"2914430556af4e48a38fc33e89ca32f4fe21aeb6ee24d5652742e701c351414b","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 27356b378857..c4f0f15cdf51 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -188,13 +188,15 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt # already-MERGED fix has NOT shipped yet — so a leak that is fixed on `main` STILL reproduces # (goes red) and would be re-filed every run forever. De-dup is the ONLY guard. Build the # "fixed-on-main" rooting-Type.Member set from **MERGED `[leak-fix]` PR titles ONLY** — a merged -# fix PR is durable, workflow-owned provenance that a fix ACTUALLY LANDED on `main`. Do NOT trust +# fix PR is durable, workflow-owned provenance that a fix ACTUALLY LANDED on `main`. The query is +# scoped to `label:agentic-workflows`, so ONLY this workflow's own merged fixes count (a manually +# created `[leak-fix]`-titled PR is ignored). Do NOT trust # an issue's close *reason*: a `[leak-scan]` issue closed as COMPLETED records only that SOMEONE # closed it, not that a fix merged (a maintainer can close a still-reproducing issue as # completed), so treating "closed as completed" as fixed would permanently suppress a still-valid # leak. Worst case here (a human-fixed leak with no [leak-fix] PR) is a single bounded re-file # that the OPEN-issue de-dup above then catches — far safer than permanent data loss. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ --limit 300 --json title -q '.[].title' \ | grep -E '^\[leak-fix\] ' | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ | awk '{ diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 1efdfdd76259..103df5787f56 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":"8ffde7d60b6ed1b713a0adb98cfa70e4ae3539a006f1c2ba01696e3c475cb965","body_hash":"d39af3e842341663a7441770ce9aee19f4d4090ae38fff20860e9cc6e0bea7c6","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"c0d381623dfaa05b7802c7ab44df6938f5856d4d39fc61ea059c99c5ae9223d0","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -31,8 +31,9 @@ # 2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed, # library-TFM, no workload/emulator) that asserts the transient is collected. # 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving -# the test actually catches the leak. (If it already passes, the leak is already fixed -# on this branch: the agent opens NO PR and stops.) +# the test actually catches the leak. (If it already passes on unpatched source, the agent +# opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** +# close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).) # 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the # missing path), rebuilds, and confirms the **same test now PASSES** with no regression # in its neighbours. @@ -1437,7 +1438,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Memory Leak Fixer" - WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes, the leak is already fixed\n on this branch: the agent opens NO PR and stops.)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." + WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes on unpatched source, the agent\n opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not**\n close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 6cf4339288b5..9b8b369d607a 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -9,8 +9,9 @@ description: | 2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed, library-TFM, no workload/emulator) that asserts the transient is collected. 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving - the test actually catches the leak. (If it already passes, the leak is already fixed - on this branch: the agent opens NO PR and stops.) + the test actually catches the leak. (If it already passes on unpatched source, the agent + opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** + close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).) 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the missing path), rebuilds, and confirms the **same test now PASSES** with no regression in its neighbours. @@ -270,7 +271,7 @@ request). Otherwise, BEFORE looking for new work, see whether one of this workfl # same-repo (dotnet/maui-hosted) PRs here; skip fork-hosted [leak-fix] PRs entirely. OWNER="${GITHUB_REPOSITORY%%/*}" gh pr list --repo "$GITHUB_REPOSITORY" --state open \ - --search '"[leak-fix]" in:title' \ + --search '"[leak-fix]" in:title label:agentic-workflows' \ --json number,title,headRefName,headRepositoryOwner,url,updatedAt \ | jq --arg owner "$OWNER" '[.[] | select((.headRepositoryOwner.login // "") == $owner)] | sort_by(.number)' \ > /tmp/gh-aw/agent/my-open-prs.json @@ -409,20 +410,20 @@ echo "target rooting API: $API" # 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' --limit 200 \ +gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body \ | jq --arg n "$N" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ > /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' --limit 200 \ +gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title \ | jq --arg api "$API_RE" '[.[] | select(.title | test("Fix +"+$api+"([. ]|$)"))]' \ > /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' --limit 200 \ +gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body,mergedAt \ | jq --arg n "$N" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ > /tmp/gh-aw/agent/closed-fix-prs.json @@ -431,8 +432,9 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # Merged fixes often reference an UPSTREAM issue (e.g. "Fixes #35775") instead of this # [leak-scan] number, so GitHub never auto-closed this scan issue — leaving it to be # re-picked and rebuilt from source every run. Detect the merged fix by BOTH the issue-ref -# AND the rooting Type.Member so we can close this issue instead of rebuilding. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title' --limit 200 \ +# AND the rooting Type.Member so we can close this issue instead of rebuilding. The search is +# scoped to label:agentic-workflows so ONLY workflow-owned merged fixes can close a scan issue. +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body \ | jq --arg n "$N" --arg api "$API_RE" \ '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ From 5e47e5a864ea3cd730ef3b08dda4ff8e4cd55a61 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:03:23 +0200 Subject: [PATCH 10/68] Grant hunter pull-requests: read; add --limit to fixer Track C list Copilot re-review: - daily-leak-hunter now runs a 'gh pr list --state merged' call (the new fixed-on-main de-dup guard), but its permissions were only contents:read + issues:read. Without pull-requests:read the agent job's GITHUB_TOKEN can't list PRs and the guard would fail. Add pull-requests: read (the fixer already declares it). Compiled lock's agent job now carries pull-requests: read. - leak-fixer Track C own-open-PR list (Step R1) had no --limit, so gh pr list's default 30-item page could miss older open [leak-fix] PRs with pending CHANGES_REQUESTED and skip review-feedback handling. Add --limit 200 to match the other four de-dup gates. gh aw v0.80.9 recompile: 0 errors/0 warnings. Hunter lock gains only pull-requests: read on the agent job (frontmatter change); fixer lock body_hash only (prompt change); actions-lock.json unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 3 ++- .github/workflows/daily-leak-hunter.md | 1 + .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 45076fae6fe1..a9e15f420030 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"2914430556af4e48a38fc33e89ca32f4fe21aeb6ee24d5652742e701c351414b","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"900c7bbaa56bdfbbebd8069d248c510df4593b61086be8c71f3c62d396477d96","body_hash":"2914430556af4e48a38fc33e89ca32f4fe21aeb6ee24d5652742e701c351414b","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -367,6 +367,7 @@ jobs: permissions: contents: read issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" queue: max diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index c4f0f15cdf51..ce4f0654ccda 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 engine: id: copilot diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 103df5787f56..2da7fdd11eae 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":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"c0d381623dfaa05b7802c7ab44df6938f5856d4d39fc61ea059c99c5ae9223d0","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"88efde5002c55f4d5de170103430977868e58ea8bb32d15cfa58e63ca8fd6492","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 9b8b369d607a..54f66ac80cc0 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -271,7 +271,7 @@ request). Otherwise, BEFORE looking for new work, see whether one of this workfl # same-repo (dotnet/maui-hosted) PRs here; skip fork-hosted [leak-fix] PRs entirely. OWNER="${GITHUB_REPOSITORY%%/*}" gh pr list --repo "$GITHUB_REPOSITORY" --state open \ - --search '"[leak-fix]" in:title label:agentic-workflows' \ + --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,headRefName,headRepositoryOwner,url,updatedAt \ | jq --arg owner "$OWNER" '[.[] | select((.headRepositoryOwner.login // "") == $owner)] | sort_by(.number)' \ > /tmp/gh-aw/agent/my-open-prs.json From 9bc607b0bb4cade7c0b6ec20212795a317b224ac Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:41:20 +0200 Subject: [PATCH 11/68] Fix cross-repo issue-ref false match in leak-fixer close gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates (a/c/d) matched a referenced issue with `(Fixes|Refs)[^0-9]*#`. Because `[^0-9]*` spans `: dotnet/runtime#`, a cross-repo reference such as `Refs: dotnet/runtime#5000` falsely satisfied a search for maui issue #5000. The destructive gate (d) would then close a live `[leak-scan]` issue against an unrelated upstream reference — silent data loss. Require the `#` to be either a BARE same-repo reference or an explicit `/#` qualifier (new `$REPO_RE` jq arg), rejecting arbitrary `other/repo#`. Empirically verified: `Refs: dotnet/runtime#5000` and `Fixes dotnet/runtime#5000` no longer match; `Fixes #5000`, `Refs: #5000`, and `Fixes dotnet/maui#5000` still match. Recompiled locks (body_hash only; gh-aw v0.80.9, actions-lock.json unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 2da7fdd11eae..68b7b4208f7a 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":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"88efde5002c55f4d5de170103430977868e58ea8bb32d15cfa58e63ca8fd6492","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"b3f86f6d7837a4be8374d6c0af013715060191cb32306dafb1f6d0e59adea6ec","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 54f66ac80cc0..816b83874cf0 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -409,10 +409,16 @@ 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') +# Escape the owner/repo so it embeds literally in the jq test() calls below. The issue-ref match +# requires "#" to be a BARE same-repo reference OR an explicit "/#" qualifier — +# NEVER an arbitrary cross-repo "other/repo#". Otherwise an unrelated upstream ref such as +# "Refs: dotnet/runtime#5000" would satisfy a search for maui #5000 and let a foreign reference +# drive the destructive close path in gate (d) against a live [leak-scan] issue. +REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | 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 label:agentic-workflows' --limit 200 \ --json number,title,body \ - | jq --arg n "$N" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ + | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ > /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)? @@ -425,7 +431,7 @@ jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/sam # (c) Closed-unmerged attempts for this issue (attempt cap = 3). gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body,mergedAt \ - | jq --arg n "$N" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ + | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? @@ -436,8 +442,8 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # scoped to label:agentic-workflows so ONLY workflow-owned merged fixes can close a scan issue. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body \ - | jq --arg n "$N" --arg api "$API_RE" \ - '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ + | jq --arg n "$N" --arg api "$API_RE" --arg repo "$REPO_RE" \ + '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json ``` From a5fbf623690e321aa1cabb74fb17a94d52d68589 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:43:04 +0200 Subject: [PATCH 12/68] Raise merged [leak-fix] provenance cap to search-API ceiling + warn on truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses MauiBot [major] finding: the merged [leak-fix] provenance queries were capped (--limit 300 in daily-leak-hunter, --limit 200 in leak-fixer gate d). If more than that many merged leak-fix PRs exist, older durable-provenance entries silently drop out — the hunter could re-file an already-fixed leak, and the fixer could fail to close an orphaned [leak-scan] issue. Both queries now use --limit 1000 (GitHub's search-API hard ceiling; pagination cannot exceed it) and emit a loud WARNING if the raw result count reaches that ceiling, since true completeness beyond 1000 requires date-windowed enumeration. Both truncation cases remain safe-direction: hunter truncation = a single bounded re-file (open-issue de-dup then catches it); fixer gate (d) truncation = under-close (it only closes on a positive match, so a miss never wrongly closes). The data-loss fix regex (bare/current-repo issue-ref match, cross-repo excluded) is preserved verbatim; only the fetch cap, the raw-capture to a temp file, and the truncation guard changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 11 +++++++++-- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 16 ++++++++++++---- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index a9e15f420030..4cb3c34309ff 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":"900c7bbaa56bdfbbebd8069d248c510df4593b61086be8c71f3c62d396477d96","body_hash":"2914430556af4e48a38fc33e89ca32f4fe21aeb6ee24d5652742e701c351414b","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"900c7bbaa56bdfbbebd8069d248c510df4593b61086be8c71f3c62d396477d96","body_hash":"bf9da5913dc0f77df4aa54c63360f494af6be6fc4f3063ead66e1e29161badd6","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index ce4f0654ccda..7729a701161e 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -197,9 +197,16 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt # completed), so treating "closed as completed" as fixed would permanently suppress a still-valid # leak. Worst case here (a human-fixed leak with no [leak-fix] PR) is a single bounded re-file # that the OPEN-issue de-dup above then catches — far safer than permanent data loss. +# --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If the +# merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED: because Step 5 tests the SHIPPED +# package (where a merged-but-unshipped fix still reproduces), a dropped-out fix would be re-filed +# once (then the OPEN-issue de-dup catches it), so warn loudly if we ever reach the ceiling. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ - --limit 300 --json title -q '.[].title' \ - | grep -E '^\[leak-fix\] ' | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ + --limit 1000 --json title -q '.[].title' > /tmp/gh-aw/agent/merged-leakfix-titles.txt +if [ "$(grep -c '' /tmp/gh-aw/agent/merged-leakfix-titles.txt)" -ge 1000 ]; then + echo "WARNING: fixed-on-main provenance hit the 1000 search-API ceiling; older merged [leak-fix] fixes may be TRUNCATED and their leaks could be re-filed once — switch to date-windowed enumeration." +fi +grep -E '^\[leak-fix\] ' /tmp/gh-aw/agent/merged-leakfix-titles.txt | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ | 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] diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 68b7b4208f7a..b0c49042a1ae 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":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"b3f86f6d7837a4be8374d6c0af013715060191cb32306dafb1f6d0e59adea6ec","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"32d85d798e42248493baf6e5a47b799ea72476a2440ad3e6f0f2fe65579104ec","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 816b83874cf0..daefa4a5dd88 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -440,10 +440,18 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # re-picked and rebuilt from source every run. Detect the merged fix by BOTH the issue-ref # AND the rooting Type.Member so we can close this issue instead of rebuilding. The search is # scoped to label:agentic-workflows so ONLY workflow-owned merged fixes can close a scan issue. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ - --json number,title,body \ - | jq --arg n "$N" --arg api "$API_RE" --arg repo "$REPO_RE" \ - '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ +# --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If +# the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a +# valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss +# under-closes rather than wrongly closing), but close-coverage would be incomplete, so warn. +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ + --json number,title,body > /tmp/gh-aw/agent/merged-leakfix-all.json +if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json)" -ge 1000 ]; then + echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." +fi +jq --arg n "$N" --arg api "$API_RE" --arg repo "$REPO_RE" \ + '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ + /tmp/gh-aw/agent/merged-leakfix-all.json \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json ``` From 155707cb06a225917404a9e6eddd39d3eb385e0b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:44:47 +0200 Subject: [PATCH 13/68] Tighten leak-fixer issue-ref regex + fix two prompt-clarity nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the adversarial re-review of a5fbf623690. [correctness] The three [leak-fix] issue-reference gates (a/c/d) used '(Fixes|Refs)[^0-9]*(...#)N', where [^0-9]* greedily spans arbitrary prose and newlines. So a body like 'Fixes a bug.\nAlso see #123' matched as a closing reference to #123 — and in gate (d) (the destructive close path) that could close an unrelated live [leak-scan] issue. Replaced the separator with a strict '(?i)\b(Fixes|Refs)\b:?[ \t]*(...)': the keyword must be a whole word, followed only by an optional colon and horizontal whitespace, before the same repo-scoped '#N' matcher. Adding \b also blocks the '(?i)' false positive on words like 'prefixes #N'. Verified 12/12 on a case matrix (bare/current-repo match; cross-repo, prose-spanning, prefix-word, and #N-boundary cases all excluded). [clarity] Step-pointer fix: the description said closure happens via 'Step 6 / gate (d)', but gate (d) is in Step 3 (Step 6 is the red-test step) — a wrong pointer compiled into the prompt could send an agent to the wrong gate. Now 'Step 3 / gate (d)', matching every other reference. [clarity] The dry-run gate note was formatted as a blockquote directly after the example close-comment blockquote, so an agent could copy it into the emitted comment body. Moved it out of the quote and labeled it '(control text — NOT part of the close comment above)'. The merged-provenance close key intentionally stays at Type.Member granularity: the hunter de-dups scan issues by rooting Type.Member (Step 2), treating same-member/different-retention-path as the same leak (its #1 guarded failure mode), so there is only ever one open scan issue per Type.Member and the close key matches issue granularity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 6 +++--- .github/workflows/leak-fixer.md | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index b0c49042a1ae..c55aabece459 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":"a8d7de4c58f47b2eadf6bb68e4ee3471669593b2df9a8a19483cf44108ba3ee5","body_hash":"32d85d798e42248493baf6e5a47b799ea72476a2440ad3e6f0f2fe65579104ec","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"10dee7e2008b3eca441e3eae65bb9fa5231d457398852a8264def25bf28d8e5e","body_hash":"98b6c1d034e157b24ff3b1ae1de3a893943dc79e9ea5846ed5d916c3e122cb95","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -33,7 +33,7 @@ # 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving # the test actually catches the leak. (If it already passes on unpatched source, the agent # opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** -# close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).) +# close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).) # 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the # missing path), rebuilds, and confirms the **same test now PASSES** with no regression # in its neighbours. @@ -1438,7 +1438,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Memory Leak Fixer" - WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes on unpatched source, the agent\n opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not**\n close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." + WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes on unpatched source, the agent\n opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not**\n close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index daefa4a5dd88..4156f0432e8d 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -11,7 +11,7 @@ description: | 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving the test actually catches the leak. (If it already passes on unpatched source, the agent opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** - close the scan issue; closure happens only via a merged fix PR — see Step 6 / gate (d).) + close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).) 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the missing path), rebuilds, and confirms the **same test now PASSES** with no regression in its neighbours. @@ -418,7 +418,7 @@ REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | 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 label:agentic-workflows' --limit 200 \ --json number,title,body \ - | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ + | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ > /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)? @@ -431,7 +431,7 @@ jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/sam # (c) Closed-unmerged attempts for this issue (attempt cap = 3). gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title,body,mergedAt \ - | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ + | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? @@ -450,7 +450,7 @@ if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json)" -ge 1000 ]; then echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." fi jq --arg n "$N" --arg api "$API_RE" --arg repo "$REPO_RE" \ - '[.[] | select(((.body // "") | test("(Fixes|Refs)[^0-9]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ + '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ /tmp/gh-aw/agent/merged-leakfix-all.json \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json @@ -466,8 +466,9 @@ jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent > stop the rebuild loop. Note: it may still reproduce in the shipped NuGet package until the > fix ships in a release. - > **Dry-run gate:** if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` - > and the closing comment to the run log instead, then stop. + **Dry-run gate** (control text — NOT part of the close comment above): if `dry_run == "true"`, + do NOT emit — print `DRY RUN — would close #` and the closing comment to the run log + instead, then stop. This is the run's single action — stop after emitting `close-issue`. - If an **open** fix PR already refs this issue (a) OR already fixes the same rooting From 5b8b853a3a78950936a3d2cef6f532775fdb15e5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:59:05 +0200 Subject: [PATCH 14/68] Raise gate (c) attempt-cap query to search-API ceiling + warn on truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the native Copilot reviewer finding on gate (c): the closed-unmerged [leak-fix] attempt-cap search used --limit 200. The attempt cap (3 tries per issue) is durable state, but the search is global across all closed [leak-fix] PRs, so as total history grows past the cap, older attempts for a given issue fall off the result set and the 3-attempt cap can under-count — allowing a 4th+ rebuild of a genuinely un-fixable leak. Matches the gate (d) treatment: --limit 1000 (GitHub's search-API hard ceiling; pagination cannot exceed it), capture the raw set to a temp file, and echo a loud WARNING if the count reaches the ceiling (true completeness beyond 1000 needs date-windowed enumeration). Truncation here is fail-safe (over-processing / wasted CI, never data loss). The tightened issue-ref regex from 155707cb06a is preserved verbatim; only the fetch cap, raw-capture-to-temp-file, and truncation guard changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index c55aabece459..11b220c9d997 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":"10dee7e2008b3eca441e3eae65bb9fa5231d457398852a8264def25bf28d8e5e","body_hash":"98b6c1d034e157b24ff3b1ae1de3a893943dc79e9ea5846ed5d916c3e122cb95","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"10dee7e2008b3eca441e3eae65bb9fa5231d457398852a8264def25bf28d8e5e","body_hash":"54c114d608e939e1acb1a4191f680d9b24792d8c2d8b7b558bfa964aafc3be3c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 4156f0432e8d..704d76c538f6 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -429,9 +429,18 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:ti > /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 label:agentic-workflows' --limit 200 \ - --json number,title,body,mergedAt \ - | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ +# --limit 1000 = the GitHub SEARCH API's hard ceiling (pagination cannot exceed it). The +# attempt cap is durable state (a genuinely un-fixable leak must stop being retried), but the +# search is global: as total closed [leak-fix] history grows past the cap, older attempts for +# THIS issue could fall off the set and let the 3-attempt cap under-count (allowing a 4th+ +# rebuild). Fail-safe direction (over-processing, never data loss), but warn if we hit it. +gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ + --json number,title,body,mergedAt > /tmp/gh-aw/agent/closed-leakfix-all.json +if [ "$(jq 'length' /tmp/gh-aw/agent/closed-leakfix-all.json)" -ge 1000 ]; then + echo "WARNING: closed [leak-fix] set hit the 1000 search-API ceiling; older attempts may be TRUNCATED and the 3-attempt cap could under-count for some issues — switch to date-windowed enumeration." +fi +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ + /tmp/gh-aw/agent/closed-leakfix-all.json \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? From 4715f423e84c12ebdb9c4d993b2d8874b9f17d19 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:11:42 +0200 Subject: [PATCH 15/68] Make fixed-on-main provenance grep robust under set -eo pipefail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two native-reviewer findings on the hunter's fixed-on-main suppression block: 1. The leading `grep -E '^\[leak-fix\] '` in the provenance pipe exits 1 when nothing matches. Under the runner's `set -eo pipefail`, the common 'no merged [leak-fix] PRs yet' state (empty provenance — the ACTUAL current state of this brand-new workflow) would make the whole pipe abort the step. Replaced with an `awk '/^\[leak-fix\] /'` filter, which always exits 0 and yields an empty fixed-on-main-apis.txt (the intended no-op). Empirically confirmed: the grep pipe aborts (exit 1) on empty input under set -eo pipefail; the awk pipe survives (exit 0). Output is byte-identical on populated input. 2. The ceiling check used `grep -c ''` (exits 1 on an empty file). Not an actual abort (command-substitution failures don't trip set -e, and the if-condition suspends it — verified it survives), but switched to `awk 'END{print NR}'` for a clean exit-0 count and consistency. No behavior change on non-empty input (verified identical); recompiled with gh-aw v0.80.9 (0/0), only hunter body_hash changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 4cb3c34309ff..2cccad660af2 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":"900c7bbaa56bdfbbebd8069d248c510df4593b61086be8c71f3c62d396477d96","body_hash":"bf9da5913dc0f77df4aa54c63360f494af6be6fc4f3063ead66e1e29161badd6","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"900c7bbaa56bdfbbebd8069d248c510df4593b61086be8c71f3c62d396477d96","body_hash":"e377109afbf21f592f887dafdc263aa5a021105e0b3acebff7a074e39e8d51f1","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 7729a701161e..8f28ee3d79cc 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -203,10 +203,14 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt # once (then the OPEN-issue de-dup catches it), so warn loudly if we ever reach the ceiling. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ --limit 1000 --json title -q '.[].title' > /tmp/gh-aw/agent/merged-leakfix-titles.txt -if [ "$(grep -c '' /tmp/gh-aw/agent/merged-leakfix-titles.txt)" -ge 1000 ]; then +if [ "$(awk 'END{print NR}' /tmp/gh-aw/agent/merged-leakfix-titles.txt)" -ge 1000 ]; then echo "WARNING: fixed-on-main provenance hit the 1000 search-API ceiling; older merged [leak-fix] fixes may be TRUNCATED and their leaks could be re-filed once — switch to date-windowed enumeration." fi -grep -E '^\[leak-fix\] ' /tmp/gh-aw/agent/merged-leakfix-titles.txt | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ +# awk (not grep) as the leading filter: grep exits 1 when nothing matches, which under the +# runner's `set -eo pipefail` would abort this step on the common "no merged [leak-fix] PRs yet" +# state (empty provenance is valid — it just means nothing is fixed-on-main yet). awk always +# exits 0 and yields an empty fixed-on-main-apis.txt, which is the intended no-op. +awk '/^\[leak-fix\] /' /tmp/gh-aw/agent/merged-leakfix-titles.txt | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ | 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] From 82dadbe0ba484eab0188cd56b06265262f604a68 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:20:23 +0200 Subject: [PATCH 16/68] Require perf/memory-leak label on the destructive close-issue guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the native-reviewer finding on the close-issue safe-output: the agentic-workflows label is shared across multiple agentic workflows, so title-prefix [leak-scan] + agentic-workflows alone could theoretically match a manually-created or other-workflow issue if the agent output its number. Added perf/memory-leak 💦 to required-labels so the destructive close path requires BOTH labels (gh-aw close-issue required-labels is AND-semantics: 'Only close issues that have all of these labels'). This is behavior-preserving for the intended target set: the hunter stamps exactly [agentic-workflows, perf/memory-leak 💦] on every [leak-scan] issue it creates and locks that via allowed-labels, so every legitimate orphaned scan issue still qualifies — while a shared-label collision no longer can. Reinforces the design invariant that issue closure is a tightly-guarded destructive action. Recompiled with gh-aw v0.80.9 (0/0); only frontmatter_hash + the compiled close_issue config changed (body_hash, actions-lock.json, and the hunter workflow all unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 10 +++++----- .github/workflows/leak-fixer.md | 11 +++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 11b220c9d997..3a9430ded4df 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":"10dee7e2008b3eca441e3eae65bb9fa5231d457398852a8264def25bf28d8e5e","body_hash":"54c114d608e939e1acb1a4191f680d9b24792d8c2d8b7b558bfa964aafc3be3c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"54c114d608e939e1acb1a4191f680d9b24792d8c2d8b7b558bfa964aafc3be3c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -517,9 +517,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7f124cc8961e87c6_EOF' - {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"required_labels":["agentic-workflows"],"required_title_prefix":"[leak-scan] ","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_7f124cc8961e87c6_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_83d8203845b9009c_EOF' + {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"required_labels":["agentic-workflows","perf/memory-leak 💦"],"required_title_prefix":"[leak-scan] ","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_83d8203845b9009c_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -1814,7 +1814,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.blob.core.windows.net,*.githubusercontent.com,*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dev.azure.com,dist.nuget.org,docs.github.com,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"required_labels\":[\"agentic-workflows\"],\"required_title_prefix\":\"[leak-scan] \",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"required_labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"required_title_prefix\":\"[leak-scan] \",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 704d76c538f6..8aae0bbce587 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -158,13 +158,16 @@ safe-outputs: # The agent supplies the issue number + a closing comment linking the merged fix. Write # access lives ONLY in the isolated safe-outputs/conclusion jobs — the agent itself stays # read-only. Deterministic guards: the safe-output validator will ONLY close an issue whose - # title starts with "[leak-scan] " AND that carries this workflow's own `agentic-workflows` - # label, so a hallucinated or injected number pointing at an unrelated issue is rejected - # outright (not merely bounded by max:1). + # title starts with "[leak-scan] " AND that carries BOTH of this workflow's labels + # (`agentic-workflows` and `perf/memory-leak 💦` — the exact pair the hunter stamps, and locks + # via allowed-labels, on every [leak-scan] issue it creates). `agentic-workflows` alone is + # shared by other agentic workflows, so requiring the memory-leak label too prevents a + # hallucinated/injected number pointing at an unrelated agentic issue from being closed + # (rejected outright, not merely bounded by max:1). close-issue: target: "*" required-title-prefix: "[leak-scan] " - required-labels: [agentic-workflows] + required-labels: [agentic-workflows, "perf/memory-leak 💦"] max: 1 # Most 6h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" From e01a5cb47cc5ea6b9dbf375815a7795fb46ad51b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:34:39 +0200 Subject: [PATCH 17/68] Normalize PR titles with symmetric last-pair extraction in leak-fix gates (b)/(d) Addresses the improved-reviewer [moderate] finding on gate (d): the Type.Member title-match anchored the API immediately after "Fix ", so a merged [leak-fix] PR titled with a qualifier ("Fix Shell BackButtonBehavior.Command ...") or a fully-qualified name ("Fix Microsoft.Maui.Controls.Picker.ItemsSource ...") did NOT match target BackButtonBehavior.Command / Picker.ItemsSource -- the orphaned [leak-scan] issue stayed open and was rebuilt from source every run. The API derivation already normalizes the SCAN ISSUE title via a last-dotted-pair awk extraction, with a comment promising both sides key "identically" -- but the PR side never did that extraction. Replace the anchored Fix-prefix regex in BOTH gate (b) (open, informational) and gate (d) (merged, destructive close) with the same last-dotted-pair extraction in jq, comparing exactly to the plain API string. Empirically verified (jq programs extracted from the file, run on a case matrix): - Now matches qualifier + fully-qualified titles (the two missed cases). - Also ELIMINATES a latent false-positive: the old anchored regex matched "Fix Picker.ItemsSource.Something" (a DIFFERENT member) for target Picker.ItemsSource, a wrongful-close risk on the destructive gate. - Preserves the cross-repo data-loss guard: "Refs: dotnet/runtime#999" still does NOT satisfy a maui #999 close; bare "dotnet/maui#N" does. - Target-sensitive; off-contract titles with no dotted API yield no match. Removed the now-unused API_RE variable (was only consumed by the replaced regexes). Recompiled with gh-aw v0.80.9 (0/0); only leak-fixer.lock.yml body_hash changed (frontmatter, actions-lock.json, hunter all unchanged); gate region passes bash -n. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 3a9430ded4df..f33c753037ed 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":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"54c114d608e939e1acb1a4191f680d9b24792d8c2d8b7b558bfa964aafc3be3c","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"e31c9a36d368c34687466fa00845483e6f23456afdcd6c60ba97cfea481f67c5","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 8aae0bbce587..cc383c535200 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -409,9 +409,6 @@ 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 }') 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') # Escape the owner/repo so it embeds literally in the jq test() calls below. The issue-ref match # requires "#" to be a BARE same-repo reference OR an explicit "/#" qualifier — # NEVER an arbitrary cross-repo "other/repo#". Otherwise an unrelated upstream ref such as @@ -425,10 +422,15 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:ti > /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". +# [leak-fix] PR titles lead with "Fix . ...". Normalize each PR title with the +# SAME last-dotted-pair extraction used for $API above (issue side) instead of anchoring the +# API right after "Fix " — that keys both sides identically, so a qualifier ("Fix Shell +# BackButtonBehavior.Command ...") or a fully-qualified title ("Fix +# Microsoft.Maui.Controls.Picker.ItemsSource ...") still matches the target Type.Member, and a +# deeper member ("Fix Picker.ItemsSource.Something") no longer false-matches Picker.ItemsSource. gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ --json number,title \ - | jq --arg api "$API_RE" '[.[] | select(.title | test("Fix +"+$api+"([. ]|$)"))]' \ + | jq --arg apiplain "$API" 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.title // "") | titleapi) == $apiplain)]' \ > /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). @@ -449,8 +451,10 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? # Merged fixes often reference an UPSTREAM issue (e.g. "Fixes #35775") instead of this # [leak-scan] number, so GitHub never auto-closed this scan issue — leaving it to be -# re-picked and rebuilt from source every run. Detect the merged fix by BOTH the issue-ref -# AND the rooting Type.Member so we can close this issue instead of rebuilding. The search is +# re-picked and rebuilt from source every run. Detect the merged fix by the issue-ref OR the +# rooting Type.Member (each merged title normalized with the SAME last-dotted-pair extraction +# as $API, so qualified / fully-qualified titles key identically) so we can close this issue +# instead of rebuilding. The search is # scoped to label:agentic-workflows so ONLY workflow-owned merged fixes can close a scan issue. # --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If # the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a @@ -461,8 +465,8 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in: if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json)" -ge 1000 ]; then echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." fi -jq --arg n "$N" --arg api "$API_RE" --arg repo "$REPO_RE" \ - '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (.title | test("Fix +"+$api+"([. ]|$)")))]' \ +jq --arg n "$N" --arg apiplain "$API" --arg repo "$REPO_RE" \ + 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (((.title // "") | titleapi) == $apiplain))]' \ /tmp/gh-aw/agent/merged-leakfix-all.json \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json From 7aeffeefa8a64b41c79dba7af54942bde82ff9b5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:56:01 +0200 Subject: [PATCH 18/68] Respect maintainer re-open in leak-fixer gate (d) Gate (d) closes a [leak-scan] issue whenever a MERGED [leak-fix] PR references the issue number or the same rooting Type.Member. If a maintainer re-opens the scan issue after that PR merged (incomplete fix or another retention edge remains), gate (d) re-closed it every 6h and posted a false "already fixed" comment, reversing the human decision. Add a maintainer re-open guard: fetch mergedAt for matched merged fixes, read the issue timeline, and if the newest 'reopened' event is newer than the newest matching mergedAt, emit `skipped: scan issue re-opened after merged fix (maintainer override)` instead of closing or rebuilding. The workflow never re-opens issues, so any reopened event is an external human action. The extra timeline call only runs when a merged fix already matched, so the common no-match path is unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 29 +++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index f33c753037ed..82a0c7b22497 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":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"e31c9a36d368c34687466fa00845483e6f23456afdcd6c60ba97cfea481f67c5","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"1e3346ac5b71cb4bca1584935935f223f8638b28a36aff5931ef8cb03aaa7493","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index cc383c535200..cb9e3faa729f 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -461,7 +461,7 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss # under-closes rather than wrongly closing), but close-coverage would be incomplete, so warn. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ - --json number,title,body > /tmp/gh-aw/agent/merged-leakfix-all.json + --json number,title,body,mergedAt > /tmp/gh-aw/agent/merged-leakfix-all.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json)" -ge 1000 ]; then echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." fi @@ -470,10 +470,35 @@ jq --arg n "$N" --arg apiplain "$API" --arg repo "$REPO_RE" \ /tmp/gh-aw/agent/merged-leakfix-all.json \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json +# (d-override) MAINTAINER RE-OPEN GUARD: if this [leak-scan] issue was RE-OPENED *after* the +# newest matched merged [leak-fix] PR merged, a maintainer deliberately overrode the auto-close +# (the merged fix was incomplete / another retention edge remains). This workflow NEVER re-opens +# issues, so any 'reopened' event is an external human action. Respect it: never re-close (which +# would reverse the human decision every 6h and post a false "already fixed" comment) and never +# rebuild. Emit `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. +if [ "$(jq 'length' /tmp/gh-aw/agent/merged-fix-prs.json)" -ge 1 ]; then + FIX_MERGED_AT=$(jq -r '[.[].mergedAt] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/merged-fix-prs.json) + gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate -H "Accept: application/vnd.github+json" \ + > /tmp/gh-aw/agent/issue-timeline.json 2>/dev/null || echo '[]' > /tmp/gh-aw/agent/issue-timeline.json + REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) + if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then + echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ + > /tmp/gh-aw/agent/reopen-override.txt + cat /tmp/gh-aw/agent/reopen-override.txt + fi +fi ``` +- **Re-open override (takes precedence over gate (d) and any rebuild):** if + `/tmp/gh-aw/agent/reopen-override.txt` exists — i.e. THIS `[leak-scan]` issue was **re-opened + after** the newest matched merged `[leak-fix]` PR merged — a maintainer has deliberately + overridden the auto-close. Do NOT emit `close-issue` and do NOT rebuild; emit + `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. Never reverse a + deliberate human re-open (doing so would re-close it every 6h and post a false "already fixed" + comment). - If a **merged** `[leak-fix]` PR already fixes this issue number OR the same rooting - `Type.Member` (d) → the leak is **already on `main`**. Do NOT create a branch or rebuild. + `Type.Member` (d) **and the re-open override above did NOT fire** → the leak is **already on + `main`**. Do NOT create a branch or rebuild. Emit exactly one `close-issue` safe-output on THIS `[leak-scan]` issue `#` with a closing comment (AI-attributed, linking the merged PR), e.g.: > 🔍 **AI-generated action** (Memory Leak Fixer) — this leak is already fixed on `main` by From ecda5f43d5b133add7094cd75eb82d96b2bf08b2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:15:11 +0200 Subject: [PATCH 19/68] Fix fail-open re-open guard in leak-fixer (fail closed on unverified timeline) The maintainer re-open guard fetched the issue timeline with a `|| echo '[]'` fallback. On ANY timeline fetch/parse failure it wrote an empty timeline, so REOPENED_AT became empty, the re-open override did NOT fire, and the destructive close-issue/rebuild path ran -- re-closing a maintainer-reopened [leak-scan] issue every 6h and posting a false "already fixed" comment. Fix (fail CLOSED on this destructive path): only compute the re-open time when the timeline is BOTH fetched successfully AND parses as a JSON array. Otherwise write the override sentinel so close-issue/rebuild is skipped and the issue is left open. The agent emits a distinct skip reason ("re-open guard unverified (timeline lookup failed)") for the fail-closed case vs. a genuine maintainer override. Empirically verified all four timeline scenarios: (1) API failure and (2) non-array garbage now fail closed (skip close); (3) genuine re-open and (4) normal-no-reopen behave exactly as before. Recompiled leak-fixer.lock.yml with gh aw v0.80.9 (body_hash only; frontmatter and actions-lock.json unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 767d2d21-494b-41fb-8e8b-4410f9cc1b4b --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 34 ++++++++++++++++++--------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 82a0c7b22497..a9affa88d6cb 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":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"1e3346ac5b71cb4bca1584935935f223f8638b28a36aff5931ef8cb03aaa7493","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"522f0eb37b0ef1d3c96b1c9a0893ac22ec866f94da5d92960d222963a5f9c768","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index cb9e3faa729f..bf25205cb1e5 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -478,11 +478,21 @@ jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent # rebuild. Emit `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. if [ "$(jq 'length' /tmp/gh-aw/agent/merged-fix-prs.json)" -ge 1 ]; then FIX_MERGED_AT=$(jq -r '[.[].mergedAt] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/merged-fix-prs.json) - gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate -H "Accept: application/vnd.github+json" \ - > /tmp/gh-aw/agent/issue-timeline.json 2>/dev/null || echo '[]' > /tmp/gh-aw/agent/issue-timeline.json - REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) - if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then - echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ + # Fail CLOSED on this DESTRUCTIVE path: auto-closing a possibly maintainer-reopened issue must + # never happen on an unverified timeline. If the timeline cannot be fetched AND parsed as a JSON + # array, we cannot rule out a maintainer re-open, so write the override sentinel (skip close/ + # rebuild, leave the issue open) instead of treating a failed lookup as an empty timeline. + if gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate -H "Accept: application/vnd.github+json" \ + > /tmp/gh-aw/agent/issue-timeline.json 2>/dev/null \ + && jq -e 'type == "array"' /tmp/gh-aw/agent/issue-timeline.json >/dev/null 2>&1; then + REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) + if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then + echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ + > /tmp/gh-aw/agent/reopen-override.txt + cat /tmp/gh-aw/agent/reopen-override.txt + fi + else + echo "REOPEN GUARD UNVERIFIED: timeline lookup for #$N failed or returned non-array JSON — failing closed; will NOT close or rebuild (cannot rule out a maintainer re-open)." \ > /tmp/gh-aw/agent/reopen-override.txt cat /tmp/gh-aw/agent/reopen-override.txt fi @@ -490,12 +500,14 @@ fi ``` - **Re-open override (takes precedence over gate (d) and any rebuild):** if - `/tmp/gh-aw/agent/reopen-override.txt` exists — i.e. THIS `[leak-scan]` issue was **re-opened - after** the newest matched merged `[leak-fix]` PR merged — a maintainer has deliberately - overridden the auto-close. Do NOT emit `close-issue` and do NOT rebuild; emit - `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. Never reverse a - deliberate human re-open (doing so would re-close it every 6h and post a false "already fixed" - comment). + `/tmp/gh-aw/agent/reopen-override.txt` exists — either THIS `[leak-scan]` issue was **re-opened + after** the newest matched merged `[leak-fix]` PR merged (a maintainer deliberately overrode the + auto-close), **or** the issue timeline could **not be fetched/parsed** so the re-open guard + failed closed — do NOT emit `close-issue` and do NOT rebuild. Read the sentinel and emit the + matching skip: `skipped: scan issue re-opened after merged fix (maintainer override)` for a real + re-open, or `skipped: re-open guard unverified (timeline lookup failed)` when the lookup failed; + then stop. Never reverse a deliberate human re-open, and never close on an unverified timeline + (either would re-close the issue every 6h and post a false "already fixed" comment). - If a **merged** `[leak-fix]` PR already fixes this issue number OR the same rooting `Type.Member` (d) **and the re-open override above did NOT fire** → the leak is **already on `main`**. Do NOT create a branch or rebuild. From 95a840d9849315ff1f84178dd4dc06e444271596 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:17:51 +0200 Subject: [PATCH 20/68] Prevent duplicate memory leak workflow fixes Skip leak candidates that already have an equivalent generated fix merged to main or inflight/current, in both the hunter and fixer workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9536b655-3ba9-4983-84af-b8642b32e066 --- .github/workflows/daily-leak-hunter.lock.yml | 9 +- .github/workflows/daily-leak-hunter.md | 106 ++++++++++++++----- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 97 +++++++++++++---- 4 files changed, 162 insertions(+), 52 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 607c75c1913d..4910cfc6eb7c 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":"745be3f30003b219d6dd9e5e3c43ff0c843c6c60231bff9d7ea0ecb4ed2d668b","body_hash":"03dd04b37a8427233cf1d403b6d08b394ab52f81e16e1d7d8ce99e22153a0192","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"0d6d3533816d2ffdcbe13f04562a41593ce5934cc004c896f4ca9cc89f2599d8","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -367,6 +367,7 @@ jobs: permissions: contents: read issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" queue: max @@ -664,7 +665,7 @@ jobs: mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_a98ee12b97d9a079_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_073fa74f64f5f51f_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -674,7 +675,7 @@ jobs: "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "issues,search" + "GITHUB_TOOLSETS": "pull_requests,issues,search" }, "guard-policies": { "allow-only": { @@ -721,7 +722,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_a98ee12b97d9a079_EOF + GH_AW_MCP_CONFIG_073fa74f64f5f51f_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 29f656b28127..90440f5de513 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 engine: id: copilot @@ -72,7 +73,7 @@ max-daily-ai-credits: -1 tools: github: - toolsets: [issues, search] + toolsets: [pull_requests, issues, search] 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"] @@ -132,11 +133,12 @@ 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 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 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. @@ -150,16 +152,22 @@ 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: + +1. this workflow's own open `[leak-scan]` issues; and +2. exact `[leak-fix]` PRs already merged to `main` or `inflight/current`. + +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. 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: +label), then fetch merged generated fixes and extract the **rooting API** each artifact covers: -``` +```bash 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 @@ -172,19 +180,59 @@ gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ # 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 }' \ + | 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] } }' \ | sort -u \ > /tmp/gh-aw/agent/already-filed-apis.txt echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt + +# Fetch only this workflow family's exact generated PRs that are already merged into one of +# the two active source-flow branches. Trust live baseRefName, not a stale "Target branch:" +# line in the PR body (PRs can be retargeted before merge). +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body,baseRefName,mergedAt,url \ + | jq '[.[] | + select(.mergedAt != null) | + select(.title | startswith("[leak-fix] ")) | + select(.baseRefName == "main" or .baseRefName == "inflight/current")]' \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.json + +# Keep the canonical API first so every candidate can be checked with grep -Fx before its +# throwaway repro is written. Preserve PR metadata for a clear skip diagnostic. +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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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 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. + otherwise covers the same rooting API / retention path, OR it appears in + `already-merged-fix-apis.txt`. **Check this for EVERY candidate before you write its test.** +- Normalize each candidate with the same last-`Type.Member` extraction above, 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: equivalent fix already merged via # to `. +- Re-filing a leak this scanner already has open or that already has a merged fix (even with + different issue wording/number) is the primary failure mode, so be strict about the + canonical `Type.Member`. + +A candidate whose only prior scanner issue is CLOSED may be re-filed only when its API is +absent from `already-merged-fix-apis.txt`. # ===================== RUNTIME LEAK HUNT ===================== @@ -237,10 +285,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) @@ -305,12 +354,13 @@ 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. +issue per distinct leak. De-dup each against open `[leak-scan]` issues, supported-branch merged +`[leak-fix]` PRs, AND 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. 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 82b423ba13de..133ee75d2c46 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":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"6bcdee91cfbce25619b6641e2f3f5979d7b90fe996e90533a5913f7a21a73ed9","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"c68c5216dc2e0ba706cb7c24cbc785fc71806cb4f0020a25ae2cd4c37547437a","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index c321becede71..641c2bc86dd4 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -189,9 +189,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 @@ -347,7 +348,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. @@ -367,12 +368,17 @@ 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= @@ -381,35 +387,88 @@ N= # 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 }') + | 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] } }') 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' \ +# (a) 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 \ + | jq '[.[] | + select(.mergedAt != null) | + select(.title | startswith("[leak-fix] ")) | + select(.baseRefName == "main" or .baseRefName == "inflight/current")]' \ + > /tmp/gh-aw/agent/merged-leak-fix-prs.json + +# Canonicalize every merged PR title with the same last-Type.Member extraction used for the +# selected issue. This handles fully-qualified/off-contract wording without 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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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 + +# 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. +jq --arg n "$N" '[.[] | + select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$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? +gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ --json number,title,body \ - | jq --arg n "$N" '[.[] | select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ + | jq --arg n "$N" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ > /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)? +# (c) 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' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ --json number,title \ - | jq --arg api "$API_RE" '[.[] | select(.title | test("Fix +"+$api+"([. ]|$)"))]' \ + | jq --arg api "$API_RE" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ > /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' \ +# (d) Closed-unmerged attempts for this issue (attempt cap = 3). +gh pr list --repo "$GITHUB_REPOSITORY" --state closed --limit 1000 \ + --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))]' \ + | jq --arg n "$N" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ > /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` OR + `merged-api-fix-prs.tsv` has at least one row (a) → 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 an **open** fix PR already refs this issue (b) OR already fixes the same rooting + `Type.Member` (c) → `skipped: leak already being fixed` and stop (or move to the next + automatic candidate). Also double-check the leak isn't already fixed on `main` (Step 6 gate). - 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). From 56abf8da25dfc0b9b61da5e122e34a71ee5b8033 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:43:08 +0200 Subject: [PATCH 21/68] leak-fixer: slurp paginated timeline + reset shared reopen-override sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes for the maintainer re-open guard: - Merge paginated timeline pages before testing the override. 'gh api --paginate' writes each page as a SEPARATE JSON array, so a multi-page timeline emitted concatenated arrays and the jq reads ran once per page — REOPENED_AT could become multi-valued and the override comparison misfire. Fetch with --slurp and flatten the array-of-arrays with 'jq add // []' into a single event stream (also makes the 'type == array' guard robust). - Reset the shared reopen-override.txt sentinel at the start of each candidate. It is a single fixed path (not keyed by $N) written only inside the merged-fix block, while Step 3 may advance to the next-oldest candidate in the same run. Without the reset a sentinel written for an earlier candidate leaked forward and falsely gated a later one. rm -f it per candidate. Recompiled leak-fixer.lock.yml (body_hash only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index a9affa88d6cb..af167cb18b21 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":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"522f0eb37b0ef1d3c96b1c9a0893ac22ec866f94da5d92960d222963a5f9c768","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"929c73bd3fc49199da15dd587cb2467d37e1f4e9bf7620a7f36baec35e64a85a","body_hash":"1a6b8f3eb2a52459da0ac2af63b4de84e283455a2b9469fd9898f5e6ef9b2186","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index bf25205cb1e5..120ed05216a5 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -470,6 +470,12 @@ jq --arg n "$N" --arg apiplain "$API" --arg repo "$REPO_RE" \ /tmp/gh-aw/agent/merged-leakfix-all.json \ > /tmp/gh-aw/agent/merged-fix-prs.json jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json +# Reset the SHARED re-open override sentinel at the start of every candidate. It lives at a single +# fixed path (NOT keyed by $N) and is written only inside the merged-fix block below, while Step 3 +# may advance to the next-oldest candidate within the SAME run. Without this reset a sentinel +# written for an earlier candidate would leak forward and falsely gate a later one (phantom +# "maintainer override" / "unverified timeline"). Clear it so it reflects ONLY the current $N. +rm -f /tmp/gh-aw/agent/reopen-override.txt # (d-override) MAINTAINER RE-OPEN GUARD: if this [leak-scan] issue was RE-OPENED *after* the # newest matched merged [leak-fix] PR merged, a maintainer deliberately overrode the auto-close # (the merged fix was incomplete / another retention edge remains). This workflow NEVER re-opens @@ -482,9 +488,14 @@ if [ "$(jq 'length' /tmp/gh-aw/agent/merged-fix-prs.json)" -ge 1 ]; then # never happen on an unverified timeline. If the timeline cannot be fetched AND parsed as a JSON # array, we cannot rule out a maintainer re-open, so write the override sentinel (skip close/ # rebuild, leave the issue open) instead of treating a failed lookup as an empty timeline. - if gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate -H "Accept: application/vnd.github+json" \ - > /tmp/gh-aw/agent/issue-timeline.json 2>/dev/null \ - && jq -e 'type == "array"' /tmp/gh-aw/agent/issue-timeline.json >/dev/null 2>&1; then + if gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate --slurp -H "Accept: application/vnd.github+json" \ + > /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null \ + && jq -e 'type == "array"' /tmp/gh-aw/agent/issue-timeline-pages.json >/dev/null 2>&1; then + # `gh api --paginate` writes each page as a SEPARATE JSON array; without --slurp a multi-page + # timeline emits concatenated arrays ([..][..]) and every jq read below would run once PER page, + # so REOPENED_AT could become multi-valued and the string comparison would misfire. --slurp + # collects the pages into one array-of-arrays; `add` flattens them into a single event stream. + jq 'add // []' /tmp/gh-aw/agent/issue-timeline-pages.json > /tmp/gh-aw/agent/issue-timeline.json REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ From fdf16ef99d83aaf6d19d30e55bead137d990237e Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:14:35 +0200 Subject: [PATCH 22/68] Harden leak dedup: normalize title newlines, guard empty API, fail-closed merged fetch Addresses three review findings on the memory-leak dedup workflows: - daily-leak-hunter.md / leak-fixer.md: collapse embedded CR/LF in issue titles (`jq ... | gsub("[\r\n]+";" ")`) before the awk Type.Member extraction, so a multi-line title can't split into two physical lines and emit a spurious second rooting-API key that over-suppresses an unrelated candidate. - leak-fixer.md: guard the same-API open-PR scan on a non-empty $API. An empty $API made $API_RE empty, collapsing the test() regex to "^\[leak-fix\] +Fix +([. ]|$)" which false-matches unrelated PRs (e.g. "[leak-fix] Fix .NET ...") and wrongly skips the fix. - daily-leak-hunter.md / leak-fixer.md: make the merged-fix fetch fail-closed. `gh pr list` errors write an empty pipe, and jq still emits [] with exit 0, so a transient API/auth/rate-limit failure would empty the merged-fix set and re-file/re-create a duplicate. Split fetch from filter and abort on non-zero fetch status. Lock files recompiled via `gh aw compile` (body_hash only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 20 +++++++--- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 42 ++++++++++++++------ 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 4910cfc6eb7c..ba24b9dd2047 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":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"0d6d3533816d2ffdcbe13f04562a41593ce5934cc004c896f4ca9cc89f2599d8","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"3d0182229bb782d68f94c97c16a783cc64990bfd38c6a03f0c8a5ac0ddba997f","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 90440f5de513..7a3b0d69d349 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -178,7 +178,7 @@ gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ # 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 \ +jq -r '.[].title | gsub("[\r\n]+";" ")' /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] } }' \ | sort -u \ @@ -188,13 +188,21 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt # Fetch only this workflow family's exact generated PRs that are already merged into one of # the two active source-flow branches. Trust live baseRefName, not a stale "Target branch:" # line in the PR body (PRs can be retargeted before merge). -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ +# Fail-closed: if the fetch errors (transient API/auth/rate-limit) it writes nothing, and jq on +# an empty pipe still emits [] with exit 0 — the de-dup would then wrongly conclude "no merged +# fix exists" and re-file a duplicate. Split the fetch from the filter and abort on failure. +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ --search '"[leak-fix]" in:title' \ --json number,title,body,baseRefName,mergedAt,url \ - | 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; then + echo "ERROR: 'gh pr list --state merged [leak-fix]' failed — aborting so a transient API/auth/rate-limit error can't empty the merged-fix set and re-file duplicate leaks (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 # Keep the canonical API first so every candidate can be checked with grep -Fx before its diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 133ee75d2c46..785fec2b6b10 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":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"c68c5216dc2e0ba706cb7c24cbc785fc71806cb4f0020a25ae2cd4c37547437a","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"1ed76cf6ce6d6f636c44a75958a32dcb40c107323549d7f87a103ac206fc88a8","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 641c2bc86dd4..b79d0eb5fa8b 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -385,7 +385,7 @@ 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' \ +API=$(gh issue view "$N" --repo "$GITHUB_REPOSITORY" --json title -q '.title | gsub("[\r\n]+";" ")' \ | 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] } }') echo "target rooting API: $API" @@ -393,13 +393,21 @@ echo "target rooting API: $API" # a LITERAL "Type.Member" — otherwise "BackButtonBehavior.Command" would also match "BackButtonBehaviorXCommand". API_RE=$(printf '%s' "$API" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') # (a) Exact [leak-fix] PRs already MERGED to main/inflight/current. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ +# 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 \ - | 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; 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 +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 # Canonicalize every merged PR title with the same last-Type.Member extraction used for the @@ -443,13 +451,21 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # (c) 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 --limit 1000 \ - --search '"[leak-fix]" in:title' \ - --json number,title \ - | jq --arg api "$API_RE" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ - > /tmp/gh-aw/agent/same-api-prs.json +# Guard: if $API is empty (issue title had no Type.Member chain), $API_RE is empty and the +# test() regex collapses to "^\[leak-fix\] +Fix +([. ]|$)", which false-matches unrelated +# PRs like "[leak-fix] Fix .NET …" and would wrongly skip this fix — so only scan when set. +if test -n "$API"; then + gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title \ + | jq --arg api "$API_RE" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ + > /tmp/gh-aw/agent/same-api-prs.json +else + echo "target rooting API is empty (issue #$N title has no Type.Member chain) — skipping same-API dedup so an empty regex can't false-match unrelated [leak-fix] PRs." >&2 + echo '[]' > /tmp/gh-aw/agent/same-api-prs.json +fi jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/same-api-prs.json # (d) Closed-unmerged attempts for this issue (attempt cap = 3). gh pr list --repo "$GITHUB_REPOSITORY" --state closed --limit 1000 \ From 9e6d1ee43c545c6bf93c224cdf1b2374b515595d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:26:33 +0200 Subject: [PATCH 23/68] Make all leak-dedup/cap gates fail-closed, not just the merged fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to fdf16ef9 addressing Copilot's re-review: the remaining `gh pr list | jq` dedup pipelines were still fail-open (a failed fetch lets jq succeed on empty input and emit [], so the gate reads as "nothing found" and re-files/re-attempts a duplicate). leak-fixer.md — split fetch from filter and abort on non-zero fetch for: - (b) open [leak-fix] PR already addressing THIS issue, - (c) open [leak-fix] PR fixing the SAME rooting API, - (d) closed-unmerged attempt-cap query (a fail-open here would reset the attempt cap to 0 and re-attempt past the limit). daily-leak-hunter.md — make the `gh issue list` open-scanner-issues fetch fail-closed too, so a transient error can't empty already-filed-apis and re-file duplicate scanner issues. Selection/work-list fetches (own-open-PRs, candidate issue list) are left as-is: a transient failure there yields an empty work list (a no-op), which is safe and cannot create a duplicate. Lock files recompiled via `gh aw compile` (body_hash only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 7 +++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 40 ++++++++++++++------ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index ba24b9dd2047..e9aa817af936 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":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"3d0182229bb782d68f94c97c16a783cc64990bfd38c6a03f0c8a5ac0ddba997f","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"952280b72ab6296409078abd08e0bdc13c1bbc7ecb1c4574807cbf906e2ee346","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 7a3b0d69d349..6aaf397287b9 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -168,9 +168,12 @@ Fetch this scanner's own open `[leak-scan]` issues (they are filed with the `age label), then fetch merged generated fixes and extract the **rooting API** each artifact covers: ```bash -gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ +if ! 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 + > /tmp/gh-aw/agent/my-open-leakscan.json; then + echo "ERROR: 'gh issue list [leak-scan]' failed — aborting so a transient error can't empty the already-filed set and re-file duplicate scanner issues (fail-closed)." >&2 + exit 1 +fi # 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 785fec2b6b10..5a629ab04dc1 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":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"1ed76cf6ce6d6f636c44a75958a32dcb40c107323549d7f87a103ac206fc88a8","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"93286c516b276831faadc4da37017ed32b01e64520b1becf2e87407bd286ae25","body_hash":"31adce4edc9a85150fe64c8486335109ab7215b7ddf9621806406bd02af925a4","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index b79d0eb5fa8b..d13f79e67a14 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -441,12 +441,17 @@ awk -F '\t' '{ print "equivalent API fix already merged: #" $2 " -> " $3 " " $4 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? -gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ --search '"[leak-fix]" in:title' \ --json number,title,body \ - | jq --arg n "$N" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ + > /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" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$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 # (c) Open [leak-fix] PR already fixing the SAME rooting Type.Member (any issue number)? @@ -455,12 +460,17 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # test() regex collapses to "^\[leak-fix\] +Fix +([. ]|$)", which false-matches unrelated # PRs like "[leak-fix] Fix .NET …" and would wrongly skip this fix — so only scan when set. if test -n "$API"; then - gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ --search '"[leak-fix]" in:title' \ --json number,title \ - | jq --arg api "$API_RE" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ + > /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 --arg api "$API_RE" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ + /tmp/gh-aw/agent/same-api-prs-raw.json \ > /tmp/gh-aw/agent/same-api-prs.json else echo "target rooting API is empty (issue #$N title has no Type.Member chain) — skipping same-API dedup so an empty regex can't false-match unrelated [leak-fix] PRs." >&2 @@ -468,12 +478,18 @@ else fi jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/same-api-prs.json # (d) Closed-unmerged attempts for this issue (attempt cap = 3). -gh pr list --repo "$GITHUB_REPOSITORY" --state closed --limit 1000 \ +# 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,mergedAt \ - | jq --arg n "$N" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ + > /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 +jq --arg n "$N" '[.[] | + select(.title | startswith("[leak-fix] ")) | + select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ + /tmp/gh-aw/agent/closed-fix-prs-raw.json \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json ``` From 2e4337662f930bad154e6f333893514b1e2ebae4 Mon Sep 17 00:00:00 2001 From: kubaflo Date: Tue, 21 Jul 2026 18:24:24 +0200 Subject: [PATCH 24/68] Create /tmp/gh-aw/agent before first redirect in daily-leak-hunter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-closed rewrite writes intermediate de-dup state to /tmp/gh-aw/agent/*.json, but the gh-aw runtime pre-creates only /tmp/gh-aw and /tmp/gh-aw/safeoutputs — not /tmp/gh-aw/agent. Without the directory the first redirect fails and the run aborts before any de-dup logic executes. Add `mkdir -p /tmp/gh-aw/agent` at the start of the Step 2 bash block (before the first redirect); /tmp persists across the later fresh-subshell bash calls, so one mkdir covers every write in the job. leak-fixer.md already does this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index e9aa817af936..c8f50780e09f 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":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"952280b72ab6296409078abd08e0bdc13c1bbc7ecb1c4574807cbf906e2ee346","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1fcafb8e3ad3c5fa2210bdfea52f3723a30413a308a83ddf1dcc85a5db8cccd1","body_hash":"8cfce9602e2060272b0e02263e8b767966fc3e31eb8fa68ff5e7b5c906641660","compiler_version":"v0.80.9","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.63"}} # 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":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"8c7d04ebf1ece56cd381446125da3e0f6896294a","version":"v0.80.9"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.80.9). 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/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 6aaf397287b9..57927d433891 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -168,6 +168,12 @@ Fetch this scanner's own open `[leak-scan]` issues (they are filed with the `age label), then fetch merged generated fixes and extract the **rooting API** each artifact covers: ```bash +# Each bash call is a fresh subshell and the gh-aw runtime pre-creates only +# /tmp/gh-aw and /tmp/gh-aw/safeoutputs — NOT /tmp/gh-aw/agent. Create it before +# the first redirect below, otherwise that redirect fails and the run aborts +# before any de-dup logic runs (the /tmp filesystem persists across the later +# subshells, so one mkdir here covers every /tmp/gh-aw/agent write in this job). +mkdir -p /tmp/gh-aw/agent if ! 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; then From 0c284c40675a4162c6cf93d32cc248daa8aadf21 Mon Sep 17 00:00:00 2001 From: kubaflo <34349119+kubaflo@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:51:55 +0200 Subject: [PATCH 25/68] Harden leak dedup: fail-closed title fetch + drop unused pull_requests toolset Addresses two review findings: - leak-fixer.md: separate the issue-title fetch from the Type.Member extraction so a *transient* `gh issue view` failure fails closed (empty title -> abort) instead of silently yielding an empty $API, which disabled the same-API dedup scan and could let a duplicate [leak-fix] PR be opened. A genuinely Type.Member-less title still yields an empty $API and is handled by the existing skip guard. - daily-leak-hunter.md: drop the unused `pull_requests` GitHub MCP toolset. PR enumeration for the historical-fix gate uses `gh` in the agent shell, so the toolset only widened the model's content-bearing read surface. Recompiled both .lock.yml with gh-aw v0.83.4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 8 ++++---- .github/workflows/daily-leak-hunter.md | 2 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 10 +++++++++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 367acd073ded..b671fb833e71 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":"76a85f4ddaada7dcf90876ab82b075474a94bea5c7ac5f2cdc30a65d4a9063a6","body_hash":"a1e644d4df38688f7e219d87b0ccb7f1e6d67ffb6651fd34602c27dc44216561","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"71f45a2ade9f6c413a4725cd606260dac29f72a27aac34213db55dc730eb3cbd","body_hash":"a1e644d4df38688f7e219d87b0ccb7f1e6d67ffb6651fd34602c27dc44216561","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.75"}} # 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":"e89c65e17eb281bbd5ff2ff9e9199a03e96654c7","version":"v0.83.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw (v0.83.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -674,7 +674,7 @@ jobs: mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_f8e55588733f6ab3_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_34a21b3876084751_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -685,7 +685,7 @@ jobs: "GITHUB_HOST": "${GITHUB_SERVER_URL}", "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "pull_requests,issues,search" + "GITHUB_TOOLSETS": "issues,search" }, "guard-policies": { "allow-only": { @@ -736,7 +736,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_f8e55588733f6ab3_EOF + GH_AW_MCP_CONFIG_34a21b3876084751_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index c36f98eabfd8..34e8d2767cc2 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -60,7 +60,7 @@ max-daily-ai-credits: -1 tools: github: - toolsets: [pull_requests, issues, search] + toolsets: [issues, search] 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"] diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 7dbaef2f43e4..7e866933b97e 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":"1ba453363cd25bf220c1d11d79ff35b30301d0a4d6a515803f04f53dcd2c21f1","body_hash":"47e8ba15ca28e26fec29d581f3a48458e34fae4d09a2808d23cc1b6f35e5d269","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.75"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"1ba453363cd25bf220c1d11d79ff35b30301d0a4d6a515803f04f53dcd2c21f1","body_hash":"28f6b01c2e67e9f2806a8d3cc7a344ba82fffa79a63c26dfd9a2d1b15d60490a","compiler_version":"v0.83.4","strict":true,"agent_id":"copilot","agent_model":"claude-opus-4.8","engine_versions":{"copilot":"1.0.75"}} # 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":"e89c65e17eb281bbd5ff2ff9e9199a03e96654c7","version":"v0.83.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw (v0.83.4). 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/leak-fixer.md b/.github/workflows/leak-fixer.md index 69229e5c6f6d..6084b7e84103 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -372,7 +372,15 @@ 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 | gsub("[\r\n]+";" ")' \ +# 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=$(printf '%s' "$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] } }') echo "target rooting API: $API" From fe6040a076b72f526cedb1869e96d7944e1f6d38 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:01:32 +0200 Subject: [PATCH 26/68] Address review feedback on leak-fixer/daily-leak-hunter workflows - leak-fixer.md: cache the open/closed/merged [leak-fix] PR searches ONCE per run (new Step 2.5) instead of re-running all three GitHub searches for every auto-selected candidate. Step 3 now applies its per-candidate jq filters (gates a-d) against the cached JSON files. (r3685311261) - daily-leak-hunter.md + leak-fixer.md gate (d): a merged "[leak-fix]" PR only proves the fix landed on ITS base branch, not necessarily `main` (real counterexample: #36370, merged into `inflight/current`). Both "fixed-on-main" provenance lookups now verify each merge commit is an ancestor of `main` via the GitHub compare API (`ahead_by == 0`), the same ancestry check already used by the release-readiness skill, before trusting it as main provenance. This preserves the intended dedup behavior (no false "already fixed" suppression/close) while fixing the false-positive. (r3732227859) - leak-fixer.md: make the dry_run no-write guarantee job-enforced via `safe-outputs.staged`, matching the pattern already used by ci-status-main.md/ci-status-net11.md, instead of relying solely on agent prompt compliance. The generated safe_outputs job now stages (never executes) close-issue/create-pull-request/ push-to-pull-request-branch/add-comment whenever dry_run is true. (r3732227918) Both workflows recompiled with `gh aw compile --strict` (0 warnings); .lock.yml files regenerated and included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 31 +++- .github/workflows/leak-fixer.lock.yml | 7 +- .github/workflows/leak-fixer.md | 140 ++++++++++++++----- 4 files changed, 141 insertions(+), 39 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 2409c4ddd119..b2afeb1d06a6 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"7f5cd4087f58c2fae7f14b2e882d7b75ea10b7342e3a0e19e8caac7c121aef8f","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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"1ad19af593945448f42873b6d6598260311fec92da5505e3df7f3f24588a9920","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 5c34c1b1a48a..eed1219ebb0c 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -189,10 +189,37 @@ echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt # package (where a merged-but-unshipped fix still reproduces), a dropped-out fix would be re-filed # once (then the OPEN-issue de-dup catches it), so warn loudly if we ever reach the ceiling. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ - --limit 1000 --json title -q '.[].title' > /tmp/gh-aw/agent/merged-leakfix-titles.txt -if [ "$(awk 'END{print NR}' /tmp/gh-aw/agent/merged-leakfix-titles.txt)" -ge 1000 ]; then + --limit 1000 --json title,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json +if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then echo "WARNING: fixed-on-main provenance hit the 1000 search-API ceiling; older merged [leak-fix] fixes may be TRUNCATED and their leaks could be re-filed once — switch to date-windowed enumeration." fi +# A "[leak-fix]" PR being MERGED only proves the fix landed on ITS base branch — NOT necessarily +# `main`. This workflow's safe-outputs always OPEN the fix PR against `main`, but a merge can +# still land the SAME commit content on a forward-integration branch (e.g. `inflight/current`) +# instead, either via a later retarget or a merge-queue rebase; #36370 is a concrete example +# (title/label-matches this query, `mergedAt` is set, but its merge commit is on +# `inflight/current`, not `main`). Trusting merge-state alone would suppress a leak that still +# reproduces on `main`. Verify each merge commit is ACTUALLY an ancestor of `main` via the GitHub +# compare API (`ahead_by == 0` ⇒ the merge commit is fully contained in `main`'s history) — the +# SAME ancestry check the release-readiness skill uses (see +# `.github/skills/release-readiness/references/methodology.md`) — instead of relying on +# `--state merged` / `--base` alone (a local `fetch-depth: 50` checkout is too shallow to answer +# this reliably with local git, and `--base` only reflects the PR's declared base, not where the +# merge commit ultimately landed). +: > /tmp/gh-aw/agent/merged-onmain.ndjson +jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do + sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") + [ -z "$sha" ] && continue + ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" + if [ "$ahead" = "0" ]; then + printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain.ndjson + fi +done +if [ -s /tmp/gh-aw/agent/merged-onmain.ndjson ]; then + jq -s -r '.[].title' /tmp/gh-aw/agent/merged-onmain.ndjson > /tmp/gh-aw/agent/merged-leakfix-titles.txt +else + : > /tmp/gh-aw/agent/merged-leakfix-titles.txt +fi # awk (not grep) as the leading filter: grep exits 1 when nothing matches, which under the # runner's `set -eo pipefail` would abort this step on the common "no merged [leak-fix] PRs yet" # state (empty provenance is valid — it just means nothing is fixed-on-main yet). awk always diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index cd804121d204..208c7259e901 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":"2486c370bed3e9fcf03e3bb5bcc6d7da8aaa466a9077cec615fd9caedc25f740","body_hash":"1bbeb2d8bca0ade58570ce99102a6d2cec2fd2508e585b8ecf28acf3a6780ecb","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":"111ed8a5c18f319d3980f441e6b88b373833a122f6fda21197d704c3befd4b1a","body_hash":"513eb151708d1e209c16f78854ce9a51589360d8dd7475c2c6dd71313e55b1e5","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 # @@ -162,7 +162,7 @@ jobs: GH_AW_INFO_WORKFLOW_NAME: "Memory Leak Fixer" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" - GH_AW_INFO_STAGED: "false" + GH_AW_INFO_STAGED: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github","dotnet","dev.azure.com","*.blob.core.windows.net"]' GH_AW_INFO_FIREWALL_ENABLED: "true" GH_AW_INFO_AWF_VERSION: "v0.27.44" @@ -989,6 +989,7 @@ jobs: GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_STAGED: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} GH_AW_TIMEOUT_MINUTES: 120 GH_AW_VERSION: v0.85.4 GITHUB_API_URL: ${{ github.api_url }} @@ -1770,6 +1771,7 @@ jobs: GH_AW_ENGINE_ID: "copilot" GH_AW_ENGINE_MODEL: "gpt-5.6-sol" GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_SAFE_OUTPUTS_STAGED: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} GH_AW_WORKFLOW_ID: "leak-fixer" GH_AW_WORKFLOW_NAME: "Memory Leak Fixer" @@ -1858,6 +1860,7 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"required_labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"required_title_prefix\":\"[leak-scan] \",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\",\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_STAGED: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 73501994cc72..3be3363d8868 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -99,6 +99,16 @@ network: - "*.blob.core.windows.net" safe-outputs: + # Job-enforced dry-run: when `dry_run` is true, gh-aw stages every safe-output below (writes + # nothing, only logs/records what WOULD have been emitted) regardless of what the agent does. + # The per-step "Dry-run gate" prompt text further down is a courtesy for a clean run log, but + # it is NOT what makes dry_run safe — an agent that emits close-issue/create-pull-request/etc. + # anyway (ignoring the prompt) must still produce NO write. Making this an emitted-tool-call + # decision inside the prompt (as it was before) leaves the actual guarantee entirely up to + # model compliance; `staged` moves the guarantee into the generated job itself, so a manual + # preview run (`dry_run: true`) can NEVER close an issue, open/push a PR, or add a comment. Keep + # this identical to the pattern already used by ci-status-main.md / ci-status-net11.md. + staged: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} 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. @@ -228,7 +238,10 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch (Track A). If blank (e.g. the scheduled run), do Track C first (Step R), then auto-pick a `[leak-scan]` target in Step 2. - `dry_run` (optional, default false): if `"true"`, do the full local work but **emit no - safe-output** — print what you *would* push/comment/open/close to the run log instead. + safe-output** — print what you *would* push/comment/open/close to the run log instead. This is + now backed by `safe-outputs.staged` in the frontmatter, so even an emitted safe-output call + writes nothing on a `dry_run` run — the printing above is for a clean, readable log, not the + enforcement mechanism. ```bash mkdir -p /tmp/gh-aw/agent @@ -348,7 +361,9 @@ PUSH BACK), make no commit. evidence). Be courteous and specific; it is fine to disagree with a review when you are right. > **Dry-run gate:** if `dry_run == "true"`, do NOT emit — print what you would push (diff --stat) -> and the comment body to the run log instead. +> and the comment body to the run log instead. (Enforced job-side too: `safe-outputs.staged` +> stages, and does not write, `push-to-pull-request-branch` / `add-comment` when `dry_run` is +> true, so this print-only behavior holds even if you emit anyway.) Track C uses this run's single action. **Stop here — do not also do Track A/B.** If no PR needed a response, fall through to Step 2. @@ -377,7 +392,78 @@ 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 2.5 — Fetch workflow-owned `[leak-fix]` PR sets ONCE per run (cache, don't re-query) + +Step 3's gates (a)–(d) below all filter this SAME three-state `[leak-fix]` PR history with +per-candidate `jq` (keyed on `$N` / `$API`), but the raw open/closed/merged PR sets themselves do +NOT depend on `$N` or `$API` — only the filters do. If Step 2's auto-pick gates out one or more +candidates before landing on an actionable issue ("move to the next oldest" below), re-running +these THREE `gh pr list` searches for every candidate multiplies GitHub Search API pagination for +no reason and can burn the scheduled run before it reaches an actionable candidate. Fetch each +state ONCE here, cache it, and have Step 3 apply its `jq` filters against these SAME cached files +for every candidate instead of re-issuing the searches. + +```bash +# Open [leak-fix] PRs — gates (a) and (b) below both filter this SAME set (issue-ref match and +# same-rooting-API match respectively), so fetch it once with both fields instead of twice. +gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ + --json number,title,body > /tmp/gh-aw/agent/open-leakfix-all.json +jq 'length' /tmp/gh-aw/agent/open-leakfix-all.json +# Closed-unmerged [leak-fix] PRs — feeds gate (c)'s attempt cap. +# --limit 1000 = the GitHub SEARCH API's hard ceiling (pagination cannot exceed it). The +# attempt cap is durable state (a genuinely un-fixable leak must stop being retried), but the +# search is global: as total closed [leak-fix] history grows past the cap, older attempts for +# THIS issue could fall off the set and let the 3-attempt cap under-count (allowing a 4th+ +# rebuild). Fail-safe direction (over-processing, never data loss), but warn if we hit it. +gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ + --json number,title,body,mergedAt > /tmp/gh-aw/agent/closed-leakfix-all.json +if [ "$(jq 'length' /tmp/gh-aw/agent/closed-leakfix-all.json)" -ge 1000 ]; then + echo "WARNING: closed [leak-fix] set hit the 1000 search-API ceiling; older attempts may be TRUNCATED and the 3-attempt cap could under-count for some issues — switch to date-windowed enumeration." +fi +# MERGED [leak-fix] PRs — feeds gate (d), the destructive close path. +# --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If +# the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a +# valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss +# under-closes rather than wrongly closing), but close-coverage would be incomplete, so warn. +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ + --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json +if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then + echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." +fi +# A "[leak-fix]" PR being MERGED only proves the fix landed on ITS base branch — NOT necessarily +# `main`. This workflow's safe-outputs always OPEN the fix PR against `main`, but the SAME merge +# commit can still land on a forward-integration branch (e.g. `inflight/current`) rather than +# `main` (real example: #36370 — matches this title/label query, `mergedAt` is set, but its merge +# commit is on `inflight/current`, not `main`). Gate (d) CLOSES a live [leak-scan] issue as +# "already fixed on main" on this evidence, so trusting merge-state alone can produce a FALSE +# "already fixed" close while the leak still reproduces on `main`. Verify each merge commit is +# ACTUALLY an ancestor of `main` via the GitHub compare API (`ahead_by == 0` ⇒ the merge commit is +# fully contained in `main`'s history) — the SAME ancestry check the release-readiness skill uses +# (see `.github/skills/release-readiness/references/methodology.md`) — and drop any entry that +# fails it BEFORE gate (d) ever sees it, instead of relying on `--state merged` / `--base` alone +# (`--base` only reflects the PR's declared base, not where the merge commit ultimately landed). +: > /tmp/gh-aw/agent/merged-onmain.ndjson +jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do + sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") + [ -z "$sha" ] && continue + ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" + if [ "$ahead" = "0" ]; then + printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain.ndjson + fi +done +if [ -s /tmp/gh-aw/agent/merged-onmain.ndjson ]; then + jq -s '.' /tmp/gh-aw/agent/merged-onmain.ndjson > /tmp/gh-aw/agent/merged-leakfix-all.json +else + echo '[]' > /tmp/gh-aw/agent/merged-leakfix-all.json +fi +jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json +``` + +Do this ONCE at the top of the run, before evaluating any candidate. If Step 2 gates out the +first candidate and moves to the next-oldest, do NOT re-run the fetches above — go straight to +Step 3 and re-apply its `jq` filters (below) to these SAME cached files for the new `$N` / `$API`. + +## Step 3 — De-dup + attempt cap (per-candidate filters against the Step 2.5 cache) A fix PR carries `Fixes #` in its body (where `` is the `[leak-scan]` issue) — that is the sole scan-issue join / auto-close key. An optional `Refs: /#` line may also @@ -402,10 +488,10 @@ echo "target rooting API: $API" # "Refs: dotnet/runtime#5000" would satisfy a search for maui #5000 and let a foreign reference # drive the destructive close path in gate (d) against a live [leak-scan] issue. REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | 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 label:agentic-workflows' --limit 200 \ - --json number,title,body \ - | jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ +# (a) Open [leak-fix] PR already addressing THIS issue number? Filters the Step 2.5 cache — no +# new gh pr list call for this (or any later) candidate. +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/open-leakfix-all.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)? @@ -415,22 +501,12 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # BackButtonBehavior.Command ...") or a fully-qualified title ("Fix # Microsoft.Maui.Controls.Picker.ItemsSource ...") still matches the target Type.Member, and a # deeper member ("Fix Picker.ItemsSource.Something") no longer false-matches Picker.ItemsSource. -gh pr list --repo "$GITHUB_REPOSITORY" --state open --search '"[leak-fix]" in:title label:agentic-workflows' --limit 200 \ - --json number,title \ - | jq --arg apiplain "$API" 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.title // "") | titleapi) == $apiplain)]' \ +# Filters the SAME Step 2.5 cache as (a) — still no new gh pr list call. +jq --arg apiplain "$API" 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.title // "") | titleapi) == $apiplain)]' \ + /tmp/gh-aw/agent/open-leakfix-all.json \ > /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). -# --limit 1000 = the GitHub SEARCH API's hard ceiling (pagination cannot exceed it). The -# attempt cap is durable state (a genuinely un-fixable leak must stop being retried), but the -# search is global: as total closed [leak-fix] history grows past the cap, older attempts for -# THIS issue could fall off the set and let the 3-attempt cap under-count (allowing a 4th+ -# rebuild). Fail-safe direction (over-processing, never data loss), but warn if we hit it. -gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ - --json number,title,body,mergedAt > /tmp/gh-aw/agent/closed-leakfix-all.json -if [ "$(jq 'length' /tmp/gh-aw/agent/closed-leakfix-all.json)" -ge 1000 ]; then - echo "WARNING: closed [leak-fix] set hit the 1000 search-API ceiling; older attempts may be TRUNCATED and the 3-attempt cap could under-count for some issues — switch to date-windowed enumeration." -fi +# (c) Closed-unmerged attempts for this issue (attempt cap = 3). Filters the Step 2.5 cache. jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ /tmp/gh-aw/agent/closed-leakfix-all.json \ > /tmp/gh-aw/agent/closed-fix-prs.json @@ -441,17 +517,10 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json # re-picked and rebuilt from source every run. Detect the merged fix by the issue-ref OR the # rooting Type.Member (each merged title normalized with the SAME last-dotted-pair extraction # as $API, so qualified / fully-qualified titles key identically) so we can close this issue -# instead of rebuilding. The search is -# scoped to label:agentic-workflows so ONLY workflow-owned merged fixes can close a scan issue. -# --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If -# the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a -# valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss -# under-closes rather than wrongly closing), but close-coverage would be incomplete, so warn. -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ - --json number,title,body,mergedAt > /tmp/gh-aw/agent/merged-leakfix-all.json -if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json)" -ge 1000 ]; then - echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." -fi +# instead of rebuilding. Filters the Step 2.5 cache, which is ALREADY scoped to +# label:agentic-workflows (so only workflow-owned merged fixes count) and ALREADY restricted +# to merge commits verified as an ancestor of `main` (so a fix merged only into +# `inflight/current` or another non-`main` branch can never satisfy this gate). jq --arg n "$N" --arg apiplain "$API" --arg repo "$REPO_RE" \ 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (((.title // "") | titleapi) == $apiplain))]' \ /tmp/gh-aw/agent/merged-leakfix-all.json \ @@ -519,7 +588,9 @@ fi **Dry-run gate** (control text — NOT part of the close comment above): if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` and the closing comment to the run log - instead, then stop. + instead, then stop. (Enforced job-side too: `safe-outputs.staged` stages, and does not + execute, `close-issue` when `dry_run` is true, so a manual preview run can never actually close + this issue even if a `close-issue` call is emitted.) This is the run's single action — stop after emitting `close-issue`. - If an **open** fix PR already refs this issue (a) OR already fixes the same rooting @@ -688,7 +759,8 @@ If nothing was committed (count `0`) → `skipped: no commit produced` and stop. > **Dry-run gate:** if `dry_run == "true"`, do NOT emit. Print `DRY RUN — would open PR` > with base `main`, source branch `leak-fix/issue-`, the title, the full body, and -> `git --no-pager diff --stat "origin/main..HEAD"`, then stop. +> `git --no-pager diff --stat "origin/main..HEAD"`, then stop. (Enforced job-side too: +> `safe-outputs.staged` stages, and does not open, `create-pull-request` when `dry_run` is true.) Emit exactly one `create-pull-request` safe-output. Do NOT set a `base` field (the `base-branch: main` config pins it). Source `branch` MUST be `leak-fix/issue-`. Title MUST From 17aa8848cb7079e7b55715b7d3f24424d7e6d978 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:08:04 +0200 Subject: [PATCH 27/68] Address remaining review feedback on leak dedup (regex, cache staleness, job-enforced gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leak-fixer.md: - Tighten the Fixes/Refs de-dup regex in gates (a), (b), and (d) from a loose "(Fixes|Refs)[^0-9]*#N" to line-anchored "Fixes #N" plus exact-repo "Refs: #N", so cross-repo references (e.g. "Refs: other/repo#123") and negated text ("Does not Fixes #123") can no longer be mistaken for a dedup match. - Rewrite gate (c) (open PR same-API check) to canonicalize each open PR title with the same last-Type.Member extraction used elsewhere and compare by exact string equality, instead of anchor-matching the raw title against the short API — fully-qualified titles (e.g. "Fix Microsoft.Maui.Controls.Picker.ItemsSource memory leak") now match correctly. - Rewrite gate (d) (closed-unmerged attempt cap) to count attempts by BOTH issue-number reference AND canonical API match (union, deduped by PR number), so a leak re-filed under a duplicate issue number still inherits its prior attempt count instead of resetting to 0. - Add Step 9.5, a final de-dup re-check run immediately before Step 10's PR emission, re-running the merged/open dedup checks so a fix opened or merged by another run during this run's (up to 120-minute) build/test cycle is caught before a duplicate PR is created. - Clarify in the Step 3 summary that an API-only match is identity of the rooting member, not identity of the leak — distinct retention mechanisms can share one Type.Member (e.g. GradientBrush.GradientStops in #36363 vs #36743) — and require a mechanism comparison before skipping on an API-only match; a direct issue-reference match remains an unconditional skip. - Confirmed `/tmp/gh-aw/agent` is already created in Step 0 (predates this PR) and covers every later write in the job; no code change needed there. daily-leak-hunter.md: - Move the open-[leak-scan]-issue and merged-[leak-fix]-PR de-dup fetch out of the in-prompt agent bash block and into a new `pre-agent-steps` job step that runs before the MCP gateway/sandbox starts, with `set -euo pipefail`. A `gh` failure there now fails the GitHub Actions step (and the job) before the agent ever starts, instead of only surfacing as a tool-call error the agent could route around and still emit a `create-issue` safe-output from. - In that same step, detect the GitHub Search API's 1000-result ceiling on the merged-PR search (`gh pr list --search` truncates silently at 1000 regardless of `--limit`) and fail closed if the raw fetch returns >= 1000 rows, since this scanner runs on a permanent schedule and history will keep growing. - Also cross-reference each already-merged [leak-fix] PR against merged "Revert" PRs (using GitHub's standard `Reverts /#` body line) and exclude any that were later reverted from the permanent-proof set, so a reverted fix is treated as re-filable again instead of permanently suppressing its API. - Update the Step 2 prompt body to read the pre-fetched files instead of re-running the fetch in-agent, and document the new semantics (revert-exclusion) of already-merged-fix-apis.{tsv,txt}. Both workflows recompiled cleanly with `gh aw compile --strict` (0 warnings) and the regenerated .lock.yml files are included. Verified the new jq/awk logic (regex anchoring, canonical-API extraction, revert exclusion, 1000-row cap detection) against synthetic fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/daily-leak-hunter.lock.yml | 9 +- .github/workflows/daily-leak-hunter.md | 187 ++++++++++++------- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 161 +++++++++++++--- 4 files changed, 267 insertions(+), 92 deletions(-) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 13143daae559..f348581380eb 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"a1e644d4df38688f7e219d87b0ccb7f1e6d67ffb6651fd34602c27dc44216561","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":"741d15950598b701fc9868e81a72efd320810957e948a1a14f0179af502fca45","body_hash":"16f2e9fd94e2171052a745c5410b4f4362f4ea53cdf7d440bd33f90755476d9c","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 # @@ -501,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 200 --json number,title,body \\\n > /tmp/gh-aw/agent/my-open-leakscan.json\njq -r '.[].title | gsub(\"[\\r\\n]+\";\" \")' /tmp/gh-aw/agent/my-open-leakscan.json \\\n | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button always creates a PR whose body contains an exact\n# \"Reverts /#\" line pointing at the original PR — cross-reference that\n# against every already-merged-fix PR# and drop any that were reverted from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /tmp/gh-aw/agent/merged-revert-prs.json\nREPO_RE=$(printf '%s' \"$GITHUB_REPOSITORY\" | sed -E 's/[][(){}.^$*+?|\\\\]/\\\\&/g')\n: > /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt\nwhile IFS=$'\\t' read -r API PR BASE URL TITLE; do\n if jq -e --arg repo \"$REPO_RE\" --arg n \"$PR\" \\\n '[.[] | select((.body // \"\") | test(\"(^|\\n)[ \\t]*Reverts *\"+$repo+\"#\"+$n+\"\\\\b\"))] | length > 0' \\\n /tmp/gh-aw/agent/merged-revert-prs.json > /dev/null; then\n echo \"$PR\" >> /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt\n fi\ndone < /tmp/gh-aw/agent/already-merged-fix-apis.tsv\nif test -s /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt; then\n echo \"excluding 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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 3a4ce92a19fe..f58ef03b570f 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -67,6 +67,107 @@ tools: checkout: fetch-depth: 50 +# 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 200 --json number,title,body \ + > /tmp/gh-aw/agent/my-open-leakscan.json + jq -r '.[].title | gsub("[\r\n]+";" ")' /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] } }' \ + | 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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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's "Revert" button always creates a PR whose body contains an exact + # "Reverts /#" line pointing at the original PR — cross-reference that + # against every already-merged-fix PR# and drop any that were reverted from the + # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). + gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"Revert" in:title' --json number,title,body,mergedAt \ + > /tmp/gh-aw/agent/revert-prs-raw.json + jq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \ + > /tmp/gh-aw/agent/merged-revert-prs.json + REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') + : > /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt + while IFS=$'\t' read -r API PR BASE URL TITLE; do + if jq -e --arg repo "$REPO_RE" --arg n "$PR" \ + '[.[] | select((.body // "") | test("(^|\n)[ \t]*Reverts *"+$repo+"#"+$n+"\\b"))] | length > 0' \ + /tmp/gh-aw/agent/merged-revert-prs.json > /dev/null; then + echo "$PR" >> /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt + fi + done < /tmp/gh-aw/agent/already-merged-fix-apis.tsv + if test -s /tmp/gh-aw/agent/reverted-fix-pr-numbers.txt; then + echo "excluding 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 @@ -151,81 +252,35 @@ themselves — duplicating those is explicitly fine. A merged generated fix is d 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. -Fetch this scanner's own open `[leak-scan]` issues (they are filed with the `agentic-workflows` -label), then fetch merged generated fixes and extract the **rooting API** each artifact covers: +**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 -# Each bash call is a fresh subshell and the gh-aw runtime pre-creates only -# /tmp/gh-aw and /tmp/gh-aw/safeoutputs — NOT /tmp/gh-aw/agent. Create it before -# the first redirect below, otherwise that redirect fails and the run aborts -# before any de-dup logic runs (the /tmp filesystem persists across the later -# subshells, so one mkdir here covers every /tmp/gh-aw/agent write in this job). -mkdir -p /tmp/gh-aw/agent -if ! 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; then - echo "ERROR: 'gh issue list [leak-scan]' failed — aborting so a transient error can't empty the already-filed set and re-file duplicate scanner issues (fail-closed)." >&2 - exit 1 -fi -# 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 | gsub("[\r\n]+";" ")' /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] } }' \ - | sort -u \ - > /tmp/gh-aw/agent/already-filed-apis.txt echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt - -# Fetch only this workflow family's exact generated PRs that are already merged into one of -# the two active source-flow branches. Trust live baseRefName, not a stale "Target branch:" -# line in the PR body (PRs can be retargeted before merge). -# Fail-closed: if the fetch errors (transient API/auth/rate-limit) it writes nothing, and jq on -# an empty pipe still emits [] with exit 0 — the de-dup would then wrongly conclude "no merged -# fix exists" and re-file a duplicate. Split the fetch from the filter and abort on failure. -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 so a transient API/auth/rate-limit error can't empty the merged-fix set and re-file duplicate leaks (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 - -# Keep the canonical API first so every candidate can be checked with grep -Fx before its -# throwaway repro is written. Preserve PR metadata for a clear skip diagnostic. -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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') - 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 +echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.tsv ``` +- `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). A merged PR that was **later reverted** (via + GitHub's Revert button, detected from the standard `Reverts /#` line it + writes into the revert PR body) is already excluded from both files — it is treated as a + re-filable candidate again, not permanent proof the fix is still active. + - 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, OR it appears in `already-merged-fix-apis.txt`. **Check this for EVERY candidate before you write its test.** -- Normalize each candidate with the same last-`Type.Member` extraction above, then use +- Normalize each candidate with the same last-`Type.Member` extraction convention (LAST dotted + `Type.Member` pair of the first identifier chain in the title/name — e.g. a fully-qualified + `Microsoft.Maui.Controls.Picker.ItemsSource` yields `Picker.ItemsSource`), 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index cf3d15bb0ef6..80d641ada300 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":"28f6b01c2e67e9f2806a8d3cc7a344ba82fffa79a63c26dfd9a2d1b15d60490a","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":"b2eed20c93dea3b323add93332da77e528c31456f9a3b7e23963e80d9c7ee255","body_hash":"0fb65627a05734c6ef5681a581072f2c62e93b02009e8a0630d1b0023bade689","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 711837b3ea54..221843640ccd 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -384,9 +384,8 @@ API=$(printf '%s' "$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] } }') 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') +# Escape the repo slug (repo names may contain '.' / '-') for the exact `Refs:` match below. +REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') # (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 @@ -422,8 +421,13 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ # 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. -jq --arg n "$N" '[.[] | - select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ +# 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' \ @@ -443,17 +447,21 @@ if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ 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" '[.[] | +jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(.title | startswith("[leak-fix] ")) | - select((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b"))]' \ + 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 # (c) Open [leak-fix] PR already fixing the SAME rooting Type.Member (any issue number)? -# [leak-fix] PR titles are "Fix . memory leak". -# Guard: if $API is empty (issue title had no Type.Member chain), $API_RE is empty and the -# test() regex collapses to "^\[leak-fix\] +Fix +([. ]|$)", which false-matches unrelated -# PRs like "[leak-fix] Fix .NET …" and would wrongly skip this fix — so only scan when set. +# Canonicalize each open PR title with the SAME last-Type.Member 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 test -n "$API"; then if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ --search '"[leak-fix]" in:title' \ @@ -462,17 +470,24 @@ if test -n "$API"; 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 --arg api "$API_RE" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select(.title | test("^\\[leak-fix\\] +Fix +"+$api+"([. ]|$)"))]' \ + jq -r '.[] | 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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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 else - echo "target rooting API is empty (issue #$N title has no Type.Member chain) — skipping same-API dedup so an empty regex can't false-match unrelated [leak-fix] PRs." >&2 + echo "target rooting API is empty (issue #$N title has no Type.Member chain) — skipping same-API dedup so an empty key can't false-match unrelated [leak-fix] PRs." >&2 echo '[]' > /tmp/gh-aw/agent/same-api-prs.json fi jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/same-api-prs.json -# (d) Closed-unmerged attempts for this issue (attempt cap = 3). +# (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' \ @@ -481,21 +496,51 @@ if ! gh pr list --repo "$GITHUB_REPOSITORY" --state closed --limit 1000 \ 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 -jq --arg n "$N" '[.[] | - select(.title | startswith("[leak-fix] ")) | - select(((.body // "") | test("(Fixes|Refs)[^0-9]*#"+$n+"\\b")) and (.mergedAt == null))]' \ +jq '[.[] | select(.title | startswith("[leak-fix] ")) | select(.mergedAt == null)]' \ /tmp/gh-aw/agent/closed-fix-prs-raw.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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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 `jq 'length' merged-issue-fix-prs.json` is greater than `0` OR - `merged-api-fix-prs.tsv` has at least one row (a) → record +- 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 an **open** fix PR already refs this issue (b) OR already fixes the same rooting - `Type.Member` (c) → `skipped: leak already being fixed` and stop (or move to the next - automatic candidate). Also double-check the leak isn't already fixed on `main` (Step 6 gate). +- If `merged-api-fix-prs.tsv` has at least one row but NO direct issue-reference match above — + the match is only on the canonical `Type.Member`, which is **API identity, not leak + identity**: distinct retention mechanisms can legitimately share one rooting member (e.g. + `GradientBrush.GradientStops` has both a no-detach-teardown subscription leak, #36363, and a + separate `Clear()`/Reset unsubscribe-miss leak, #36743 — a fix for one does not fix the + other). Before skipping on an API-only match, open the matched PR and compare its stated + retention path (root → … → transient) against THIS issue's retention path. Skip only if the + mechanism is the same (`skipped: equivalent fix already merged via # to `); + if the mechanism differs, this is a distinct leak — proceed with Steps 4–10 and cite the + API-match PR in your own PR body so a human reviewer can double-check. +- If an **open** fix PR already refs this issue (b) → `skipped: leak already being fixed` and + stop (or move to the next automatic candidate). +- If an open fix PR already fixes the same rooting `Type.Member` with no direct issue + reference (c) → apply the SAME retention-mechanism check as the merged-API case above before + skipping; a same-API open PR that targets a different mechanism is not a duplicate. + Also double-check the leak isn't already fixed on `main` (Step 6 gate). - 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). @@ -647,6 +692,74 @@ 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 — Re-check de-dup immediately before emitting (snapshots go stale) + +The Step 3 merged/open snapshots were 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, so re-run the SAME fail-closed merged/open checks from Step 3 right +here — the last possible point before `create-pull-request` — using the same `$N`, `$API`, +and `$REPO_RE` from Step 3, and no-op on a new match instead of emitting a duplicate PR. + +```bash +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 +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 --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 +FINAL_MERGED_API_HIT=$(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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + test -n "$API" && test "$PR_API" = "$API" && echo "$PR" + done | wc -l | tr -d ' ') + +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --search '"[leak-fix]" in:title' \ + --json number,title,body \ + > /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 +jq '[.[] | 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 +FINAL_OPEN_API_HIT=$(jq -r '.[] | [.number, .title] | @tsv' /tmp/gh-aw/agent/final-open-fix-prs.json \ + | while IFS=$'\t' read -r PR TITLE; do + PR_API=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + test -n "$API" && test "$PR_API" = "$API" && echo "$PR" + done | wc -l | tr -d ' ') + +echo "final re-check: merged issue-ref=$(jq 'length' /tmp/gh-aw/agent/final-merged-issue-fix-prs.json) merged API=$FINAL_MERGED_API_HIT open issue-ref=$(jq 'length' /tmp/gh-aw/agent/final-open-issue-fix-prs.json) open API=$FINAL_OPEN_API_HIT" +``` + +If ANY of the four counts above is greater than `0` → an equivalent fix was opened or merged +while this run's build/test cycle was in progress. Record +`skipped: equivalent fix opened or merged during this run's build/test window` and stop — +do NOT proceed to Step 10. + ## 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` From 3d5654f081109481af28cc41f1eb7d2db7995fff Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:00:58 +0200 Subject: [PATCH 28/68] Clarify leak fixer dry-run scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 208c7259e901..94cadde8e16e 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":"111ed8a5c18f319d3980f441e6b88b373833a122f6fda21197d704c3befd4b1a","body_hash":"513eb151708d1e209c16f78854ce9a51589360d8dd7475c2c6dd71313e55b1e5","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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"bd50914dcba4dd8f72c4144d80c7e013a606cbb1403e13ea8de2e2e893565b3c","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 3be3363d8868..a22910c48686 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -99,15 +99,17 @@ network: - "*.blob.core.windows.net" safe-outputs: - # Job-enforced dry-run: when `dry_run` is true, gh-aw stages every safe-output below (writes - # nothing, only logs/records what WOULD have been emitted) regardless of what the agent does. + # Job-enforced dry-run: when `dry_run` is true, gh-aw stages every task safe-output below + # (only logs/records what WOULD have been emitted) regardless of what the agent does. # The per-step "Dry-run gate" prompt text further down is a courtesy for a clean run log, but # it is NOT what makes dry_run safe — an agent that emits close-issue/create-pull-request/etc. # anyway (ignoring the prompt) must still produce NO write. Making this an emitted-tool-call # decision inside the prompt (as it was before) leaves the actual guarantee entirely up to - # model compliance; `staged` moves the guarantee into the generated job itself, so a manual - # preview run (`dry_run: true`) can NEVER close an issue, open/push a PR, or add a comment. Keep - # this identical to the pattern already used by ci-status-main.md / ci-status-net11.md. + # model compliance; `staged` moves the task-output guarantee into the generated jobs, so a + # manual preview run (`dry_run: true`) cannot close an issue, open/push a PR, or add a comment. + # gh-aw's separate conclusion job may still report framework diagnostics when a run is + # incomplete or fails. Keep this identical to the pattern already used by + # ci-status-main.md / ci-status-net11.md. staged: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} create-pull-request: # No fixed title-prefix: the agent writes the FULL title starting with "[leak-fix] " @@ -237,11 +239,11 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch - `issue_number` (optional): if set, SKIP Track C and target exactly that `[leak-scan]` issue (Track A). If blank (e.g. the scheduled run), do Track C first (Step R), then auto-pick a `[leak-scan]` target in Step 2. -- `dry_run` (optional, default false): if `"true"`, do the full local work but **emit no +- `dry_run` (optional, default false): if `"true"`, do the full local work but **emit no task safe-output** — print what you *would* push/comment/open/close to the run log instead. This is - now backed by `safe-outputs.staged` in the frontmatter, so even an emitted safe-output call - writes nothing on a `dry_run` run — the printing above is for a clean, readable log, not the - enforcement mechanism. + backed by `safe-outputs.staged` in the frontmatter, so emitted PR/comment/issue-close task + outputs are staged on a `dry_run` run. The separate gh-aw conclusion job may still create a + framework diagnostic issue if the run is incomplete or fails. ```bash mkdir -p /tmp/gh-aw/agent From f59f6dae5339d3d17c19542a34a2d11f4d0f3480 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Mon, 10 Aug 2026 17:25:31 +0200 Subject: [PATCH 29/68] Harden leak workflow provenance and de-dup Use exact scan-issue provenance for destructive closure, distinguish retention mechanisms, bound hunter suppression to fixes absent from the shipped package, and avoid unnecessary compare calls and shared sentinel state. Add targeted Pester fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 137 ++++++++++ .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 201 +++++++------- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 267 +++++++++++-------- 5 files changed, 402 insertions(+), 207 deletions(-) create mode 100644 .github/scripts/LeakWorkflowPrompt.Tests.ps1 diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 new file mode 100644 index 000000000000..a293a863399d --- /dev/null +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -0,0 +1,137 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester + +Describe 'Memory leak workflow provenance and safety' { + BeforeAll { + $workflowRoot = Join-Path $PSScriptRoot '../workflows' + $fixerPath = Join-Path $workflowRoot 'leak-fixer.md' + $hunterPath = Join-Path $workflowRoot 'daily-leak-hunter.md' + $fixerLockPath = Join-Path $workflowRoot 'leak-fixer.lock.yml' + $hunterLockPath = Join-Path $workflowRoot 'daily-leak-hunter.lock.yml' + $actionsLockPath = Join-Path $PSScriptRoot '../aw/actions-lock.json' + + $script:fixer = Get-Content -LiteralPath $fixerPath -Raw + $script:hunter = Get-Content -LiteralPath $hunterPath -Raw + $script:fixerLock = Get-Content -LiteralPath $fixerLockPath -Raw + $script:hunterLock = Get-Content -LiteralPath $hunterLockPath -Raw + $actionsLock = Get-Content -LiteralPath $actionsLockPath -Raw | ConvertFrom-Json + $setupEntry = $actionsLock.entries.PSObject.Properties | + Where-Object Name -Like 'github/gh-aw-actions/setup@*' | + Select-Object -First 1 + $script:compilerVersion = $setupEntry.Value.version + + function Get-FixesScanIssueNumber { + param( + [Parameter(Mandatory)] + [string] $Body, + + [Parameter(Mandatory)] + [string] $Repository + ) + + $repoPattern = [Regex]::Escape($Repository) + $match = [Regex]::Match( + $Body, + "(?im)\bFixes\b:?[ \t]*(?:(?:$repoPattern)#|(?[^>]+?)\s*-->') + + if ($match.Success) { + return $match.Groups['key'].Value.Trim() + } + + return $null + } + } + + It 'accepts only exact bare or same-repository Fixes provenance' -ForEach @( + @{ Body = 'Fixes #123'; Expected = 123 } + @{ Body = 'Fixes: #124'; Expected = 124 } + @{ Body = 'Fixes dotnet/maui#125'; Expected = 125 } + @{ Body = 'Refs: dotnet/maui#126'; Expected = $null } + @{ Body = 'Fixes dotnet/runtime#127'; Expected = $null } + @{ Body = 'Text owner/repo#128 without a closing keyword'; Expected = $null } + ) { + Get-FixesScanIssueNumber -Body $Body -Repository 'dotnet/maui' | + Should -Be $Expected + } + + It 'keeps two mechanisms on the same API as distinct leak identities' { + $collectionLeak = Get-LeakScanKey '' + $selectionLeak = Get-LeakScanKey '' + + $collectionLeak | Should -Be 'Picker.ItemsSource|collectionchanged-retains-picker' + $selectionLeak | Should -Be 'Picker.ItemsSource|selectedindexchanged-handler-retains-picker' + $collectionLeak | Should -Not -Be $selectionLeak + Get-LeakScanKey 'legacy issue without a marker' | Should -BeNullOrEmpty + } + + It 'filters merged history before making compare API calls' { + $filterIndex = $fixer.IndexOf('# (d-prefilter)', [StringComparison]::Ordinal) + $compareIndex = $fixer.IndexOf('compare/main...$sha', [StringComparison]::Ordinal) + + $filterIndex | Should -BeGreaterThan -1 + $compareIndex | Should -BeGreaterThan $filterIndex + $fixer | Should -Not -Match ([Regex]::Escape( + "jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while")) + } + + It 'limits destructive closure to exact Fixes provenance' { + $fixer | Should -Match 'ONLY an exact same-repo Fixes #N match may drive close-issue' + $fixer | Should -Match 'merged-exact-fix-prs\.json' + $fixer | Should -Match 'A `Refs` line or Type\.Member match can never\s+authorize closure' + $fixer | Should -Not -Match 'already fixes this issue number OR the same rooting' + } + + It 'uses a candidate-keyed non-empty sentinel without adding rm' { + $fixer | Should -Match 'REOPEN_OVERRIDE_FILE="/tmp/gh-aw/agent/reopen-override-\$\{N\}\.txt"' + $fixer | Should -Match 'if test -s "\$REOPEN_OVERRIDE_FILE"' + $fixer | Should -Not -Match 'rm -f /tmp/gh-aw/agent/reopen-override' + $fixer | Should -Not -Match '/tmp/gh-aw/agent/reopen-override\.txt' + } + + It 'bounds hunter suppression to fixes absent from the shipped release' { + $versionMatch = [Regex]::Match($hunter, '(?m)^SHIPPED_MAUI_VERSION=(?[0-9][^\s]+)$') + $packageMatch = [Regex]::Match( + $hunter, + 'PackageReference Include="Microsoft\.Maui\.Controls" Version="(?[^"]+)"') + + $versionMatch.Success | Should -BeTrue + $packageMatch.Success | Should -BeTrue + $versionMatch.Groups['version'].Value | Should -Be $packageMatch.Groups['version'].Value + $hunter | Should -Match 'compare/\$SHIPPED_MAUI_VERSION\.\.\.\$sha' + $hunter | Should -Match 'open-leakscan-provenance\.json' + $hunter | Should -Match 'unshipped-main-fixes\.json' + $hunter | Should -Not -Match 'already-filed-apis\.txt' + $hunter | Should -Not -Match 'fixed-on-main-apis\.txt' + $hunter | Should -Not -Match '"title:"' + } + + It 'carries a canonical leak identity across scan issues and fix PRs' { + $hunter | Should -Match '' + $fixer | Should -Match '' + $fixer | Should -Match 'scan issue''s marker/title/body' + $hunter | Should -Match 'never infer leak\s+# identity from the independently-authored PR title' + } + + It 'keeps generated locks synchronized with the current dynamic compiler version and prompt' { + $compilerVersion | Should -Not -BeNullOrEmpty + $fixerLock | Should -Match ([Regex]::Escape("""compiler_version"":""$compilerVersion""")) + $hunterLock | Should -Match ([Regex]::Escape("""compiler_version"":""$compilerVersion""")) + $fixerLock | Should -Match '"body_hash":"[0-9a-f]{64}"' + $hunterLock | Should -Match '"body_hash":"[0-9a-f]{64}"' + $hunterLock | Should -Not -Match 'fixed-on-main-apis\.txt' + } +} diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index b2afeb1d06a6..cc30c8ab1c65 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"1ad19af593945448f42873b6d6598260311fec92da5505e3df7f3f24588a9920","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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"d8be38b67c411cbd123e173032dd8f8db292f9644377d93ef2dd0f91adf57834","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index eed1219ebb0c..183db0a76f66 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -122,15 +122,15 @@ Never push, never open a PR, never comment, never edit product or test code in t `ConditionalWeakTable`, `WeakReference`, or any `Weak*Proxy`, it does not leak — move on. 5. **De-dup against THIS SCANNER's own OPEN issues AND leaks already fixed on `main`.** Before filing, fetch this workflow's open `[leak-scan]` issues and skip a leak already covered by one - (same rooting API / retention path). ALSO skip any leak whose rooting `Type.Member` is already - fixed on `main` by a **merged `[leak-fix]` PR** (Step 2 builds this set — a merged fix PR is - durable, workflow-owned proof a fix landed; a close *reason* alone is not). Such leaks still - reproduce against the SHIPPED package until the fix ships, so the empirical test alone can't - tell — this provenance-gated de-dup is the only guard. Do NOT suppress a candidate because + (same rooting API / retention path). ALSO skip the same retention path when Step 2 proves a + workflow-owned `[leak-fix]` PR landed on `main` but is not yet contained in the pinned shipped + package. A Type.Member match alone is insufficient: a later regression or second mechanism on + the same property remains eligible. A close *reason* alone is not proof of a fix. Such + unshipped fixes still reproduce against the package, so the empirical test alone cannot tell; + the issue-linked provenance is the guard. 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 with **no merged `[leak-fix]` PR** - (any close reason) may be re-filed if it still reproduces; one whose leak is fixed on `main` by - a merged `[leak-fix]` PR may not. + (any close reason) may be re-filed if it still reproduces. 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. @@ -151,115 +151,110 @@ filed. You do **not** care about AdamEssenmacher's repro branches or anyone else duplicating those is explicitly fine. 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: +label) and retain their full leak identity: ``` 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 -echo "already-filed rooting APIs:"; cat /tmp/gh-aw/agent/already-filed-apis.txt - -# ── ALSO skip leaks ALREADY FIXED on main — MERGED [leak-fix] PRs ONLY ─────────────────────── -# Your Step 5 confirmation runs against the SHIPPED NuGet package (pinned 10.0.0), where an -# already-MERGED fix has NOT shipped yet — so a leak that is fixed on `main` STILL reproduces -# (goes red) and would be re-filed every run forever. De-dup is the ONLY guard. Build the -# "fixed-on-main" rooting-Type.Member set from **MERGED `[leak-fix]` PR titles ONLY** — a merged -# fix PR is durable, workflow-owned provenance that a fix ACTUALLY LANDED on `main`. The query is -# scoped to `label:agentic-workflows`, so ONLY this workflow's own merged fixes count (a manually -# created `[leak-fix]`-titled PR is ignored). Do NOT trust -# an issue's close *reason*: a `[leak-scan]` issue closed as COMPLETED records only that SOMEONE -# closed it, not that a fix merged (a maintainer can close a still-reproducing issue as -# completed), so treating "closed as completed" as fixed would permanently suppress a still-valid -# leak. Worst case here (a human-fixed leak with no [leak-fix] PR) is a single bounded re-file -# that the OPEN-issue de-dup above then catches — far safer than permanent data loss. -# --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If the -# merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED: because Step 5 tests the SHIPPED -# package (where a merged-but-unshipped fix still reproduces), a dropped-out fix would be re-filed -# once (then the OPEN-issue de-dup catches it), so warn loudly if we ever reach the ceiling. +# The rooting API is the LAST dotted Type.Member pair of the first identifier chain. Preserve the +# issue title/body and canonical marker too: API equality is only a prefilter, because two +# different retention paths can share one property. +jq ' + def titleapi: + [(.title // "") | scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] + | if length > 0 then (.[0] | split(".") | .[-2:] | join(".")) else null end; + def leakkey: + [(.body // "") | capture("(?i)").key] + | if length > 0 then .[0] else null end; + [.[] | {scan_issue:., rooting_api:titleapi, leak_scan_key:leakkey}] +' /tmp/gh-aw/agent/my-open-leakscan.json > /tmp/gh-aw/agent/open-leakscan-provenance.json +jq -r '.[] | "open scan #\(.scan_issue.number): API \(.rooting_api // ""), key \(.leak_scan_key // "")"' \ + /tmp/gh-aw/agent/open-leakscan-provenance.json + +# ── ALSO skip the SAME leak while its merged fix is on main but NOT in the shipped package ─── +# Step 5 tests the shipped package pinned below. A fix newer than that package still reproduces +# there, so provenance must suppress it temporarily. Suppression MUST end once the pinned release +# contains the fix, and MUST distinguish two retention paths rooted at the same Type.Member. +# +# The durable cross-workflow join is the exact same-repo `Fixes #` line copied into +# every [leak-fix] PR body. Resolve that issue and retain its full title/body; never infer leak +# identity from the independently-authored PR title. New scan issues also carry a canonical +# `leak-scan-key` marker, while legacy issues without the marker remain comparable from their +# original title/body. A Type.Member match alone is only a candidate for semantic comparison — +# it is NEVER sufficient to suppress a different retention mechanism. +SHIPPED_MAUI_VERSION=10.0.0 +printf '%s\n' "$SHIPPED_MAUI_VERSION" > /tmp/gh-aw/agent/shipped-maui-version.txt +REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ - --limit 1000 --json title,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json + --limit 1000 --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then - echo "WARNING: fixed-on-main provenance hit the 1000 search-API ceiling; older merged [leak-fix] fixes may be TRUNCATED and their leaks could be re-filed once — switch to date-windowed enumeration." + echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older unshipped fixes may be TRUNCATED and re-filed once — switch to date-windowed enumeration." fi -# A "[leak-fix]" PR being MERGED only proves the fix landed on ITS base branch — NOT necessarily -# `main`. This workflow's safe-outputs always OPEN the fix PR against `main`, but a merge can -# still land the SAME commit content on a forward-integration branch (e.g. `inflight/current`) -# instead, either via a later retarget or a merge-queue rebase; #36370 is a concrete example -# (title/label-matches this query, `mergedAt` is set, but its merge commit is on -# `inflight/current`, not `main`). Trusting merge-state alone would suppress a leak that still -# reproduces on `main`. Verify each merge commit is ACTUALLY an ancestor of `main` via the GitHub -# compare API (`ahead_by == 0` ⇒ the merge commit is fully contained in `main`'s history) — the -# SAME ancestry check the release-readiness skill uses (see -# `.github/skills/release-readiness/references/methodology.md`) — instead of relying on -# `--state merged` / `--base` alone (a local `fetch-depth: 50` checkout is too shallow to answer -# this reliably with local git, and `--base` only reflects the PR's declared base, not where the -# merge commit ultimately landed). -: > /tmp/gh-aw/agent/merged-onmain.ndjson + +printf '' > /tmp/gh-aw/agent/unshipped-main-fixes.ndjson jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") [ -z "$sha" ] && continue - ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" - if [ "$ahead" = "0" ]; then - printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain.ndjson - fi + # Only workflow PRs with an exact SAME-REPO Fixes reference have scan provenance. A Refs line + # points at a separate upstream issue and an arbitrary cross-repo #N must never become a join. + scan_n=$(jq -r --arg repo "$REPO_RE" ' + [(.body // "") | scan("(?i)\\bFixes\\b:?[ \t]*(?:"+$repo+"#|[^0-9A-Za-z_/]#)([0-9]+)\\b")] + | if length > 0 then .[0][0] else empty end' <<<"$pr") + [ -z "$scan_n" ] && continue + + # The merge commit must be contained in main, but not in the shipped release tag. If either + # compare cannot be verified, fail open (do not suppress): a bounded duplicate is safer than + # permanently hiding a real leak. + on_main=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || on_main="" + in_shipped=$(gh api "repos/$GITHUB_REPOSITORY/compare/$SHIPPED_MAUI_VERSION...$sha" -q '.ahead_by' 2>/dev/null) || in_shipped="" + [ "$on_main" != "0" ] && continue + [ -z "$in_shipped" ] && continue + [ "$in_shipped" = "0" ] && continue + + issue=$(gh issue view "$scan_n" --repo "$GITHUB_REPOSITORY" --json number,title,body,state 2>/dev/null) || issue="" + [ -z "$issue" ] && continue + api=$(jq -r '.title // ""' <<<"$issue" \ + | 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] } }') + leak_key=$(jq -r '(.body // "") | capture("(?i)").key // empty' <<<"$issue") + jq -n --argjson pr "$pr" --argjson issue "$issue" --arg api "$api" --arg key "$leak_key" \ + '{pull_request:{number:$pr.number,title:$pr.title,mergedAt:$pr.mergedAt,mergeCommit:$pr.mergeCommit}, + scan_issue:$issue, rooting_api:$api, + leak_scan_key:($key | if . == "" then null else . end)}' \ + >> /tmp/gh-aw/agent/unshipped-main-fixes.ndjson done -if [ -s /tmp/gh-aw/agent/merged-onmain.ndjson ]; then - jq -s -r '.[].title' /tmp/gh-aw/agent/merged-onmain.ndjson > /tmp/gh-aw/agent/merged-leakfix-titles.txt +if [ -s /tmp/gh-aw/agent/unshipped-main-fixes.ndjson ]; then + jq -s '.' /tmp/gh-aw/agent/unshipped-main-fixes.ndjson > /tmp/gh-aw/agent/unshipped-main-fixes.json else - : > /tmp/gh-aw/agent/merged-leakfix-titles.txt + echo '[]' > /tmp/gh-aw/agent/unshipped-main-fixes.json fi -# awk (not grep) as the leading filter: grep exits 1 when nothing matches, which under the -# runner's `set -eo pipefail` would abort this step on the common "no merged [leak-fix] PRs yet" -# state (empty provenance is valid — it just means nothing is fixed-on-main yet). awk always -# exits 0 and yields an empty fixed-on-main-apis.txt, which is the intended no-op. -awk '/^\[leak-fix\] /' /tmp/gh-aw/agent/merged-leakfix-titles.txt | sed -E 's/^\[leak-fix\] *(Fix +)?//' \ - | 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 { - # Off-contract title with no dotted Type.Member: keep a durable normalized-title - # fallback key (prefixed "title:") instead of SILENTLY DROPPING it, so the known-fixed - # leak still surfaces with a de-dup identity for the agent to match against. - key=tolower($0); gsub(/[^a-z0-9]+/, "-", key); gsub(/^-+|-+$/, "", key); - if (key != "") print "title:" key - } - }' \ - | sort -u \ - > /tmp/gh-aw/agent/fixed-on-main-apis.txt -echo "fixed-on-main (do NOT re-file) rooting APIs:"; cat /tmp/gh-aw/agent/fixed-on-main-apis.txt +jq -r '.[] | "unshipped main fix: PR #\(.pull_request.number), scan #\(.scan_issue.number), API \(.rooting_api // ""), key \(.leak_scan_key // "")"' \ + /tmp/gh-aw/agent/unshipped-main-fixes.json ``` -- 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 is **ALSO OUT** if its rooting `Type.Member` is in `fixed-on-main-apis.txt` - (already fixed on `main` by a **merged `[leak-fix]` PR**). The shipped-package test in Step 5 - will STILL go red for these because the fix hasn't shipped in a release yet — that is expected. - Do NOT re-file: the fix is already on `main` and will ship. For an **off-contract candidate - whose title has no dotted `Type.Member`**, apply the SAME normalization the merged-PR list uses - (lower-case, replace each run of non-alphanumerics with `-`, trim leading/trailing `-`) and - treat the candidate as OUT if `title:` is in `fixed-on-main-apis.txt`. That is how the - `title:` fallback keys are matched, so off-contract fixed leaks aren't re-filed either. +- A candidate is **OUT** when an entry in `open-leakscan-provenance.json` covers the same leak. + Narrow by rooting `Type.Member`; a matching non-empty `leak-scan-key` is sufficient. Otherwise + compare the full title/body and confirm the same publisher/event/collection and retention path. + A same-API issue with a different mechanism does not block the candidate. **Check this for + EVERY candidate before you write its test** — re-filing the same retention path is the #1 + failure mode, but over-collapsing distinct paths loses real regressions. +- A candidate is **ALSO OUT** only when an entry in `unshipped-main-fixes.json` describes the + **same leak**, not merely the same API. First narrow by `rooting_api`; then require the same + non-empty `leak-scan-key`, or compare the referenced scan issue's title/body and confirm the + same publisher/event/collection and the same + `root -> ... -> transient` retention edge. If the Type.Member matches but the retention + mechanism differs, the candidate remains eligible. Off-contract records are never matched by an + independently slugified PR title; use their referenced scan issue title/body directly. + These records contain only fixes that are on `main` and absent from the pinned shipped release, + so suppression expires automatically when `SHIPPED_MAUI_VERSION` advances to a tag containing + the fix. A candidate whose only prior `[leak-scan]` issue was **closed with no merged `[leak-fix]` PR** (closed as not planned — wontfix / invalid / duplicate — OR closed as completed by a maintainer without a landed fix) may be re-filed if it still reproduces: a close *reason* is not proof the -leak is gone. A candidate whose leak is in `fixed-on-main-apis.txt` (fixed on `main` by a merged -`[leak-fix]` PR) must **not** be re-filed. +leak is gone. A candidate matching an entry in `unshipped-main-fixes.json` must **not** be +re-filed while that exact fix is absent from the pinned package. # ===================== RUNTIME LEAK HUNT ===================== @@ -381,15 +376,21 @@ no MAUI source build, no emulator. 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, against -`fixed-on-main-apis.txt` (Step 2 — never re-file a leak already fixed on `main`), AND against the -other issues you're filing this run (no two issues for the same rooting API). Each title MUST be +`unshipped-main-fixes.json` (Step 2 — never re-file the same retention path while its fix is on +`main` but absent from the pinned package), AND against the other issues you're filing this run +(no two issues for the same retention path). 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. +ICommand.CanExecuteChanged retains the control`). Keep both the API and mechanism stable and +canonical — do not reword them run-to-run. Body (markdown): - A clear **AI-generated** banner naming this workflow. +- A hidden canonical marker immediately after the banner: + ``, where the slug is the lower-case + `` with each non-alphanumeric run replaced by `-` and leading/trailing `-` + removed. This marker is copied unchanged into the eventual `[leak-fix]` PR and is the durable + identity for this exact retention path; the Type.Member alone is not unique. - **Description** of the leak and why it retains. - **Retention path** `root -> ... -> transient` with file:line citations. - **Repro**: paste the standalone `leakprobe.csproj` + `LeakTest.cs` (it restores the shipped diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 94cadde8e16e..4bac96195992 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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"bd50914dcba4dd8f72c4144d80c7e013a606cbb1403e13ea8de2e2e893565b3c","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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"4c522f4bb8a037d9878903f7a0a335fa2b546a6f16c4fa75d9fdd796592a9c63","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index a22910c48686..301b0bf0bf2a 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -208,14 +208,14 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch 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.** Close the scan issue **only** - when a **merged** `[leak-fix]` PR either references this scan issue number (`Fixes`/`Refs #`) - OR already covers the same rooting `Type.Member` (Step 3 gate (d)) — that merged PR is the - provenance a fix actually landed. Emit a `close-issue` on the - `[leak-scan]` issue (AI-attributed comment linking the merged fix) and record - `skipped: already fixed on main`. If instead your freshly-authored regression test merely - passes on the *unpatched* source (Step 6) with **no** merged fix PR, that is NOT proof the - leak is gone — do NOT close it; record `skipped: test green on unpatched main (no PR, issue - left open)` and move on. + when a merged `[leak-fix]` PR on `main` explicitly carries the exact same-repo + `Fixes #` provenance (Step 3 gate (d)). A `Refs` line or Type.Member match can never + authorize closure. Emit `close-issue` with an AI-attributed comment linking that exact fix and + record `skipped: already fixed on main`. If a different scan issue describes the same + leak-scan-key/retention path, skip rebuilding but leave this issue open for manual + reconciliation. If instead your freshly-authored regression test merely passes on unpatched + source (Step 6) with no exact merged fix PR, that is NOT proof the leak is gone — do NOT close + it; record `skipped: test green on unpatched main (no PR, issue left open)` and move on. 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 @@ -391,19 +391,18 @@ Read the chosen issue's body in full (`gh issue view --json title,body`). Ex - the **rooting API** (e.g. `IndicatorView.ItemsSource`), - the **retention path** `root -> … -> transient` with the cited file:line(s), +- the canonical `` marker when present (legacy issues may not have one), - the **suggested fix** shape, and - any **non-default / disabling condition**. ## Step 2.5 — Fetch workflow-owned `[leak-fix]` PR sets ONCE per run (cache, don't re-query) -Step 3's gates (a)–(d) below all filter this SAME three-state `[leak-fix]` PR history with -per-candidate `jq` (keyed on `$N` / `$API`), but the raw open/closed/merged PR sets themselves do -NOT depend on `$N` or `$API` — only the filters do. If Step 2's auto-pick gates out one or more -candidates before landing on an actionable issue ("move to the next oldest" below), re-running -these THREE `gh pr list` searches for every candidate multiplies GitHub Search API pagination for -no reason and can burn the scheduled run before it reaches an actionable candidate. Fetch each -state ONCE here, cache it, and have Step 3 apply its `jq` filters against these SAME cached files -for every candidate instead of re-issuing the searches. +Step 3's gates below all filter this SAME three-state `[leak-fix]` PR history with per-candidate +`jq` (keyed on `$N` / `$API`), but the raw open/closed/merged PR sets themselves do NOT depend on +the candidate. Fetch each state ONCE here and cache it. Do NOT ancestry-check every merged PR at +this stage: Step 3 first narrows the raw cache to the handful matching the exact scan issue or +rooting API, then calls the compare API only for those survivors. This preserves the main-ancestry +guard without spending up to 1000 sequential REST calls before the first candidate is evaluated. ```bash # Open [leak-fix] PRs — gates (a) and (b) below both filter this SAME set (issue-ref match and @@ -422,7 +421,7 @@ gh pr list --repo "$GITHUB_REPOSITORY" --state closed --search '"[leak-fix]" in: if [ "$(jq 'length' /tmp/gh-aw/agent/closed-leakfix-all.json)" -ge 1000 ]; then echo "WARNING: closed [leak-fix] set hit the 1000 search-API ceiling; older attempts may be TRUNCATED and the 3-attempt cap could under-count for some issues — switch to date-windowed enumeration." fi -# MERGED [leak-fix] PRs — feeds gate (d), the destructive close path. +# MERGED [leak-fix] PRs — Step 3 first filters this raw set, then ancestry-checks only matches. # --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If # the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a # valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss @@ -430,65 +429,42 @@ fi gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then - echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Gate (d) stays fail-safe (under-closes) but close-coverage is incomplete — switch to date-windowed enumeration." + echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Exact-close remains fail-safe (under-closes) but coverage is incomplete — switch to date-windowed enumeration." fi -# A "[leak-fix]" PR being MERGED only proves the fix landed on ITS base branch — NOT necessarily -# `main`. This workflow's safe-outputs always OPEN the fix PR against `main`, but the SAME merge -# commit can still land on a forward-integration branch (e.g. `inflight/current`) rather than -# `main` (real example: #36370 — matches this title/label query, `mergedAt` is set, but its merge -# commit is on `inflight/current`, not `main`). Gate (d) CLOSES a live [leak-scan] issue as -# "already fixed on main" on this evidence, so trusting merge-state alone can produce a FALSE -# "already fixed" close while the leak still reproduces on `main`. Verify each merge commit is -# ACTUALLY an ancestor of `main` via the GitHub compare API (`ahead_by == 0` ⇒ the merge commit is -# fully contained in `main`'s history) — the SAME ancestry check the release-readiness skill uses -# (see `.github/skills/release-readiness/references/methodology.md`) — and drop any entry that -# fails it BEFORE gate (d) ever sees it, instead of relying on `--state merged` / `--base` alone -# (`--base` only reflects the PR's declared base, not where the merge commit ultimately landed). -: > /tmp/gh-aw/agent/merged-onmain.ndjson -jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do - sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") - [ -z "$sha" ] && continue - ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" - if [ "$ahead" = "0" ]; then - printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain.ndjson - fi -done -if [ -s /tmp/gh-aw/agent/merged-onmain.ndjson ]; then - jq -s '.' /tmp/gh-aw/agent/merged-onmain.ndjson > /tmp/gh-aw/agent/merged-leakfix-all.json -else - echo '[]' > /tmp/gh-aw/agent/merged-leakfix-all.json -fi -jq 'length' /tmp/gh-aw/agent/merged-leakfix-all.json ``` Do this ONCE at the top of the run, before evaluating any candidate. If Step 2 gates out the first candidate and moves to the next-oldest, do NOT re-run the fetches above — go straight to -Step 3 and re-apply its `jq` filters (below) to these SAME cached files for the new `$N` / `$API`. +Step 3 and re-apply its filters and targeted ancestry checks to the cached files for the new +`$N` / `$API`. ## Step 3 — De-dup + attempt cap (per-candidate filters against the Step 2.5 cache) A fix PR carries `Fixes #` in its body (where `` is the `[leak-scan]` issue) — that is the -sole scan-issue join / auto-close key. An optional `Refs: /#` line may also -be present, but it points at a SEPARATE pre-existing upstream issue, never at `#`. Because the -same underlying leak can still be filed under MULTIPLE issue numbers (duplicate `[leak-scan]` -issues, or a pre-existing upstream issue), also de-dup by the **rooting `Type.Member`** the target -names — never open a second fix for a leak already being fixed. (The gate (a) search below matches -`Fixes` **or** `Refs` against `#` for backward-compatibility with older fix PRs.) +sole scan-issue join and the ONLY key allowed to drive automatic closure. An optional +`Refs: /#` line points at a separate issue and can never authorize closing +`#`. Duplicate scan issues may still describe the same leak, so API matching is retained only +as a cheap prefilter. Before skipping work for a different issue number, compare its +`leak-scan-key` or full retention path; the Type.Member alone is not a leak identity. ```bash N= +gh issue view "$N" --repo "$GITHUB_REPOSITORY" --json number,title,body,state \ + > /tmp/gh-aw/agent/target-scan-issue.json # 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' \ +API=$(jq -r '.title // ""' /tmp/gh-aw/agent/target-scan-issue.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 }') + | 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] } }') +TARGET_LEAK_KEY=$(jq -r '(.body // "") | capture("(?i)").key // empty' \ + /tmp/gh-aw/agent/target-scan-issue.json) echo "target rooting API: $API" +echo "target leak key: ${TARGET_LEAK_KEY:-}" # Escape the owner/repo so it embeds literally in the jq test() calls below. The issue-ref match # requires "#" to be a BARE same-repo reference OR an explicit "/#" qualifier — # NEVER an arbitrary cross-repo "other/repo#". Otherwise an unrelated upstream ref such as -# "Refs: dotnet/runtime#5000" would satisfy a search for maui #5000 and let a foreign reference -# drive the destructive close path in gate (d) against a live [leak-scan] issue. +# "Refs: dotnet/runtime#5000" would satisfy a search for maui #5000. REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') # (a) Open [leak-fix] PR already addressing THIS issue number? Filters the Step 2.5 cache — no # new gh pr list call for this (or any later) candidate. @@ -496,52 +472,117 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(?i)\ /tmp/gh-aw/agent/open-leakfix-all.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)? +# (b-prefilter) Open [leak-fix] PRs naming the same rooting Type.Member (any issue number). # [leak-fix] PR titles lead with "Fix . ...". Normalize each PR title with the -# SAME last-dotted-pair extraction used for $API above (issue side) instead of anchoring the -# API right after "Fix " — that keys both sides identically, so a qualifier ("Fix Shell -# BackButtonBehavior.Command ...") or a fully-qualified title ("Fix -# Microsoft.Maui.Controls.Picker.ItemsSource ...") still matches the target Type.Member, and a -# deeper member ("Fix Picker.ItemsSource.Something") no longer false-matches Picker.ItemsSource. -# Filters the SAME Step 2.5 cache as (a) — still no new gh pr list call. -jq --arg apiplain "$API" 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.title // "") | titleapi) == $apiplain)]' \ +# same last-dotted-pair extraction used for $API. This is only a cheap prefilter: Step (b) +# below resolves each PR's exact Fixes scan issue and compares leak-scan-key / retention path +# before deciding to skip. API equality alone is never enough. +jq --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" ' + def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; + def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + [.[] | select((((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey)))]' \ /tmp/gh-aw/agent/open-leakfix-all.json \ - > /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 + > /tmp/gh-aw/agent/same-api-open-candidates.json # (c) Closed-unmerged attempts for this issue (attempt cap = 3). Filters the Step 2.5 cache. jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ /tmp/gh-aw/agent/closed-leakfix-all.json \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json -# (d) MERGED [leak-fix] PR already fixing this issue number OR the SAME rooting Type.Member? -# Merged fixes often reference an UPSTREAM issue (e.g. "Fixes #35775") instead of this -# [leak-scan] number, so GitHub never auto-closed this scan issue — leaving it to be -# re-picked and rebuilt from source every run. Detect the merged fix by the issue-ref OR the -# rooting Type.Member (each merged title normalized with the SAME last-dotted-pair extraction -# as $API, so qualified / fully-qualified titles key identically) so we can close this issue -# instead of rebuilding. Filters the Step 2.5 cache, which is ALREADY scoped to -# label:agentic-workflows (so only workflow-owned merged fixes count) and ALREADY restricted -# to merge commits verified as an ancestor of `main` (so a fix merged only into -# `inflight/current` or another non-`main` branch can never satisfy this gate). -jq --arg n "$N" --arg apiplain "$API" --arg repo "$REPO_RE" \ - 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; [.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) or (((.title // "") | titleapi) == $apiplain))]' \ - /tmp/gh-aw/agent/merged-leakfix-all.json \ - > /tmp/gh-aw/agent/merged-fix-prs.json -jq -r '.[] | "merged fix for this leak: #\(.number) \(.title)"' /tmp/gh-aw/agent/merged-fix-prs.json -# Reset the SHARED re-open override sentinel at the start of every candidate. It lives at a single -# fixed path (NOT keyed by $N) and is written only inside the merged-fix block below, while Step 3 -# may advance to the next-oldest candidate within the SAME run. Without this reset a sentinel -# written for an earlier candidate would leak forward and falsely gate a later one (phantom -# "maintainer override" / "unverified timeline"). Clear it so it reflects ONLY the current $N. -rm -f /tmp/gh-aw/agent/reopen-override.txt +# (d-prefilter) Narrow the raw merged cache BEFORE any compare API calls. Keep PRs that either +# carry the exact same-repo Fixes #N provenance (the only destructive-close key) or name the +# same rooting API (a non-destructive semantic de-dup candidate). +jq --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ + 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; + def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + [.[] | select(((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) + or (((.title // "") | titleapi) == $apiplain) + or (($targetkey != "") and (bodykey == $targetkey)))]' \ + /tmp/gh-aw/agent/merged-leakfix-raw.json \ + > /tmp/gh-aw/agent/merged-fix-candidates.json + +# A merged PR is usable only when its merge commit is actually contained in main. Compare only +# the candidate subset above, not the entire up-to-1000-entry history. A failed compare omits the +# entry (fail open: rebuild rather than close/skip on unverified ancestry). +printf '' > /tmp/gh-aw/agent/merged-onmain-candidates.ndjson +jq -c '.[]' /tmp/gh-aw/agent/merged-fix-candidates.json | while IFS= read -r pr; do + sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") + [ -z "$sha" ] && continue + ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" + if [ "$ahead" = "0" ]; then + printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain-candidates.ndjson + fi +done +if [ -s /tmp/gh-aw/agent/merged-onmain-candidates.ndjson ]; then + jq -s '.' /tmp/gh-aw/agent/merged-onmain-candidates.ndjson > /tmp/gh-aw/agent/merged-onmain-candidates.json +else + echo '[]' > /tmp/gh-aw/agent/merged-onmain-candidates.json +fi + +# (d-exact) ONLY an exact same-repo Fixes #N match may drive close-issue. A same Type.Member with a +# different issue number is not provenance for this retention path and remains non-destructive. +jq --arg n "$N" --arg repo "$REPO_RE" \ + '[.[] | select((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ + /tmp/gh-aw/agent/merged-onmain-candidates.json \ + > /tmp/gh-aw/agent/merged-exact-fix-prs.json +jq -r '.[] | "exact merged fix for scan issue: #\(.number) \(.title)"' \ + /tmp/gh-aw/agent/merged-exact-fix-prs.json + +# Enrich same-API open/merged candidates with the scan issue named by their exact Fixes line. +# This makes the scan issue's marker/title/body — not the independently-authored PR title — the +# cross-workflow leak identity. Legacy scan issues without a marker remain available for semantic +# retention-path comparison. Entries without valid same-repo scan provenance are ignored. +enrich_scan_provenance () { + input=$1 + output=$2 + printf '' > "$output" + jq -c '.[]' "$input" | while IFS= read -r pr; do + scan_n=$(jq -r --arg repo "$REPO_RE" ' + [(.body // "") | scan("(?i)\\bFixes\\b:?[ \t]*(?:"+$repo+"#|[^0-9A-Za-z_/]#)([0-9]+)\\b")] + | if length > 0 then .[0][0] else empty end' <<<"$pr") + [ -z "$scan_n" ] && continue + issue=$(gh issue view "$scan_n" --repo "$GITHUB_REPOSITORY" --json number,title,body,state 2>/dev/null) || issue="" + [ -z "$issue" ] && continue + key=$(jq -r '(.body // "") | capture("(?i)").key // empty' <<<"$issue") + jq -n --argjson pr "$pr" --argjson issue "$issue" --arg key "$key" \ + '{pull_request:$pr, scan_issue:$issue, + leak_scan_key:($key | if . == "" then null else . end)}' >> "$output" + done +} +enrich_scan_provenance /tmp/gh-aw/agent/same-api-open-candidates.json \ + /tmp/gh-aw/agent/same-api-open-fixes.ndjson +if [ -s /tmp/gh-aw/agent/same-api-open-fixes.ndjson ]; then + jq -s '.' /tmp/gh-aw/agent/same-api-open-fixes.ndjson > /tmp/gh-aw/agent/same-api-open-fixes.json +else + echo '[]' > /tmp/gh-aw/agent/same-api-open-fixes.json +fi +jq --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ + 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; + def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + [.[] | select((((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) | not) + and ((((.title // "") | titleapi) == $apiplain) + or (($targetkey != "") and (bodykey == $targetkey))))]' \ + /tmp/gh-aw/agent/merged-onmain-candidates.json \ + > /tmp/gh-aw/agent/same-api-merged-candidates.json +enrich_scan_provenance /tmp/gh-aw/agent/same-api-merged-candidates.json \ + /tmp/gh-aw/agent/same-api-merged-fixes.ndjson +if [ -s /tmp/gh-aw/agent/same-api-merged-fixes.ndjson ]; then + jq -s '.' /tmp/gh-aw/agent/same-api-merged-fixes.ndjson > /tmp/gh-aw/agent/same-api-merged-fixes.json +else + echo '[]' > /tmp/gh-aw/agent/same-api-merged-fixes.json +fi + +# Candidate-keyed sentinel: no shared path can leak state from an earlier candidate. `rm` is not +# in this workflow's shell allowlist, so initialize with allowlisted printf and test with `-s`. +REOPEN_OVERRIDE_FILE="/tmp/gh-aw/agent/reopen-override-${N}.txt" +printf '' > "$REOPEN_OVERRIDE_FILE" # (d-override) MAINTAINER RE-OPEN GUARD: if this [leak-scan] issue was RE-OPENED *after* the # newest matched merged [leak-fix] PR merged, a maintainer deliberately overrode the auto-close # (the merged fix was incomplete / another retention edge remains). This workflow NEVER re-opens # issues, so any 'reopened' event is an external human action. Respect it: never re-close (which # would reverse the human decision every 6h and post a false "already fixed" comment) and never # rebuild. Emit `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. -if [ "$(jq 'length' /tmp/gh-aw/agent/merged-fix-prs.json)" -ge 1 ]; then - FIX_MERGED_AT=$(jq -r '[.[].mergedAt] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/merged-fix-prs.json) +if [ "$(jq 'length' /tmp/gh-aw/agent/merged-exact-fix-prs.json)" -ge 1 ]; then + FIX_MERGED_AT=$(jq -r '[.[].mergedAt] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/merged-exact-fix-prs.json) # Fail CLOSED on this DESTRUCTIVE path: auto-closing a possibly maintainer-reopened issue must # never happen on an unverified timeline. If the timeline cannot be fetched AND parsed as a JSON # array, we cannot rule out a maintainer re-open, so write the override sentinel (skip close/ @@ -557,19 +598,24 @@ if [ "$(jq 'length' /tmp/gh-aw/agent/merged-fix-prs.json)" -ge 1 ]; then REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ - > /tmp/gh-aw/agent/reopen-override.txt - cat /tmp/gh-aw/agent/reopen-override.txt + > "$REOPEN_OVERRIDE_FILE" + cat "$REOPEN_OVERRIDE_FILE" fi else echo "REOPEN GUARD UNVERIFIED: timeline lookup for #$N failed or returned non-array JSON — failing closed; will NOT close or rebuild (cannot rule out a maintainer re-open)." \ - > /tmp/gh-aw/agent/reopen-override.txt - cat /tmp/gh-aw/agent/reopen-override.txt + > "$REOPEN_OVERRIDE_FILE" + cat "$REOPEN_OVERRIDE_FILE" fi fi +if test -s "$REOPEN_OVERRIDE_FILE"; then + echo "re-open override active for candidate #$N" +else + echo "no re-open override for candidate #$N" +fi ``` - **Re-open override (takes precedence over gate (d) and any rebuild):** if - `/tmp/gh-aw/agent/reopen-override.txt` exists — either THIS `[leak-scan]` issue was **re-opened + `/tmp/gh-aw/agent/reopen-override-.txt` is **non-empty** — either THIS `[leak-scan]` issue was **re-opened after** the newest matched merged `[leak-fix]` PR merged (a maintainer deliberately overrode the auto-close), **or** the issue timeline could **not be fetched/parsed** so the re-open guard failed closed — do NOT emit `close-issue` and do NOT rebuild. Read the sentinel and emit the @@ -577,16 +623,15 @@ fi re-open, or `skipped: re-open guard unverified (timeline lookup failed)` when the lookup failed; then stop. Never reverse a deliberate human re-open, and never close on an unverified timeline (either would re-close the issue every 6h and post a false "already fixed" comment). -- If a **merged** `[leak-fix]` PR already fixes this issue number OR the same rooting - `Type.Member` (d) **and the re-open override above did NOT fire** → the leak is **already on - `main`**. Do NOT create a branch or rebuild. +- If an **exact** merged `[leak-fix]` PR in `merged-exact-fix-prs.json` carries this same-repo + `Fixes #` line **and the re-open override above did NOT fire** → this scan issue's fix is + already on `main`. Do NOT create a branch or rebuild. Emit exactly one `close-issue` safe-output on THIS `[leak-scan]` issue `#` with a closing comment (AI-attributed, linking the merged PR), e.g.: > 🔍 **AI-generated action** (Memory Leak Fixer) — this leak is already fixed on `main` by - > # (``). That PR's `Fixes:` line referenced a different issue - > number, so this `[leak-scan]` issue stayed open and kept being re-processed. Closing it to - > stop the rebuild loop. Note: it may still reproduce in the shipped NuGet package until the - > fix ships in a release. + > # (``), whose body explicitly carries `Fixes #`. Closing this + > still-open `[leak-scan]` issue to stop the rebuild loop. Note: it may still reproduce in the + > shipped NuGet package until the fix ships in a release. **Dry-run gate** (control text — NOT part of the close comment above): if `dry_run == "true"`, do NOT emit — print `DRY RUN — would close #` and the closing comment to the run log @@ -595,9 +640,15 @@ fi this issue even if a `close-issue` call is emitted.) This is the run's single action — stop after emitting `close-issue`. -- 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 an **open** fix PR already refs this exact issue (a) → `skipped: leak already being fixed` + and stop (or, if `issue_number` was explicit, just stop). +- For `same-api-open-fixes.json` and `same-api-merged-fixes.json`, API equality is only a + prefilter. Skip without closing/rebuilding only when the referenced scan issue has the same + non-empty `leak-scan-key` as `TARGET_LEAK_KEY`, or its title/body describes the same + publisher/event/collection and the same `root -> ... -> transient` retention edge. + If the Type.Member matches but the mechanism differs, continue normally. A same-leak merged fix + under a different scan issue number is non-destructive: leave this issue open for manual + reconciliation rather than claiming it was explicitly fixed. - 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). @@ -784,6 +835,10 @@ two lines as shown), then the details: > 🤖 **AI-generated PR** — produced automatically by the **Memory Leak Fixer** agentic workflow from issue #. The regression test below was observed to FAIL on unpatched `main` and PASS with this fix, on the runner. Please review carefully before merging. Fixes # + + ").key) catch null; - [.[] | select(((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) + [.[] | select(leak_has_exact_fixes($repo; $n) or (((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey)))]' \ /tmp/gh-aw/agent/merged-leakfix-raw.json \ @@ -520,8 +521,9 @@ fi # (d-exact) ONLY an exact same-repo Fixes #N match may drive close-issue. A same Type.Member with a # different issue number is not provenance for this retention path and remains non-destructive. -jq --arg n "$N" --arg repo "$REPO_RE" \ - '[.[] | select((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg repo "$REPO_RE" \ + 'include "leak-workflow-provenance"; + [.[] | select(leak_has_exact_fixes($repo; $n))]' \ /tmp/gh-aw/agent/merged-onmain-candidates.json \ > /tmp/gh-aw/agent/merged-exact-fix-prs.json jq -r '.[] | "exact merged fix for scan issue: #\(.number) \(.title)"' \ @@ -536,9 +538,9 @@ enrich_scan_provenance () { output=$2 printf '' > "$output" jq -c '.[]' "$input" | while IFS= read -r pr; do - scan_n=$(jq -r --arg repo "$REPO_RE" ' - [(.body // "") | scan("(?i)\\bFixes\\b:?[ \t]*(?:"+$repo+"#|[^0-9A-Za-z_/]#)([0-9]+)\\b")] - | if length > 0 then .[0][0] else empty end' <<<"$pr") + scan_n=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -r --arg repo "$REPO_RE" ' + include "leak-workflow-provenance"; + leak_first_exact_fixes_number($repo)' <<<"$pr") [ -z "$scan_n" ] && continue issue=$(gh issue view "$scan_n" --repo "$GITHUB_REPOSITORY" --json number,title,body,state 2>/dev/null) || issue="" [ -z "$issue" ] && continue @@ -555,10 +557,11 @@ if [ -s /tmp/gh-aw/agent/same-api-open-fixes.ndjson ]; then else echo '[]' > /tmp/gh-aw/agent/same-api-open-fixes.json fi -jq --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ - 'def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ + 'include "leak-workflow-provenance"; + def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; def bodykey: try ((.body // "") | capture("(?i)").key) catch null; - [.[] | select((((.body // "") | test("(?i)\\bFixes\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) | not) + [.[] | select((leak_has_exact_fixes($repo; $n) | not) and ((((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey))))]' \ /tmp/gh-aw/agent/merged-onmain-candidates.json \ From e688069310721717fced6b3fd60a27e1d0a7b403 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Mon, 10 Aug 2026 18:21:27 +0200 Subject: [PATCH 32/68] Skip jq fixtures when jq is unavailable Guard the external jq-dependent provenance fixtures during Pester discovery while keeping source and lock invariant tests active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index 7d5955ee9614..cf12daf68696 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -2,6 +2,10 @@ #Requires -Modules Pester Describe 'Memory leak workflow provenance and safety' { + BeforeDiscovery { + $script:jqAvailable = $null -ne (Get-Command jq -ErrorAction SilentlyContinue) + } + BeforeAll { $workflowRoot = Join-Path $PSScriptRoot '../workflows' $fixerPath = Join-Path $workflowRoot 'leak-fixer.md' @@ -59,7 +63,7 @@ Describe 'Memory leak workflow provenance and safety' { } } - It 'runs provenance fixtures through the production jq parser' -ForEach @( + It 'runs provenance fixtures through the production jq parser' -Skip:(-not $script:jqAvailable) -ForEach @( @{ Body = 'Fixes #123'; Expected = '123' } @{ Body = 'Fixes: #124'; Expected = '124' } @{ Body = 'Fixes dotnet/maui#125'; Expected = '125' } @@ -81,7 +85,7 @@ Describe 'Memory leak workflow provenance and safety' { } } - It 'filters destructive-close candidates with the same production jq contract' { + It 'filters destructive-close candidates with the same production jq contract' -Skip:(-not $script:jqAvailable) { $inputJson = @( @{ number = 1; body = 'Fixes #500' } @{ number = 2; body = 'Fixes dotnet/maui#500' } From 59429484336d94349b82932754d0505a636aa6a6 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Mon, 10 Aug 2026 18:22:48 +0200 Subject: [PATCH 33/68] Ignore release-only open leak fixes Scope every open leak-fix duplicate gate to main and inflight/current, including Step 3, the final prompt refresh, and the safe-output boundary. Add regressions proving release-only open PRs do not block the main fix lane. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 43 +++++++++++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 3 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 19 ++++++--- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 232cb6a37ac1..f3ee4587c7b6 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -99,6 +99,27 @@ Describe 'mechanism-aware final duplicate gate' { $result.UnapprovedApiMatches.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) ` + -ApprovedDifferentMechanismPullRequests @() + + $result.Blocked | Should -BeFalse + $result.DirectMatches.Count | Should -Be 0 + $result.ApiMatches.Count | Should -Be 0 + } + It 'always blocks a direct issue reference even when the PR was approved by API' { $direct = New-LeakPr ` -Number 102 ` @@ -189,6 +210,10 @@ Describe 'workflow enforcement boundary' { $workflow | Should -Match '(?s)safe-outputs:.*steps:.*Assert-LeakFixSafeOutputGate\.ps1' $workflow | Should -Match 'dedup-state\.json' + ([regex]::Matches( + $workflow, + 'select\(\.baseRefName == "main" or \.baseRefName == "inflight/current"\)' + )).Count | Should -BeGreaterOrEqual 3 } Context 'safe-output gate script' { @@ -296,5 +321,23 @@ Describe 'workflow enforcement boundary' { -Repository 'dotnet/maui' } | Should -Not -Throw } + + 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 + } } } diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 1db017e56715..59d2b21568fa 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -115,7 +115,8 @@ function Get-LeakFixFinalDedupResult { [string]$_.baseRefName -in @('main', 'inflight/current') }) $eligibleOpen = @($OpenPullRequests | Where-Object { - ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) + ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) -and + [string]$_.baseRefName -in @('main', 'inflight/current') }) $eligible = @($eligibleMerged + $eligibleOpen) diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 7e39029a8135..8ba6e2b83854 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":"f942eb84b247a619831bc3f5f3ab46eb8099c6bfd8cee608e7dc90ab84b5f0ff","body_hash":"f02c20c85ec91394b629b8ca6dd028db8d501def20ad8e750dec9fdcc681f783","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":"f942eb84b247a619831bc3f5f3ab46eb8099c6bfd8cee608e7dc90ab84b5f0ff","body_hash":"adab1e53ba1495333a5041151cbd6aaa8a1429e8b7c1c7574532ebe6031920a6","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 9af654a082db..1ebbb95c83ff 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -471,12 +471,13 @@ echo "merged canonical-API matches: $(wc -l < /tmp/gh-aw/agent/merged-api-fix-pr # (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 \ + --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 @@ -494,12 +495,15 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json if test -n "$API"; then if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ --search '"[leak-fix]" in:title' \ - --json number,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(.title | startswith("[leak-fix] ")) | [.number, .title] | @tsv' \ + 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=$(printf '%s\n' "$TITLE" \ @@ -581,7 +585,8 @@ cat /tmp/gh-aw/agent/dedup-state.next.json > /tmp/gh-aw/agent/dedup-state.json if the mechanism differs, this is a distinct leak — proceed with Steps 4–10 and cite the API-match PR in your own PR body so a human reviewer can double-check. - If an **open** fix PR already refs this issue (b) → `skipped: leak already being fixed` and - stop (or move to the next automatic candidate). + 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 rooting `Type.Member` with no direct issue reference (c) → apply the SAME retention-mechanism check as the merged-API case above before skipping; a same-API open PR that targets a different mechanism is not a duplicate. @@ -807,7 +812,7 @@ jq -r '.[] | [.number, .title] | @tsv' \ if ! gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ --search '"[leak-fix]" in:title' \ - --json number,title,body \ + --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 @@ -817,7 +822,9 @@ 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(.title | startswith("[leak-fix] "))]' \ +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" '[.[] | From c8fe130eb5eb62ab28fe050dafedc82831575fc4 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 12 Aug 2026 23:42:31 +0200 Subject: [PATCH 34/68] Harden leak fixer review gates Filter effectively reverted fixes at every dedup boundary, reject unsupported empty API identities early, replace self-certified phase snapshots with live match coverage, and execute the safe-output hook from trusted default-branch scripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 15 ++ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 65 +++++- .github/scripts/LeakWorkflowDedup.psm1 | 45 ++-- .github/workflows/leak-fixer.lock.yml | 25 ++- .github/workflows/leak-fixer.md | 199 +++++++++++++----- 5 files changed, 257 insertions(+), 92 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index 4d12504fe8f1..ca451011ed45 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -110,6 +110,20 @@ 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." } +$mergedReverts = @( + Invoke-GhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--limit', '1000', + '--search', '"Revert" in:title', + '--json', 'number,title,body,mergedAt' + ) +) +if ($mergedReverts.Count -ge 1000) { + throw "Merged Revert search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + $open = @( Invoke-GhJson -Arguments @( 'pr', 'list', @@ -130,6 +144,7 @@ $result = Get-LeakFixFinalDedupResult ` -Repository $Repository ` -MergedPullRequests $merged ` -OpenPullRequests $open ` + -MergedRevertPullRequests $mergedReverts ` -ApprovedDifferentMechanismPullRequests $approved if ($result.Blocked) { diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index f3ee4587c7b6..391af1560dcb 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -47,9 +47,8 @@ Describe 'fresh-shell de-dup state' { issue_number = 42 api = 'Picker.ItemsSource' repository = 'dotnet/maui' - step3_api_match_pr_numbers = @() different_mechanism_prs = @( - [pscustomobject]@{ number = 100; phase = 'final'; basis = 'too short' } + [pscustomobject]@{ number = 100; basis = 'too short' } ) } @@ -137,6 +136,28 @@ Describe 'mechanism-aware final duplicate gate' { $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) + } } Describe 'effective recursive revert state' { @@ -210,6 +231,10 @@ Describe 'workflow enforcement boundary' { $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"\)' @@ -235,12 +260,12 @@ Describe 'workflow enforcement boundary' { issue_number = 20 api = 'GradientBrush.GradientStops' repository = 'dotnet/maui' - step3_api_match_pr_numbers = @() different_mechanism_prs = @() } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') $global:mockMerged = @() + $global:mockReverts = @() $global:mockOpen = @() $global:mockGhExitCode = 0 function global:gh { @@ -252,8 +277,14 @@ Describe 'workflow enforcement boundary' { } $stateIndex = [Array]::IndexOf($GhArgs, '--state') $state = $GhArgs[$stateIndex + 1] + $searchIndex = [Array]::IndexOf($GhArgs, '--search') + $search = $GhArgs[$searchIndex + 1] if ($state -eq 'merged') { - Write-Output (ConvertTo-Json -InputObject @($global:mockMerged) -Depth 5) + if ($search -eq '"Revert" in:title') { + Write-Output (ConvertTo-Json -InputObject @($global:mockReverts) -Depth 5) + } else { + Write-Output (ConvertTo-Json -InputObject @($global:mockMerged) -Depth 5) + } } else { Write-Output (ConvertTo-Json -InputObject @($global:mockOpen) -Depth 5) } @@ -262,7 +293,7 @@ Describe 'workflow enforcement boundary' { AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable mockMerged, mockOpen, mockGhExitCode -Scope Global -ErrorAction SilentlyContinue + Remove-Variable mockMerged, mockReverts, mockOpen, mockGhExitCode -Scope Global -ErrorAction SilentlyContinue } It 'fails closed before mutation when live metadata has a direct issue match' { @@ -303,11 +334,9 @@ Describe 'workflow enforcement boundary' { issue_number = 20 api = 'GradientBrush.GradientStops' repository = 'dotnet/maui' - step3_api_match_pr_numbers = @(501) different_mechanism_prs = @( @{ number = 501 - phase = 'step3' basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' } ) @@ -339,5 +368,27 @@ Describe 'workflow enforcement boundary' { -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 'Revert leak fix' ` + -Body 'Reverts dotnet/maui#503' + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } } } diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 59d2b21568fa..90e5802b4fc4 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -49,22 +49,9 @@ function Assert-LeakDedupState { if ($State.repository -cne $Repository) { throw "De-dup state repository '$($State.repository)' does not match '$Repository'." } - foreach ($requiredProperty in @('step3_api_match_pr_numbers', 'different_mechanism_prs')) { - if ($requiredProperty -notin $State.PSObject.Properties.Name -or - $null -eq $State.$requiredProperty) { - throw "De-dup state is missing required array '$requiredProperty'." - } - } - - $snapshotSeen = [System.Collections.Generic.HashSet[int]]::new() - foreach ($numberValue in @($State.step3_api_match_pr_numbers)) { - $number = 0 - if (-not [int]::TryParse([string]$numberValue, [ref]$number) -or $number -le 0) { - throw "Invalid Step 3 API-match PR number '$numberValue'." - } - if (-not $snapshotSeen.Add($number)) { - throw "Duplicate Step 3 API-match PR number #$number." - } + 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'." } $seen = [System.Collections.Generic.HashSet[int]]::new() @@ -76,15 +63,6 @@ function Assert-LeakDedupState { if (-not $seen.Add($number)) { throw "Duplicate different-mechanism decision for PR #$number." } - if ([string]$decision.phase -notin @('step3', 'final')) { - throw "Invalid different-mechanism phase '$($decision.phase)' for PR #$number." - } - if ([string]$decision.phase -eq 'step3' -and -not $snapshotSeen.Contains($number)) { - throw "Step 3 different-mechanism decision PR #$number is absent from the Step 3 API-match snapshot." - } - if ([string]$decision.phase -eq 'final' -and $snapshotSeen.Contains($number)) { - throw "Final different-mechanism decision PR #$number was already present in the Step 3 API-match snapshot." - } if ([string]::IsNullOrWhiteSpace([string]$decision.basis) -or ([string]$decision.basis).Length -lt 12) { throw "Different-mechanism decision for PR #$number lacks a specific basis." @@ -101,6 +79,7 @@ function Get-LeakFixFinalDedupResult { [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedPullRequests, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$OpenPullRequests, + [AllowEmptyCollection()][object[]]$MergedRevertPullRequests = @(), [int[]]$ApprovedDifferentMechanismPullRequests = @() ) @@ -114,6 +93,19 @@ function Get-LeakFixFinalDedupResult { ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) -and [string]$_.baseRefName -in @('main', 'inflight/current') }) + $effectivelyReverted = @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository $Repository ` + -FixPullRequestNumbers @($eligibleMerged | ForEach-Object { [int]$_.number }) ` + -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) + }) $eligibleOpen = @($OpenPullRequests | Where-Object { ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) -and [string]$_.baseRefName -in @('main', 'inflight/current') @@ -139,6 +131,8 @@ function Get-LeakFixFinalDedupResult { "direct issue-reference match: $($directMatches.number -join ', ')" } elseif ($unapprovedApiMatches.Count -gt 0) { "same-API match without a different-mechanism decision: $($unapprovedApiMatches.number -join ', ')" + } elseif ($apiMatches.Count -eq 0) { + 'no live direct-reference or same-API duplicate matches' } else { 'all live same-API matches were explicitly judged to use different retention mechanisms' } @@ -149,6 +143,7 @@ function Get-LeakFixFinalDedupResult { DirectMatches = $directMatches ApiMatches = $apiMatches UnapprovedApiMatches = $unapprovedApiMatches + EffectivelyReverted = $effectivelyReverted } } diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 8ba6e2b83854..a4fc9c916119 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":"f942eb84b247a619831bc3f5f3ab46eb8099c6bfd8cee608e7dc90ab84b5f0ff","body_hash":"adab1e53ba1495333a5041151cbd6aaa8a1429e8b7c1c7574532ebe6031920a6","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":"5afd872b8f7398cff2b159862aac7319ecc442be448aaf45fad12267e6d6d6cb","body_hash":"485416c147308cc679b70220e8d70366e5ef7709135942bbf6b8184faa5576e5","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 # @@ -1798,9 +1798,30 @@ 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: .github/scripts/Assert-LeakFixSafeOutputGate.ps1 + 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 }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 1ebbb95c83ff..0b7b6d1743e0 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -103,7 +103,30 @@ safe-outputs: # 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 mechanism decisions are incomplete/stale. + # The boundary validates live match coverage and decision structure; the retention-mechanism + # comparison itself remains an agent-authored semantic judgment for human review. 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 @@ -111,7 +134,7 @@ safe-outputs: GH_TOKEN: ${{ github.token }} GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent_output.json LEAK_DEDUP_STATE_DIR: /tmp/gh-aw/agent - run: .github/scripts/Assert-LeakFixSafeOutputGate.ps1 + 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. @@ -398,6 +421,10 @@ API=$(printf '%s' "$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] } }') echo "target rooting API: $API" +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 and the @@ -412,7 +439,6 @@ jq -n \ issue_number: $issue_number, api: $api, repository: $repository, - step3_api_match_pr_numbers: [], different_mechanism_prs: [] }' > /tmp/gh-aw/agent/dedup-state.json # (a) Exact [leak-fix] PRs already MERGED to main/inflight/current. @@ -432,6 +458,9 @@ jq '[.[] | 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] | @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 last-Type.Member extraction used for the # selected issue. This handles fully-qualified/off-contract wording without substring matches. @@ -448,6 +477,44 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ | sort -u \ > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv +# A merged fix is not authoritative if it was later effectively reverted. Resolve recursive +# revert chains before either the direct issue-reference or same-API merged gate consumes it. +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"Revert" in:title' \ + --json number,title,body,mergedAt \ + > /tmp/gh-aw/agent/merged-revert-prs-raw.json; then + echo "ERROR: merged Revert search failed — aborting because effective fix state is unknown." >&2 + exit 1 +fi +MERGED_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-revert-prs-raw.json) +if test "$MERGED_REVERT_COUNT" -ge 1000; then + echo "ERROR: merged Revert search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 + exit 1 +fi +jq '[.[] | select(.mergedAt != null)]' \ + /tmp/gh-aw/agent/merged-revert-prs-raw.json \ + > /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 @@ -492,33 +559,28 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # 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 test -n "$API"; then - 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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') - 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 -else - echo "target rooting API is empty (issue #$N title has no Type.Member chain) — skipping same-API dedup so an empty key can't false-match unrelated [leak-fix] PRs." >&2 - echo '[]' > /tmp/gh-aw/agent/same-api-prs.json +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=$(printf '%s\n' "$TITLE" \ + | sed -E 's/^\[leak-fix\] *//' \ + | 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] } }') + 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 # (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. @@ -553,21 +615,6 @@ jq --arg n "$N" --arg repo "$REPO_RE" --argjson apiNums "$API_MATCH_NUMBERS" '[. > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json -# Snapshot every Step 3 API-only match. If this run proceeds, the agent must record a -# different-mechanism decision for each of these PRs after comparing retention paths below. -# Step 9.5 compares only live matches absent from this snapshot; the safe-output gate still -# verifies that ALL live API-only matches (old and new) have an explicit decision. -STEP3_API_MATCH_NUMBERS=$( - { - awk -F '\t' '{ print $2 }' /tmp/gh-aw/agent/merged-api-fix-prs.tsv - jq -r '.[].number' /tmp/gh-aw/agent/same-api-prs.json - } | jq -R 'select(length > 0) | tonumber' | jq -s 'sort | unique' -) -jq --argjson numbers "$STEP3_API_MATCH_NUMBERS" \ - '.step3_api_match_pr_numbers = $numbers' \ - /tmp/gh-aw/agent/dedup-state.json \ - > /tmp/gh-aw/agent/dedup-state.next.json -cat /tmp/gh-aw/agent/dedup-state.next.json > /tmp/gh-aw/agent/dedup-state.json ``` - If `jq 'length' merged-issue-fix-prs.json` is greater than `0` → that merged PR literally @@ -594,7 +641,7 @@ cat /tmp/gh-aw/agent/dedup-state.next.json > /tmp/gh-aw/agent/dedup-state.json - **Persist every different-mechanism decision.** If you proceed past any API-only match from (a) or (c), append one object to the `different_mechanism_prs` array in `/tmp/gh-aw/agent/dedup-state.json`: - `{"number": , "phase": "step3", "basis": ""}`. + `{"number": , "basis": ""}`. Keep the `basis` concise but specific (at least 12 characters), do not copy untrusted PR text verbatim, and preserve the other state fields. Use `jq` plus a `.next.json` file and `cat` back over the state file. The safe-output gate rejects missing, duplicate, malformed, or @@ -750,18 +797,20 @@ 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 (snapshots go stale) +## Step 9.5 — Refresh de-dup immediately before emitting -The Step 3 merged/open snapshots were captured before the red/green build+test cycle +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 so you can compare the retention -mechanism of any NEW 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. +fix during that window. Refresh the live context here and ensure every current API-only match +has a persisted retention-mechanism decision. 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 prepares semantic mechanism decisions; it is not the authoritative mutation gate. The generated safe-output job independently re-fetches live metadata and refuses `create_pull_request` immediately before Process Safe Outputs unless every current API-only -match has a valid different-mechanism decision and no direct issue-reference match exists. +match has a structurally valid different-mechanism decision and no direct issue-reference +match exists. The gate does not claim to independently prove the semantic comparison in each +decision; that agent-authored basis remains visible for human review. ```bash set -euo pipefail @@ -773,7 +822,6 @@ STATE_REPOSITORY=$(jq -er '.repository | select(type == "string" and length > 0) test "$STATE_REPOSITORY" = "$GITHUB_REPOSITORY" REPO_RE=$(printf '%s' "$STATE_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') jq -e ' - (.step3_api_match_pr_numbers | type == "array") and (.different_mechanism_prs | type == "array") ' "$STATE" > /dev/null @@ -795,6 +843,40 @@ jq '[.[] | 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 + +if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ + --search '"Revert" in:title' \ + --json number,title,body,mergedAt \ + > /tmp/gh-aw/agent/final-merged-revert-prs-raw.json; then + echo "ERROR: final merged Revert search failed — aborting because effective fix state is unknown." >&2 + exit 1 +fi +FINAL_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/final-merged-revert-prs-raw.json) +if test "$FINAL_REVERT_COUNT" -ge 1000; then + echo "ERROR: final merged Revert search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 + exit 1 +fi +jq '[.[] | select(.mergedAt != null)]' \ + /tmp/gh-aw/agent/final-merged-revert-prs-raw.json \ + > /tmp/gh-aw/agent/final-merged-revert-prs.json +jq -r '.[] | ["_", .number, .title] | @tsv' \ + /tmp/gh-aw/agent/final-merged-leak-fix-prs.json \ + > /tmp/gh-aw/agent/final-merged-leak-fix-prs.tsv +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 @@ -848,23 +930,24 @@ cut -f2 /tmp/gh-aw/agent/final-api-matches.tsv \ | jq -R --slurpfile state "$STATE" ' select(length > 0) | tonumber as $number | - select(($state[0].step3_api_match_pr_numbers | index($number)) == null) | + select(($state[0].different_mechanism_prs | map(.number) | index($number)) == null) | $number - ' > /tmp/gh-aw/agent/final-new-api-match-pr-numbers.txt + ' > /tmp/gh-aw/agent/final-unapproved-api-match-pr-numbers.txt 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 API-only matches:"; cat /tmp/gh-aw/agent/final-api-matches.tsv -echo "API-only matches first seen after Step 3:"; cat /tmp/gh-aw/agent/final-new-api-match-pr-numbers.txt +echo "API-only matches without a persisted mechanism decision:"; cat /tmp/gh-aw/agent/final-unapproved-api-match-pr-numbers.txt ``` - If either direct issue-reference count is greater than `0`, stop: a direct reference is always an unconditional duplicate. -- Compare mechanisms only for PR numbers in - `final-new-api-match-pr-numbers.txt`; Step 3 already handled the persisted snapshot. - For each new API-only match, open the PR and compare its retention path to this issue. +- Compare mechanisms for every PR number in + `final-unapproved-api-match-pr-numbers.txt`. Existing decisions remain valid only while + their PR number is still a live same-API match; extra stale decisions are harmless. + For each unapproved API-only match, open the PR and compare its retention path to this issue. If equivalent, stop. If different, append to the `different_mechanism_prs` array in `/tmp/gh-aw/agent/dedup-state.json`: - `{"number": , "phase": "final", "basis": ""}` + `{"number": , "basis": ""}` to `dedup-state.json.different_mechanism_prs` with the same atomic `jq`/`.next.json`/`cat` pattern as Step 3. - Do not rely on `exit 1` here as enforcement. Even if you route around a failed tool call or From d96f884734f3b6a2be1f93578835039885c4e29c Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 12 Aug 2026 23:49:21 +0200 Subject: [PATCH 35/68] Address leak workflow review feedback Harden provenance parsing against inert Markdown, bound hunter ancestry checks, verify lock body hashes, and clarify generated PR body guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 166 ++++++++++++++++++- .github/scripts/leak-workflow-provenance.jq | 11 +- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 35 +++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 15 +- 6 files changed, 211 insertions(+), 20 deletions(-) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index cf12daf68696..8712008ff746 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -61,6 +61,134 @@ Describe 'Memory leak workflow provenance and safety' { return $null } + + function Get-WorkflowParts { + param([Parameter(Mandatory)][string] $Path) + + $content = (Get-Content -LiteralPath $Path -Raw) -replace "`r`n", "`n" + $lines = $content -split "`n" + if ($lines.Count -eq 0 -or $lines[0].Trim() -ne '---') { + return @{ + Frontmatter = '' + Body = $content + } + } + + $endIndex = -1 + for ($i = 1; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim() -eq '---') { + $endIndex = $i + break + } + } + if ($endIndex -lt 0) { + throw "Frontmatter is not closed in $Path" + } + + return @{ + Frontmatter = $lines[1..($endIndex - 1)] -join "`n" + Body = $lines[($endIndex + 1)..($lines.Count - 1)] -join "`n" + } + } + + function Get-WorkflowImports { + param([Parameter(Mandatory)][string] $Frontmatter) + + $imports = @() + $inImports = $false + $baseIndent = 0 + foreach ($line in ($Frontmatter -split "`n")) { + $trimmed = $line.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) { + continue + } + + if ($trimmed.StartsWith('imports:')) { + $inImports = $true + $baseIndent = $line.Length - $line.TrimStart().Length + continue + } + + if (-not $inImports) { + continue + } + + $lineIndent = $line.Length - $line.TrimStart().Length + if ($lineIndent -le $baseIndent) { + break + } + + if ($trimmed.StartsWith('-')) { + $item = $trimmed.Substring(1).Trim() + if ($item.StartsWith('uses:')) { + $item = $item.Substring('uses:'.Length).Trim() + } + elseif ($item.StartsWith('path:')) { + $item = $item.Substring('path:'.Length).Trim() + } + + $item = $item.Trim('"', "'") + if (-not [string]::IsNullOrWhiteSpace($item)) { + $imports += $item + } + } + } + + return $imports + } + + function Get-WorkflowBodyHash { + param([Parameter(Mandatory)][string] $Path) + + $visited = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + + function Get-ImportedBodies { + param([string] $WorkflowPath) + + $parts = Get-WorkflowParts -Path $WorkflowPath + $bodies = @() + foreach ($import in (Get-WorkflowImports -Frontmatter $parts.Frontmatter | Sort-Object)) { + $importPath = [IO.Path]::GetFullPath( + (Join-Path (Split-Path -Parent $WorkflowPath) $import)) + if (-not $visited.Add($importPath) -or -not (Test-Path -LiteralPath $importPath)) { + continue + } + + $importParts = Get-WorkflowParts -Path $importPath + $bodies += $importParts.Body.Trim() + $bodies += Get-ImportedBodies -WorkflowPath $importPath + } + + return $bodies + } + + $parts = Get-WorkflowParts -Path $Path + $allBodies = @($parts.Body.Trim()) + $allBodies += @(Get-ImportedBodies -WorkflowPath $Path) | Sort-Object + $combined = $allBodies -join "`n---`n" + $bytes = [Text.Encoding]::UTF8.GetBytes($combined) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } + } + + function Get-LockMetadata { + param([Parameter(Mandatory)][string] $Lock) + + $match = [Regex]::Match( + $Lock, + '(?m)^#\s*gh-aw-metadata:\s*(?\{.+\})\s*$') + if (-not $match.Success) { + throw 'Lock file has no gh-aw metadata header' + } + + return $match.Groups['json'].Value | ConvertFrom-Json + } } It 'runs provenance fixtures through the production jq parser' -Skip:(-not $script:jqAvailable) -ForEach @( @@ -75,6 +203,16 @@ Describe 'Memory leak workflow provenance and safety' { @{ Body = 'Fixes #13abc'; Expected = $null } @{ Body = 'PrefixFixes #131'; Expected = $null } @{ Body = 'Text owner/repo#132 without a closing keyword'; Expected = $null } + @{ Body = 'Fixes `#133`'; Expected = $null } + @{ Body = '`Fixes #134`'; Expected = $null } + @{ Body = '``Fixes #134``'; Expected = $null } + @{ Body = 'Fixes *#135*'; Expected = $null } + @{ Body = ''; Expected = $null } + @{ Body = ('```text' + [Environment]::NewLine + 'Fixes #137' + [Environment]::NewLine + '```'); Expected = $null } + @{ Body = ('````text' + [Environment]::NewLine + 'Fixes #137' + [Environment]::NewLine + '````'); Expected = $null } + @{ Body = ('~~~text' + [Environment]::NewLine + 'Fixes #138' + [Environment]::NewLine + '~~~'); Expected = $null } + @{ Body = 'Closes #139'; Expected = $null } + @{ Body = 'Resolved #140'; Expected = $null } ) { $actual = @(Get-ProductionFixesScanIssueNumber -Body $Body -Repository 'dotnet/maui') if ($null -eq $Expected) { @@ -93,6 +231,11 @@ Describe 'Memory leak workflow provenance and safety' { @{ number = 4; body = 'Fixes dotnet/runtime#500' } @{ number = 5; body = 'Fixes dotnet/maui/#500' } @{ number = 6; body = 'Fixes #500extra' } + @{ number = 7; body = 'Fixes `#500`' } + @{ number = 8; body = '`Fixes #500`' } + @{ number = 9; body = '' } + @{ number = 10; body = ('```text' + [Environment]::NewLine + 'Fixes #500' + [Environment]::NewLine + '```') } + @{ number = 11; body = '``Fixes #500``' } ) | ConvertTo-Json -Compress $result = $inputJson | @@ -126,11 +269,21 @@ Describe 'Memory leak workflow provenance and safety' { It 'filters merged history before making compare API calls' { $filterIndex = $fixer.IndexOf('# (d-prefilter)', [StringComparison]::Ordinal) $compareIndex = $fixer.IndexOf('compare/main...$sha', [StringComparison]::Ordinal) + $hunterFilterIndex = $hunter.IndexOf( + 'merged-leakfix-unshipped-candidates.json', + [StringComparison]::Ordinal) + $hunterCompareIndex = $hunter.IndexOf( + 'compare/main...$sha', + [StringComparison]::Ordinal) $filterIndex | Should -BeGreaterThan -1 $compareIndex | Should -BeGreaterThan $filterIndex + $hunterFilterIndex | Should -BeGreaterThan -1 + $hunterCompareIndex | Should -BeGreaterThan $hunterFilterIndex $fixer | Should -Not -Match ([Regex]::Escape( "jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while")) + $hunter | Should -Not -Match ([Regex]::Escape( + "jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while")) } It 'limits destructive closure to exact Fixes provenance' { @@ -157,6 +310,8 @@ Describe 'Memory leak workflow provenance and safety' { $packageMatch.Success | Should -BeTrue $versionMatch.Groups['version'].Value | Should -Be $packageMatch.Groups['version'].Value $hunter | Should -Match 'compare/\$SHIPPED_MAUI_VERSION\.\.\.\$sha' + $hunter | Should -Match 'WARNING: shipped release tag' + $hunter | Should -Match 'WARNING: ancestry compare failed' $hunter | Should -Match 'open-leakscan-provenance\.json' $hunter | Should -Match 'unshipped-main-fixes\.json' $hunter | Should -Not -Match 'already-filed-apis\.txt' @@ -172,11 +327,14 @@ Describe 'Memory leak workflow provenance and safety' { } It 'keeps generated locks synchronized with the current dynamic compiler version and prompt' { + $fixerMetadata = Get-LockMetadata -Lock $fixerLock + $hunterMetadata = Get-LockMetadata -Lock $hunterLock + $compilerVersion | Should -Not -BeNullOrEmpty - $fixerLock | Should -Match ([Regex]::Escape("""compiler_version"":""$compilerVersion""")) - $hunterLock | Should -Match ([Regex]::Escape("""compiler_version"":""$compilerVersion""")) - $fixerLock | Should -Match '"body_hash":"[0-9a-f]{64}"' - $hunterLock | Should -Match '"body_hash":"[0-9a-f]{64}"' + $fixerMetadata.compiler_version | Should -Be $compilerVersion + $hunterMetadata.compiler_version | Should -Be $compilerVersion + $fixerMetadata.body_hash | Should -Be (Get-WorkflowBodyHash -Path $fixerPath) + $hunterMetadata.body_hash | Should -Be (Get-WorkflowBodyHash -Path $hunterPath) $hunterLock | Should -Not -Match 'fixed-on-main-apis\.txt' } } diff --git a/.github/scripts/leak-workflow-provenance.jq b/.github/scripts/leak-workflow-provenance.jq index 7f3383caa2ce..9e25a85841d7 100644 --- a/.github/scripts/leak-workflow-provenance.jq +++ b/.github/scripts/leak-workflow-provenance.jq @@ -1,6 +1,15 @@ +# Deliberately accept only the workflow contract's literal `Fixes` keyword. Generated +# [leak-fix] PRs are required to use it, and keeping the destructive proof narrower than +# GitHub's full closing-keyword set is fail-closed. +def leak_without_inert_markdown: + gsub("(?s)"; "") + | gsub("(?ms)^[ \t]*~{3,}[^\\r\\n]*\\r?\\n.*?^[ \t]*~{3,}[ \t]*(?:\\r?\\n|$)"; "") + | gsub("(?s)`+.*?`+"; ""); + def leak_exact_fixes_numbers($repo_re): [(.body // "") - | scan("(?i)\\bFixes\\b:?[ \t]*(?:" + $repo_re + "#|[^0-9A-Za-z_/]#)([0-9]+)\\b") + | leak_without_inert_markdown + | scan("(?i)\\bFixes\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b") | .[0]]; def leak_first_exact_fixes_number($repo_re): diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index c551b6e6e7d4..1c7cd4baacab 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"44e3575e4c86060d45a31eb396d9ffa86d075ab0c15bb4b2eed0c586d5235812","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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"8bb6c89e3f91e321f3272db8eef40257faaa3d21f8b54fb886862182a0fd7b94","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 670a3772faf2..34691c3e77d6 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -186,14 +186,29 @@ jq -r '.[] | "open scan #\(.scan_issue.number): API \(.rooting_api // " /tmp/gh-aw/agent/shipped-maui-version.txt REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') -gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' \ +gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows base:main' \ --limit 1000 --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older unshipped fixes may be TRUNCATED and re-filed once — switch to date-windowed enumeration." fi +SHIPPED_MAUI_COMMIT_DATE=$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHIPPED_MAUI_VERSION" -q '.commit.committer.date' 2>/dev/null) || SHIPPED_MAUI_COMMIT_DATE="" +if [ -z "$SHIPPED_MAUI_COMMIT_DATE" ]; then + echo "WARNING: shipped release tag \"$SHIPPED_MAUI_VERSION\" could not be resolved — unshipped-fix suppression is degraded for this run." + echo '[]' > /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json +else + # A main-targeting PR merged before the shipped tag's commit cannot be an unshipped fix. + # Apply this cheap exact cutoff before spending compare-API calls on recent candidates. + jq --arg cutoff "$SHIPPED_MAUI_COMMIT_DATE" \ + '[.[] | select((.mergedAt // "") > $cutoff)]' \ + /tmp/gh-aw/agent/merged-leakfix-raw.json \ + > /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json +fi + printf '' > /tmp/gh-aw/agent/unshipped-main-fixes.ndjson -jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do +printf '' > /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt +COMPARE_CANDIDATE_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json) +jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json | while IFS= read -r pr; do sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") [ -z "$sha" ] && continue # Only workflow PRs with an exact SAME-REPO Fixes reference have scan provenance. A Refs line @@ -207,9 +222,17 @@ jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do # compare cannot be verified, fail open (do not suppress): a bounded duplicate is safer than # permanently hiding a real leak. on_main=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || on_main="" - in_shipped=$(gh api "repos/$GITHUB_REPOSITORY/compare/$SHIPPED_MAUI_VERSION...$sha" -q '.ahead_by' 2>/dev/null) || in_shipped="" + if [ -z "$on_main" ]; then + printf '%s main\n' "$sha" >> /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt + continue + fi [ "$on_main" != "0" ] && continue - [ -z "$in_shipped" ] && continue + + in_shipped=$(gh api "repos/$GITHUB_REPOSITORY/compare/$SHIPPED_MAUI_VERSION...$sha" -q '.ahead_by' 2>/dev/null) || in_shipped="" + if [ -z "$in_shipped" ]; then + printf '%s shipped\n' "$sha" >> /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt + continue + fi [ "$in_shipped" = "0" ] && continue issue=$(gh issue view "$scan_n" --repo "$GITHUB_REPOSITORY" --json number,title,body,state 2>/dev/null) || issue="" @@ -224,6 +247,10 @@ jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while IFS= read -r pr; do leak_scan_key:($key | if . == "" then null else . end)}' \ >> /tmp/gh-aw/agent/unshipped-main-fixes.ndjson done +COMPARE_FAILURE_COUNT=$(wc -l < /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt | tr -d ' ') +if [ "$COMPARE_FAILURE_COUNT" -gt 0 ]; then + echo "WARNING: ancestry compare failed for $COMPARE_FAILURE_COUNT/$COMPARE_CANDIDATE_COUNT prefiltered merged fixes (tag \"$SHIPPED_MAUI_VERSION\" resolvable?) — unshipped-fix suppression is degraded." +fi if [ -s /tmp/gh-aw/agent/unshipped-main-fixes.ndjson ]; then jq -s '.' /tmp/gh-aw/agent/unshipped-main-fixes.ndjson > /tmp/gh-aw/agent/unshipped-main-fixes.json else diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index ca15314088ef..9b3046dba698 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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"79ecf7448e5b8e0cbc7fbdbb3db91e256595289227f029ea234a08e080b4cdda","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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"9e9177c02a26802cd75a9094fcac3297a227c77bc0215b775b20c9f87df75460","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 85582dfe9825..020865253c95 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -827,7 +827,12 @@ issue so GitHub auto-closes it on merge; otherwise the scan issue is orphaned an re-picks and rebuilds it every run. The body MUST start with the required MAUI testing note (verbatim, no block-quote on the first -two lines as shown), then the details: +two lines as shown), then the details. Before constructing it, copy the selected scan issue's +exact `leak-scan-key` marker. For a legacy issue without one, derive +`Type.Member|short-mechanism-slug` from the issue's mechanism text, never from the PR title. +Include an optional `Refs: /#` line only for a separate pre-existing +upstream issue; otherwise omit that line entirely. `Fixes #` always names the selected +`[leak-scan]` issue and is the sole auto-close key. ```markdown > [!NOTE] @@ -838,15 +843,7 @@ two lines as shown), then the details: > 🤖 **AI-generated PR** — produced automatically by the **Memory Leak Fixer** agentic workflow from issue #. The regression test below was observed to FAIL on unpatched `main` and PASS with this fix, on the runner. Please review carefully before merging. Fixes # - - Refs: /# Target branch: main Attempt: /3 From 2ac8a5dfe3c575a9cd7c8363e671bac91775a706 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Thu, 13 Aug 2026 11:45:43 +0200 Subject: [PATCH 36/68] Harden leak workflow search ceilings Fail closed when authoritative leak de-dup searches reach GitHub's 1000-result search ceiling, and cover both early workflow gates with Pester regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 32 ++++++++++++++++++++ .github/workflows/daily-leak-hunter.lock.yml | 4 +-- .github/workflows/daily-leak-hunter.md | 7 ++++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 5 +++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 391af1560dcb..7ea335684ee9 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -226,6 +226,38 @@ Describe 'effective recursive revert state' { } 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 '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 diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index c09f1365094c..2ca7f64bea9a 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":"726952b241879ac32dc114ff62fbf9e333392862572e02c4557e89aa4ce31cbb","body_hash":"2ad3941200e41de8faff1bc70e8a4220599e8ee5d6c2486f2228974ceb474b70","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":"cbb55912d96480e7388049dcc41ed09c7db0cf8157e7542ef23d8793f1c2db4a","body_hash":"2ad3941200e41de8faff1bc70e8a4220599e8ee5d6c2486f2228974ceb474b70","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 # @@ -505,7 +505,7 @@ jobs: 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 200 --json number,title,body \\\n > /tmp/gh-aw/agent/my-open-leakscan.json\njq -r '.[].title | gsub(\"[\\r\\n]+\";\" \")' /tmp/gh-aw/agent/my-open-leakscan.json \\\n | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a live revert\n# removes its target's effect, a revert-of-that-revert restores it, and multiple live\n# reverts combine by parity. Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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; reverting the revert\n# reinstates the original fix. Multiple active reverts are combined by parity.\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" + 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a live revert\n# removes its target's effect, a revert-of-that-revert restores it, and multiple live\n# reverts combine by parity. Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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; reverting the revert\n# reinstates the original fix. Multiple active reverts are combined by parity.\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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index a3b2c1d9c6bd..aafd23d190e4 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -86,8 +86,13 @@ pre-agent-steps: # 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 200 --json number,title,body \ + --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 \ | 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] } }' \ diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index a4fc9c916119..84c0467c4bed 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":"5afd872b8f7398cff2b159862aac7319ecc442be448aaf45fad12267e6d6d6cb","body_hash":"485416c147308cc679b70220e8d70366e5ef7709135942bbf6b8184faa5576e5","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":"5afd872b8f7398cff2b159862aac7319ecc442be448aaf45fad12267e6d6d6cb","body_hash":"5ec543e26f26347bf48bb9ad30a9c85896fe319cf6a9b9982510d2775206d00f","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 0b7b6d1743e0..4a0bef369d9b 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -452,6 +452,11 @@ if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ 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] ")) | From 3eab5356a72f449193ed970af251369b26afba26 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 02:01:29 +0200 Subject: [PATCH 37/68] Harden leak fix PR metadata gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 27 ++++++++------ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index ca451011ed45..eb5e41d7454a 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -67,23 +67,30 @@ if ($createItems.Count -ne 1) { } $item = $createItems[0] -$api = Get-CanonicalLeakApi -Title ([string]$item.title) +$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 '$($item.title)'." + 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) -$refsMatches = [regex]::Matches( +$issue = [regex]::Escape([string]$issueNumber) +$targetRefsMatches = [regex]::Matches( ([string]$item.body), - "(?m)^[ \t]*Refs:[ \t]*$repo#(?[1-9][0-9]*)\b" + "(?m)^[ \t]*Refs:[ \t]*$repo#$issue\b" ) -if ($fixMatches.Count -ne 1 -or $refsMatches.Count -ne 1) { - throw 'The PR body must contain exactly one canonical Fixes line and one exact-repository Refs line.' -} -$issueNumber = [int]$fixMatches[0].Groups['number'].Value -if ([int]$refsMatches[0].Groups['number'].Value -ne $issueNumber) { - throw 'The PR body Fixes and Refs lines identify different issues.' +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' diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 7ea335684ee9..66c1cf71a8e0 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -328,6 +328,41 @@ Describe 'workflow enforcement boundary' { Remove-Variable mockMerged, mockReverts, mockOpen, mockGhExitCode -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 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 ` From d0d266f47b923cb7487eb3f2cc21413465e6426f Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 02:23:14 +0200 Subject: [PATCH 38/68] Harden leak workflow provenance guards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 92 +++++++++++++++++++- .github/scripts/leak-workflow-provenance.jq | 74 +++++++++++++++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 47 ++++++---- 4 files changed, 191 insertions(+), 24 deletions(-) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index 8712008ff746..ba7cb9cb575a 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -53,7 +53,7 @@ Describe 'Memory leak workflow provenance and safety' { $match = [Regex]::Match( $Body, - '(?i)') + '(?i)') if ($match.Success) { return $match.Groups['key'].Value.Trim() @@ -62,6 +62,27 @@ Describe 'Memory leak workflow provenance and safety' { return $null } + function Get-ProductionReopenGuard { + param( + [Parameter(Mandatory)] + [object] $TimelinePages, + + [Parameter(Mandatory)] + [string] $FixMergedAt + ) + + $inputJson = ConvertTo-Json -InputObject $TimelinePages -Depth 10 -Compress + $output = $inputJson | + & jq -L $script:provenanceModuleRoot -c --arg m $FixMergedAt ' + include "leak-workflow-provenance"; + leak_reopen_guard($m)' + if ($LASTEXITCODE -ne 0) { + throw "Production jq re-open guard failed with exit code $LASTEXITCODE" + } + + return $output | ConvertFrom-Json + } + function Get-WorkflowParts { param([Parameter(Mandatory)][string] $Path) @@ -211,6 +232,13 @@ Describe 'Memory leak workflow provenance and safety' { @{ Body = ('```text' + [Environment]::NewLine + 'Fixes #137' + [Environment]::NewLine + '```'); Expected = $null } @{ Body = ('````text' + [Environment]::NewLine + 'Fixes #137' + [Environment]::NewLine + '````'); Expected = $null } @{ Body = ('~~~text' + [Environment]::NewLine + 'Fixes #138' + [Environment]::NewLine + '~~~'); Expected = $null } + @{ Body = ('```text' + [Environment]::NewLine + '` stray tick' + [Environment]::NewLine + 'Fixes #141' + [Environment]::NewLine + '```'); Expected = $null } + @{ Body = ('```text' + [Environment]::NewLine + 'Fixes #142'); Expected = $null } + @{ Body = ('~~~text' + [Environment]::NewLine + 'Fixes #143'); Expected = $null } + @{ Body = ('````text' + [Environment]::NewLine + 'Fixes #144' + [Environment]::NewLine + '```' + [Environment]::NewLine + 'Fixes #145'); Expected = $null } + @{ Body = ('````text' + [Environment]::NewLine + 'Fixes #146' + [Environment]::NewLine + '````' + [Environment]::NewLine + 'Fixes #147'); Expected = '147' } + @{ Body = '' } @{ number = 10; body = ('```text' + [Environment]::NewLine + 'Fixes #500' + [Environment]::NewLine + '```') } @{ number = 11; body = '``Fixes #500``' } + @{ number = 12; body = ('```text' + [Environment]::NewLine + '`' + [Environment]::NewLine + 'Fixes #500' + [Environment]::NewLine + '```') } + @{ number = 13; body = ('```text' + [Environment]::NewLine + 'Fixes #500') } + @{ number = 14; body = '' } + @{ number = 5; body = ('````text' + [Environment]::NewLine + 'Fixes #500' + [Environment]::NewLine + '```' + [Environment]::NewLine + 'Refs #500') } + ) | ConvertTo-Json -Compress + + $result = $inputJson | + & jq -L $provenanceModuleRoot --arg repo 'dotnet/maui' --arg n '500' ' + include "leak-workflow-provenance"; + [.[] | select(leak_has_issue_reference($repo; $n)) | .number]' | + ConvertFrom-Json + + $LASTEXITCODE | Should -Be 0 + @($result) | Should -Be @(1, 2) + } + It 'keeps two mechanisms on the same API as distinct leak identities' { $collectionLeak = Get-LeakScanKey '' $selectionLeak = Get-LeakScanKey '' @@ -264,6 +320,8 @@ Describe 'Memory leak workflow provenance and safety' { $selectionLeak | Should -Be 'Picker.ItemsSource|selectedindexchanged-handler-retains-picker' $collectionLeak | Should -Not -Be $selectionLeak Get-LeakScanKey 'legacy issue without a marker' | Should -BeNullOrEmpty + Get-LeakScanKey "" | Should -BeNullOrEmpty + Get-LeakScanKey "" | Should -BeNullOrEmpty } It 'filters merged history before making compare API calls' { @@ -300,6 +358,36 @@ Describe 'Memory leak workflow provenance and safety' { $fixer | Should -Not -Match '/tmp/gh-aw/agent/reopen-override\.txt' } + It 'behaviorally fails closed on re-open races and malformed paginated timelines' -Skip:(-not $script:jqAvailable) { + $mergedAt = '2026-08-01T10:00:00Z' + + $afterMerge = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages @( + @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:00:00Z' }), + @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T11:00:00Z' }) + ) + $afterMerge.verified | Should -BeTrue + $afterMerge.block_close | Should -BeTrue + [DateTimeOffset]$afterMerge.reopened_at | + Should -Be ([DateTimeOffset]'2026-08-01T11:00:00Z') + + $beforeMerge = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages (, @( + [pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:59:59Z' } + )) + $beforeMerge.verified | Should -BeTrue + $beforeMerge.block_close | Should -BeFalse + + $nonArray = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages ([pscustomobject]@{ message = 'API failure' }) + $nonArray.verified | Should -BeFalse + $nonArray.block_close | Should -BeTrue + + $malformedPage = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages @( + @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:00:00Z' }), + [pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T11:00:00Z' } + ) + $malformedPage.verified | Should -BeFalse + $malformedPage.block_close | Should -BeTrue + } + It 'bounds hunter suppression to fixes absent from the shipped release' { $versionMatch = [Regex]::Match($hunter, '(?m)^SHIPPED_MAUI_VERSION=(?[0-9][^\s]+)$') $packageMatch = [Regex]::Match( diff --git a/.github/scripts/leak-workflow-provenance.jq b/.github/scripts/leak-workflow-provenance.jq index 9e25a85841d7..afad4fcd2f85 100644 --- a/.github/scripts/leak-workflow-provenance.jq +++ b/.github/scripts/leak-workflow-provenance.jq @@ -1,15 +1,51 @@ # Deliberately accept only the workflow contract's literal `Fixes` keyword. Generated # [leak-fix] PRs are required to use it, and keeping the destructive proof narrower than # GitHub's full closing-keyword set is fail-closed. +def leak_without_fenced_markdown: + reduce ((gsub("\\r\\n"; "\n") | split("\n"))[]) as $line + ({ fence: null, visible: [] }; + if .fence == null then + ([$line | try capture("^[ ]{0,3}(?`{3,})[^`]*$") catch null] | .[0] // null) as $backticks + | ([$line | try capture("^[ ]{0,3}(?~{3,}).*$") catch null] | .[0] // null) as $tildes + | if $backticks != null then + .fence = { character: "`", length: ($backticks.fence | length) } + elif $tildes != null then + .fence = { character: "~", length: ($tildes.fence | length) } + else + .visible += [$line] + end + else + . as $state + | if ($line | test( + "^[ ]{0,3}" + $state.fence.character + "{" + + ($state.fence.length | tostring) + ",}[ \t]*$")) + then .fence = null + else . + end + end) + | .visible + | join("\n"); + def leak_without_inert_markdown: - gsub("(?s)"; "") - | gsub("(?ms)^[ \t]*~{3,}[^\\r\\n]*\\r?\\n.*?^[ \t]*~{3,}[ \t]*(?:\\r?\\n|$)"; "") - | gsub("(?s)`+.*?`+"; ""); + # Fences are recognized only at line boundaries and close only with the same + # character at least as long as the opener. If no valid closer appears, the + # state machine consumes through EOF and emits none of the remaining lines. + leak_without_fenced_markdown + # Likewise, an unclosed HTML comment consumes the remainder of the body. + | gsub("(?s)|\\z)"; ""); def leak_exact_fixes_numbers($repo_re): [(.body // "") | leak_without_inert_markdown - | scan("(?i)\\bFixes\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b") + # Provenance is a dedicated contract line. Anchoring the full line means + # inline-code delimiters or surrounding prose can never authorize closure. + | scan("(?im)^[ \t]*Fixes\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ \t]*\\r?$") + | .[0]]; + +def leak_issue_reference_numbers($repo_re): + [(.body // "") + | leak_without_inert_markdown + | scan("(?im)^[ \t]*(?:Fixes|Refs)\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ \t]*\\r?$") | .[0]]; def leak_first_exact_fixes_number($repo_re): @@ -17,3 +53,33 @@ def leak_first_exact_fixes_number($repo_re): def leak_has_exact_fixes($repo_re; $number): leak_exact_fixes_numbers($repo_re) | any(. == ($number | tostring)); + +def leak_has_issue_reference($repo_re; $number): + leak_issue_reference_numbers($repo_re) | any(. == ($number | tostring)); + +# Keep the marker grammar identical to the workflow runtime: literal spaces are +# accepted around the key, while tabs/newlines are not. +def leak_scan_key: + [(.body // "") + | try capture("(?i)").key catch null] + | .[0] // null; + +# Input is the `gh api --paginate --slurp` result: an array of page arrays. +# Invalid shapes and missing merge timestamps block closure (fail closed). +def leak_reopen_guard($fix_merged_at): + if (type != "array") or any(.[]; type != "array") or + (($fix_merged_at | type) != "string") or ($fix_merged_at == "") then + { verified: false, block_close: true, reopened_at: null } + else + (add // []) as $events + | ([$events[] + | select(.event == "reopened") + | .created_at + | select(type == "string" and . != "")] + | max // null) as $reopened_at + | { + verified: true, + block_close: ($reopened_at != null and $reopened_at > $fix_merged_at), + reopened_at: $reopened_at + } + end; diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 9b3046dba698..44a7a868157d 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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"9e9177c02a26802cd75a9094fcac3297a227c77bc0215b775b20c9f87df75460","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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"e9cc5f2bbc30633a352143bdea11f40010fb859fe012e80907a9f908c48705c1","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 020865253c95..b3cba3459163 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -457,7 +457,9 @@ gh issue view "$N" --repo "$GITHUB_REPOSITORY" --json number,title,body,state \ API=$(jq -r '.title // ""' /tmp/gh-aw/agent/target-scan-issue.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] } }') -TARGET_LEAK_KEY=$(jq -r '(.body // "") | capture("(?i)").key // empty' \ +TARGET_LEAK_KEY=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -r ' + include "leak-workflow-provenance"; + leak_scan_key // empty' \ /tmp/gh-aw/agent/target-scan-issue.json) echo "target rooting API: $API" echo "target leak key: ${TARGET_LEAK_KEY:-}" @@ -468,7 +470,9 @@ echo "target leak key: ${TARGET_LEAK_KEY:-}" REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') # (a) Open [leak-fix] PR already addressing THIS issue number? Filters the Step 2.5 cache — no # new gh pr list call for this (or any later) candidate. -jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b"))]' \ +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg repo "$REPO_RE" ' + include "leak-workflow-provenance"; + [.[] | select(leak_has_issue_reference($repo; $n))]' \ /tmp/gh-aw/agent/open-leakfix-all.json \ > /tmp/gh-aw/agent/open-fix-prs.json jq 'length' /tmp/gh-aw/agent/open-fix-prs.json @@ -477,14 +481,17 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # same last-dotted-pair extraction used for $API. This is only a cheap prefilter: Step (b) # below resolves each PR's exact Fixes scan issue and compares leak-scan-key / retention path # before deciding to skip. API equality alone is never enough. -jq --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" ' +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" ' + include "leak-workflow-provenance"; def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; - def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + def bodykey: leak_scan_key; [.[] | select((((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey)))]' \ /tmp/gh-aw/agent/open-leakfix-all.json \ > /tmp/gh-aw/agent/same-api-open-candidates.json # (c) Closed-unmerged attempts for this issue (attempt cap = 3). Filters the Step 2.5 cache. -jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(((.body // "") | test("(?i)\\b(Fixes|Refs)\\b:?[ \t]*("+$repo+"#|[^0-9A-Za-z_/]#)"+$n+"\\b")) and (.mergedAt == null))]' \ +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg repo "$REPO_RE" ' + include "leak-workflow-provenance"; + [.[] | select(leak_has_issue_reference($repo; $n) and (.mergedAt == null))]' \ /tmp/gh-aw/agent/closed-leakfix-all.json \ > /tmp/gh-aw/agent/closed-fix-prs.json jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json @@ -494,7 +501,7 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ 'include "leak-workflow-provenance"; def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; - def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + def bodykey: leak_scan_key; [.[] | select(leak_has_exact_fixes($repo; $n) or (((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey)))]' \ @@ -544,7 +551,9 @@ enrich_scan_provenance () { [ -z "$scan_n" ] && continue issue=$(gh issue view "$scan_n" --repo "$GITHUB_REPOSITORY" --json number,title,body,state 2>/dev/null) || issue="" [ -z "$issue" ] && continue - key=$(jq -r '(.body // "") | capture("(?i)").key // empty' <<<"$issue") + key=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -r ' + include "leak-workflow-provenance"; + leak_scan_key // empty' <<<"$issue") jq -n --argjson pr "$pr" --argjson issue "$issue" --arg key "$key" \ '{pull_request:$pr, scan_issue:$issue, leak_scan_key:($key | if . == "" then null else . end)}' >> "$output" @@ -560,7 +569,7 @@ fi jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg apiplain "$API" --arg targetkey "$TARGET_LEAK_KEY" --arg repo "$REPO_RE" \ 'include "leak-workflow-provenance"; def titleapi: [scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length>0 then (.[0]|split(".")|.[-2:]|join(".")) else null end; - def bodykey: try ((.body // "") | capture("(?i)").key) catch null; + def bodykey: leak_scan_key; [.[] | select((leak_has_exact_fixes($repo; $n) | not) and ((((.title // "") | titleapi) == $apiplain) or (($targetkey != "") and (bodykey == $targetkey))))]' \ @@ -591,15 +600,19 @@ if [ "$(jq 'length' /tmp/gh-aw/agent/merged-exact-fix-prs.json)" -ge 1 ]; then # array, we cannot rule out a maintainer re-open, so write the override sentinel (skip close/ # rebuild, leave the issue open) instead of treating a failed lookup as an empty timeline. if gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate --slurp -H "Accept: application/vnd.github+json" \ - > /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null \ - && jq -e 'type == "array"' /tmp/gh-aw/agent/issue-timeline-pages.json >/dev/null 2>&1; then - # `gh api --paginate` writes each page as a SEPARATE JSON array; without --slurp a multi-page - # timeline emits concatenated arrays ([..][..]) and every jq read below would run once PER page, - # so REOPENED_AT could become multi-valued and the string comparison would misfire. --slurp - # collects the pages into one array-of-arrays; `add` flattens them into a single event stream. - jq 'add // []' /tmp/gh-aw/agent/issue-timeline-pages.json > /tmp/gh-aw/agent/issue-timeline.json - REOPENED_AT=$(jq -r '[.[] | select(.event=="reopened") | .created_at] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/issue-timeline.json) - if [ "$(jq -n --arg r "$REOPENED_AT" --arg m "$FIX_MERGED_AT" '($r != "") and ($m != "") and ($r > $m)')" = "true" ]; then + > /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null; then + # --slurp collects paginated arrays into one array-of-arrays. The shared + # helper validates that shape, flattens every page, and compares the newest + # re-open timestamp with the newest merged fix timestamp. + REOPEN_GUARD=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -c --arg m "$FIX_MERGED_AT" ' + include "leak-workflow-provenance"; + leak_reopen_guard($m)' /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null) || REOPEN_GUARD="" + if [ -z "$REOPEN_GUARD" ] || [ "$(jq -r '.verified' <<<"$REOPEN_GUARD")" != "true" ]; then + echo "REOPEN GUARD UNVERIFIED: timeline lookup for #$N returned malformed pagination data — failing closed; will NOT close or rebuild (cannot rule out a maintainer re-open)." \ + > "$REOPEN_OVERRIDE_FILE" + cat "$REOPEN_OVERRIDE_FILE" + elif [ "$(jq -r '.block_close' <<<"$REOPEN_GUARD")" = "true" ]; then + REOPENED_AT=$(jq -r '.reopened_at' <<<"$REOPEN_GUARD") echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ > "$REOPEN_OVERRIDE_FILE" cat "$REOPEN_OVERRIDE_FILE" From 07d917e32a6b239543285d5af8591dc8d062ed90 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 04:36:26 +0200 Subject: [PATCH 39/68] Harden leak workflow provenance handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 112 ++++++------ .github/scripts/leak-workflow-provenance.jq | 60 ++++--- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 47 ++++-- .github/workflows/leak-fixer.lock.yml | 88 +++------- .github/workflows/leak-fixer.md | 169 +++++++------------ 6 files changed, 217 insertions(+), 261 deletions(-) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index ba7cb9cb575a..20520e75a440 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -62,22 +62,19 @@ Describe 'Memory leak workflow provenance and safety' { return $null } - function Get-ProductionReopenGuard { + function Get-ProductionMergeProvenanceGuard { param( [Parameter(Mandatory)] - [object] $TimelinePages, - - [Parameter(Mandatory)] - [string] $FixMergedAt + [object] $PullRequest ) - $inputJson = ConvertTo-Json -InputObject $TimelinePages -Depth 10 -Compress + $inputJson = ConvertTo-Json -InputObject $PullRequest -Depth 10 -Compress $output = $inputJson | - & jq -L $script:provenanceModuleRoot -c --arg m $FixMergedAt ' + & jq -L $script:provenanceModuleRoot -c ' include "leak-workflow-provenance"; - leak_reopen_guard($m)' + leak_merge_provenance_guard' if ($LASTEXITCODE -ne 0) { - throw "Production jq re-open guard failed with exit code $LASTEXITCODE" + throw "Production jq merge provenance guard failed with exit code $LASTEXITCODE" } return $output | ConvertFrom-Json @@ -239,6 +236,10 @@ Describe 'Memory leak workflow provenance and safety' { @{ Body = ('````text' + [Environment]::NewLine + 'Fixes #146' + [Environment]::NewLine + '````' + [Environment]::NewLine + 'Fixes #147'); Expected = '147' } @{ Body = '' } @{ number = 5; body = ('````text' + [Environment]::NewLine + 'Fixes #500' + [Environment]::NewLine + '```' + [Environment]::NewLine + 'Refs #500') } + @{ number = 6; body = ' Refs #500' } + @{ number = 7; body = ' Refs #500' } + @{ number = 8; body = ("`tRefs #500") } ) | ConvertTo-Json -Compress $result = $inputJson | @@ -309,7 +315,7 @@ Describe 'Memory leak workflow provenance and safety' { ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - @($result) | Should -Be @(1, 2) + @($result) | Should -Be @(1, 2, 6) } It 'keeps two mechanisms on the same API as distinct leak identities' { @@ -344,48 +350,50 @@ Describe 'Memory leak workflow provenance and safety' { "jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-raw.json | while")) } - It 'limits destructive closure to exact Fixes provenance' { - $fixer | Should -Match 'ONLY an exact same-repo Fixes #N match may drive close-issue' + It 'uses exact immutable Fixes provenance only to skip already-fixed scan work' { + $fixer | Should -Match 'ONLY an exact immutable same-repo Fixes #N match' $fixer | Should -Match 'merged-exact-fix-prs\.json' - $fixer | Should -Match 'A `Refs` line or Type\.Member match can never\s+authorize closure' - $fixer | Should -Not -Match 'already fixes this issue number OR the same rooting' - } - - It 'uses a candidate-keyed non-empty sentinel without adding rm' { - $fixer | Should -Match 'REOPEN_OVERRIDE_FILE="/tmp/gh-aw/agent/reopen-override-\$\{N\}\.txt"' - $fixer | Should -Match 'if test -s "\$REOPEN_OVERRIDE_FILE"' - $fixer | Should -Not -Match 'rm -f /tmp/gh-aw/agent/reopen-override' - $fixer | Should -Not -Match '/tmp/gh-aw/agent/reopen-override\.txt' + $fixer | Should -Match 'A `Refs` line or Type\.Member match can never\s+prove' + $fixer | Should -Match 'automatic issue closure is disabled' + (Get-WorkflowParts -Path $fixerPath).Frontmatter | + Should -Not -Match '(?m)^\s+close-issue:' } - It 'behaviorally fails closed on re-open races and malformed paginated timelines' -Skip:(-not $script:jqAvailable) { - $mergedAt = '2026-08-01T10:00:00Z' - - $afterMerge = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages @( - @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:00:00Z' }), - @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T11:00:00Z' }) - ) - $afterMerge.verified | Should -BeTrue - $afterMerge.block_close | Should -BeTrue - [DateTimeOffset]$afterMerge.reopened_at | - Should -Be ([DateTimeOffset]'2026-08-01T11:00:00Z') - - $beforeMerge = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages (, @( - [pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:59:59Z' } - )) - $beforeMerge.verified | Should -BeTrue - $beforeMerge.block_close | Should -BeFalse - - $nonArray = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages ([pscustomobject]@{ message = 'API failure' }) - $nonArray.verified | Should -BeFalse - $nonArray.block_close | Should -BeTrue - - $malformedPage = Get-ProductionReopenGuard -FixMergedAt $mergedAt -TimelinePages @( - @([pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T09:00:00Z' }), - [pscustomobject]@{ event = 'reopened'; created_at = '2026-08-01T11:00:00Z' } - ) - $malformedPage.verified | Should -BeFalse - $malformedPage.block_close | Should -BeTrue + It 'fails closed when current merged PR provenance may have changed after merge' -Skip:(-not $script:jqAvailable) { + $neverEdited = Get-ProductionMergeProvenanceGuard -PullRequest ([pscustomobject]@{ + mergedAt = '2026-08-01T10:00:00Z' + lastEditedAt = $null + }) + $neverEdited.verified | Should -BeTrue + $neverEdited.block_provenance | Should -BeFalse + + $editedBeforeMerge = Get-ProductionMergeProvenanceGuard -PullRequest ([pscustomobject]@{ + mergedAt = '2026-08-01T10:00:00Z' + lastEditedAt = '2026-08-01T09:59:59Z' + }) + $editedBeforeMerge.verified | Should -BeTrue + $editedBeforeMerge.block_provenance | Should -BeFalse + + $editedAfterMerge = Get-ProductionMergeProvenanceGuard -PullRequest ([pscustomobject]@{ + mergedAt = '2026-08-01T10:00:00Z' + lastEditedAt = '2026-08-01T10:00:01Z' + }) + $editedAfterMerge.verified | Should -BeTrue + $editedAfterMerge.block_provenance | Should -BeTrue + + $missingMerge = Get-ProductionMergeProvenanceGuard -PullRequest ([pscustomobject]@{ + mergedAt = $null + lastEditedAt = '2026-08-01T09:00:00Z' + }) + $missingMerge.verified | Should -BeFalse + $missingMerge.block_provenance | Should -BeTrue + + $malformedEdit = Get-ProductionMergeProvenanceGuard -PullRequest ([pscustomobject]@{ + mergedAt = '2026-08-01T10:00:00Z' + lastEditedAt = 'not-a-timestamp' + }) + $malformedEdit.verified | Should -BeFalse + $malformedEdit.block_provenance | Should -BeTrue } It 'bounds hunter suppression to fixes absent from the shipped release' { @@ -398,10 +406,14 @@ Describe 'Memory leak workflow provenance and safety' { $packageMatch.Success | Should -BeTrue $versionMatch.Groups['version'].Value | Should -Be $packageMatch.Groups['version'].Value $hunter | Should -Match 'compare/\$SHIPPED_MAUI_VERSION\.\.\.\$sha' - $hunter | Should -Match 'WARNING: shipped release tag' + $hunter | Should -Match 'merge-time body provenance was mutable or unverified' $hunter | Should -Match 'WARNING: ancestry compare failed' + $hunter | Should -Match 'lastEditedAt' + $hunter | Should -Match 'select\(\(leak_exact_fixes_numbers\(\$repo\) \| length\) > 0\)' $hunter | Should -Match 'open-leakscan-provenance\.json' $hunter | Should -Match 'unshipped-main-fixes\.json' + $hunter | Should -Not -Match 'SHIPPED_MAUI_COMMIT_DATE' + $hunter | Should -Not -Match 'select\(\(\.mergedAt // ""\) > \$cutoff\)' $hunter | Should -Not -Match 'already-filed-apis\.txt' $hunter | Should -Not -Match 'fixed-on-main-apis\.txt' $hunter | Should -Not -Match '"title:"' diff --git a/.github/scripts/leak-workflow-provenance.jq b/.github/scripts/leak-workflow-provenance.jq index afad4fcd2f85..13e095145217 100644 --- a/.github/scripts/leak-workflow-provenance.jq +++ b/.github/scripts/leak-workflow-provenance.jq @@ -38,14 +38,16 @@ def leak_exact_fixes_numbers($repo_re): [(.body // "") | leak_without_inert_markdown # Provenance is a dedicated contract line. Anchoring the full line means - # inline-code delimiters or surrounding prose can never authorize closure. - | scan("(?im)^[ \t]*Fixes\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ \t]*\\r?$") + # inline-code delimiters, indented code, tabs, or surrounding prose cannot + # become provenance. CommonMark permits at most three leading spaces before + # a rendered contract line; four spaces or a tab starts an inert code block. + | scan("(?im)^[ ]{0,3}Fixes\\b:?[ ]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ ]*\\r?$") | .[0]]; def leak_issue_reference_numbers($repo_re): [(.body // "") | leak_without_inert_markdown - | scan("(?im)^[ \t]*(?:Fixes|Refs)\\b:?[ \t]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ \t]*\\r?$") + | scan("(?im)^[ ]{0,3}(?:Fixes|Refs)\\b:?[ ]*(?:" + $repo_re + "#|#)([0-9]+)\\b[ ]*\\r?$") | .[0]]; def leak_first_exact_fixes_number($repo_re): @@ -64,22 +66,42 @@ def leak_scan_key: | try capture("(?i)").key catch null] | .[0] // null; -# Input is the `gh api --paginate --slurp` result: an array of page arrays. -# Invalid shapes and missing merge timestamps block closure (fail closed). -def leak_reopen_guard($fix_merged_at): - if (type != "array") or any(.[]; type != "array") or - (($fix_merged_at | type) != "string") or ($fix_merged_at == "") then - { verified: false, block_close: true, reopened_at: null } - else - (add // []) as $events - | ([$events[] - | select(.event == "reopened") - | .created_at - | select(type == "string" and . != "")] - | max // null) as $reopened_at - | { +# GitHub exposes PullRequest.lastEditedAt and the complete edit history via +# GraphQL. Current PR bodies are safe as merge-time provenance only when the +# latest body edit was no later than the merge. Missing or malformed metadata +# fails closed so a post-merge `Fixes` edit cannot affect later automation. +def leak_merge_provenance_guard: + . as $pr + | (try ($pr.mergedAt | fromdateiso8601) catch null) as $merged_at + | if $merged_at == null then + { + verified: false, + block_provenance: true, + merged_at: ($pr.mergedAt // null), + last_edited_at: ($pr.lastEditedAt // null) + } + elif $pr.lastEditedAt == null then + { verified: true, - block_close: ($reopened_at != null and $reopened_at > $fix_merged_at), - reopened_at: $reopened_at + block_provenance: false, + merged_at: $pr.mergedAt, + last_edited_at: null } + else + (try ($pr.lastEditedAt | fromdateiso8601) catch null) as $last_edited_at + | if $last_edited_at == null then + { + verified: false, + block_provenance: true, + merged_at: $pr.mergedAt, + last_edited_at: $pr.lastEditedAt + } + else + { + verified: true, + block_provenance: ($last_edited_at > $merged_at), + merged_at: $pr.mergedAt, + last_edited_at: $pr.lastEditedAt + } + end end; diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 1c7cd4baacab..7d103810828f 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"8bb6c89e3f91e321f3272db8eef40257faaa3d21f8b54fb886862182a0fd7b94","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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"f02ef9f244a4755eaaa4938dbd4c9c93e42ee3cfa74a508494d033c70118b6d4","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 34691c3e77d6..b233fecc4f9a 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -187,30 +187,47 @@ SHIPPED_MAUI_VERSION=10.0.0 printf '%s\n' "$SHIPPED_MAUI_VERSION" > /tmp/gh-aw/agent/shipped-maui-version.txt REPO_RE=$(printf '%s' "$GITHUB_REPOSITORY" | sed -E 's/[][(){}.^$*+?|\\]/\\&/g') gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows base:main' \ - --limit 1000 --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json + --limit 1000 --json id,number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older unshipped fixes may be TRUNCATED and re-filed once — switch to date-windowed enumeration." fi -SHIPPED_MAUI_COMMIT_DATE=$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHIPPED_MAUI_VERSION" -q '.commit.committer.date' 2>/dev/null) || SHIPPED_MAUI_COMMIT_DATE="" -if [ -z "$SHIPPED_MAUI_COMMIT_DATE" ]; then - echo "WARNING: shipped release tag \"$SHIPPED_MAUI_VERSION\" could not be resolved — unshipped-fix suppression is degraded for this run." - echo '[]' > /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json -else - # A main-targeting PR merged before the shipped tag's commit cannot be an unshipped fix. - # Apply this cheap exact cutoff before spending compare-API calls on recent candidates. - jq --arg cutoff "$SHIPPED_MAUI_COMMIT_DATE" \ - '[.[] | select((.mergedAt // "") > $cutoff)]' \ - /tmp/gh-aw/agent/merged-leakfix-raw.json \ - > /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json -fi +# A tag timestamp does not prove ancestry: a release-branch tag can be newer than an unrelated +# main merge while excluding it. Retain every merged PR with current exact Fixes provenance until +# GraphQL edit verification and both compare APIs establish containment. +jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg repo "$REPO_RE" ' + include "leak-workflow-provenance"; + [.[] | select((leak_exact_fixes_numbers($repo) | length) > 0)]' \ + /tmp/gh-aw/agent/merged-leakfix-raw.json \ + > /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json printf '' > /tmp/gh-aw/agent/unshipped-main-fixes.ndjson printf '' > /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt +printf '' > /tmp/gh-aw/agent/merged-leakfix-provenance-failures.txt COMPARE_CANDIDATE_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json) jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json | while IFS= read -r pr; do + node_id=$(jq -r '.id // empty' <<<"$pr") sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") + [ -z "$node_id" ] && continue [ -z "$sha" ] && continue + edit_meta=$(gh api graphql -f id="$node_id" -f query=' + query($id: ID!) { + node(id: $id) { + ... on PullRequest { + mergedAt + lastEditedAt + } + } + }' 2>/dev/null) || edit_meta="" + provenance_guard=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -c ' + include "leak-workflow-provenance"; + .data.node | leak_merge_provenance_guard' <<<"$edit_meta" 2>/dev/null) || provenance_guard="" + if [ -z "$provenance_guard" ] || + [ "$(jq -r '.verified' <<<"$provenance_guard")" != "true" ] || + [ "$(jq -r '.block_provenance' <<<"$provenance_guard")" = "true" ]; then + printf '%s\n' "$(jq -r '.number' <<<"$pr")" >> /tmp/gh-aw/agent/merged-leakfix-provenance-failures.txt + continue + fi # Only workflow PRs with an exact SAME-REPO Fixes reference have scan provenance. A Refs line # points at a separate upstream issue and an arbitrary cross-repo #N must never become a join. scan_n=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -r --arg repo "$REPO_RE" ' @@ -247,6 +264,10 @@ jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json | while IF leak_scan_key:($key | if . == "" then null else . end)}' \ >> /tmp/gh-aw/agent/unshipped-main-fixes.ndjson done +PROVENANCE_FAILURE_COUNT=$(wc -l < /tmp/gh-aw/agent/merged-leakfix-provenance-failures.txt | tr -d ' ') +if [ "$PROVENANCE_FAILURE_COUNT" -gt 0 ]; then + echo "WARNING: merge-time body provenance was mutable or unverified for $PROVENANCE_FAILURE_COUNT merged fixes — those fixes cannot suppress scans." +fi COMPARE_FAILURE_COUNT=$(wc -l < /tmp/gh-aw/agent/merged-leakfix-compare-failures.txt | tr -d ' ') if [ "$COMPARE_FAILURE_COUNT" -gt 0 ]; then echo "WARNING: ancestry compare failed for $COMPARE_FAILURE_COUNT/$COMPARE_CANDIDATE_COUNT prefiltered merged fixes (tag \"$SHIPPED_MAUI_VERSION\" resolvable?) — unshipped-fix suppression is degraded." diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 44a7a868157d..dbf4d7938d6d 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":"c3de9a87334031efe37e54362bd673d4e02183eda58ffb6d7b3b1fa867c45d17","body_hash":"e9cc5f2bbc30633a352143bdea11f40010fb859fe012e80907a9f908c48705c1","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":"12df86157ce4218f8fe3c700e2e76384b94c31ea5c6bf8997431ac3ad33956c5","body_hash":"df7eaf6d126c19117b0547921a6ef146bdba841b3ad212d1fa48162a28806f73","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 # @@ -33,7 +33,8 @@ # 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving # the test actually catches the leak. (If it already passes on unpatched source, the agent # opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** -# close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).) +# close the scan issue. An immutable merged fix PR suppresses redundant rebuilds, but automatic +# issue closure is disabled — see Step 3 / gate (d).) # 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the # missing path), rebuilds, and confirms the **same test now PASSES** with no regression # in its neighbours. @@ -245,24 +246,24 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' + cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' - GH_AW_PROMPT_54a86f49a2a70409_EOF + GH_AW_PROMPT_1915128a947746b1_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' + cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' - Tools: add_comment, close_issue, create_pull_request, push_to_pull_request_branch, missing_tool, missing_data, noop - GH_AW_PROMPT_54a86f49a2a70409_EOF + Tools: add_comment, create_pull_request, push_to_pull_request_branch, missing_tool, missing_data, noop + GH_AW_PROMPT_1915128a947746b1_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' + cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' - GH_AW_PROMPT_54a86f49a2a70409_EOF + GH_AW_PROMPT_1915128a947746b1_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' + cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -304,12 +305,12 @@ jobs: stop immediately and report the limitation rather than spending turns trying to work around it. - GH_AW_PROMPT_54a86f49a2a70409_EOF + GH_AW_PROMPT_1915128a947746b1_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_54a86f49a2a70409_EOF' + cat << 'GH_AW_PROMPT_1915128a947746b1_EOF' {{#runtime-import .github/workflows/leak-fixer.md}} - GH_AW_PROMPT_54a86f49a2a70409_EOF + GH_AW_PROMPT_1915128a947746b1_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -544,34 +545,20 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_68e0eb28b9d0993b_EOF' - {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"close_issue":{"max":1,"required_labels":["agentic-workflows","perf/memory-leak 💦"],"required_title_prefix":"[leak-scan] ","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md"],"protected_files_policy":"blocked"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md"],"protected_files_policy":"blocked","target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_68e0eb28b9d0993b_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_72a48351838b7a86_EOF' + {"add_comment":{"max":1,"required_title_prefix":"[leak-fix]","target":"*"},"create_pull_request":{"allowed_base_branches":["main"],"allowed_branches":["leak-fix/**"],"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"base_branch":"main","draft":true,"labels":["agentic-workflows","perf/memory-leak 💦"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md"],"protected_files_policy":"blocked"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["src/Controls/src/**","src/Controls/tests/Core.UnitTests/**","src/Controls/tests/DeviceTests/**","src/Core/src/**","src/Essentials/src/**","**/PublicAPI.Unshipped.txt"],"if_no_changes":"ignore","max":1,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md"],"protected_files_policy":"blocked","target":"*","title_prefix":"[leak-fix]"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_72a48351838b7a86_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: *. Supports reply_to_id for discussion threading.", - "close_issue": " CONSTRAINTS: Maximum 1 issue(s) can be closed. Target: *. Only issues with title prefix \"[leak-scan] \" can be closed.", "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Labels [\"agentic-workflows\" \"perf/memory-leak 💦\"] will be automatically added. Only these labels are allowed: [\"agentic-workflows\" \"perf/memory-leak 💦\"]. PRs will be created as drafts.", "push_to_pull_request_branch": " CONSTRAINTS: Maximum 1 push(es) can be made. The target pull request title must start with \"[leak-fix]\"." }, "repo_params": {}, - "dynamic_tools": [], - "property_injections": { - "close_issue": { - "state_reason": { - "description": "Optional closing state reason. Omit to use the configured default. Select 'duplicate' together with 'duplicate_of' to mark a native duplicate relationship.", - "enum": [ - "completed", - "not_planned", - "duplicate" - ], - "type": "string" - } - } - } + "dynamic_tools": [] } GH_AW_VALIDATION_JSON: | { @@ -597,41 +584,6 @@ jobs: } } }, - "close_issue": { - "defaultMax": 1, - "fields": { - "body": { - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "confidence": { - "type": "string", - "enum": [ - "LOW", - "MEDIUM", - "HIGH" - ], - "x-strip-on-error": true - }, - "issue_number": { - "optionalPositiveInteger": true - }, - "rationale": { - "type": "string", - "sanitize": true, - "maxLength": 280, - "x-strip-on-error": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "suggest": { - "type": "boolean" - } - } - }, "create_pull_request": { "defaultMax": 1, "fields": { @@ -1487,7 +1439,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Memory Leak Fixer" - WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes on unpatched source, the agent\n opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not**\n close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." + WORKFLOW_DESCRIPTION: "Turns one open `[leak-scan]` issue (filed by the Daily Memory Leak Hunter) into a\nvalidated, draft `[leak-fix]` pull request. For the selected issue the agent:\n\n1. Locates the leaking product code in the CURRENT source tree (the leak was found in\n the shipped NuGet, so it may already be fixed on this branch — that is handled).\n2. Writes a focused **regression unit test** in `Controls.Core.UnitTests` (managed,\n library-TFM, no workload/emulator) that asserts the transient is collected.\n3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving\n the test actually catches the leak. (If it already passes on unpatched source, the agent\n opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not**\n close the scan issue. An immutable merged fix PR suppresses redundant rebuilds, but automatic\n issue closure is disabled — see Step 3 / gate (d).)\n4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the\n missing path), rebuilds, and confirms the **same test now PASSES** with no regression\n in its neighbours.\n5. Opens ONE draft `[leak-fix]` PR (`Fixes #`) carrying the test + fix, with the\n before/after evidence in the body.\n\nDetection (the hunter) uses the shipped package and needs no source build; the FIXER\nmust build MAUI **from source** to validate a product change, so it restores from the\npublic dnceng Azure DevOps feeds (`dev.azure.com`) — hence the wider network allowlist.\nScope is the managed `[leak-scan]` issues only; native/handler leaks are out of scope." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" with: @@ -1859,7 +1811,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.blob.core.windows.net,*.githubusercontent.com,*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dev.azure.com,dist.nuget.org,docs.github.com,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"close_issue\":{\"max\":1,\"required_labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"required_title_prefix\":\"[leak-scan] \",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\",\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"required_title_prefix\":\"[leak-fix]\",\"target\":\"*\"},\"create_pull_request\":{\"allowed_base_branches\":[\"main\"],\"allowed_branches\":[\"leak-fix/**\"],\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"base_branch\":\"main\",\"draft\":true,\"labels\":[\"agentic-workflows\",\"perf/memory-leak 💦\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"src/Controls/src/**\",\"src/Controls/tests/Core.UnitTests/**\",\"src/Controls/tests/DeviceTests/**\",\"src/Core/src/**\",\"src/Essentials/src/**\",\"**/PublicAPI.Unshipped.txt\"],\"if_no_changes\":\"ignore\",\"max\":1,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\"],\"protected_files_policy\":\"blocked\",\"target\":\"*\",\"title_prefix\":\"[leak-fix]\"},\"report_incomplete\":{}}" GH_AW_SAFE_OUTPUTS_STAGED: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index b3cba3459163..c31659104485 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -11,7 +11,8 @@ description: | 3. Builds + runs that test and confirms it **FAILS** on the unpatched source — proving the test actually catches the leak. (If it already passes on unpatched source, the agent opens NO PR — but a single green run is **not** proof the leak is fixed, so it does **not** - close the scan issue; closure happens only via a merged fix PR — see Step 3 / gate (d).) + close the scan issue. An immutable merged fix PR suppresses redundant rebuilds, but automatic + issue closure is disabled — see Step 3 / gate (d).) 4. Implements the product fix (weak subscription / `WeakEventManager` / teardown on the missing path), rebuilds, and confirms the **same test now PASSES** with no regression in its neighbours. @@ -102,11 +103,11 @@ safe-outputs: # Job-enforced dry-run: when `dry_run` is true, gh-aw stages every task safe-output below # (only logs/records what WOULD have been emitted) regardless of what the agent does. # The per-step "Dry-run gate" prompt text further down is a courtesy for a clean run log, but - # it is NOT what makes dry_run safe — an agent that emits close-issue/create-pull-request/etc. + # it is NOT what makes dry_run safe — an agent that emits a mutating safe-output anyway # anyway (ignoring the prompt) must still produce NO write. Making this an emitted-tool-call # decision inside the prompt (as it was before) leaves the actual guarantee entirely up to # model compliance; `staged` moves the task-output guarantee into the generated jobs, so a - # manual preview run (`dry_run: true`) cannot close an issue, open/push a PR, or add a comment. + # manual preview run (`dry_run: true`) cannot open/push a PR or add a comment. # gh-aw's separate conclusion job may still report framework diagnostics when a run is # incomplete or fails. Keep this identical to the pattern already used by # ci-status-main.md / ci-status-net11.md. @@ -154,22 +155,6 @@ safe-outputs: target: "*" # agent supplies the leak PR number required-title-prefix: "[leak-fix]" # Track C only comments on this workflow's own [leak-fix] PRs max: 1 - # Close an ORPHANED [leak-scan] issue whose leak is ALREADY FIXED on main by a MERGED - # [leak-fix] PR (Step 3 gate (d)) so it isn't re-picked and rebuilt from source every run. - # The agent supplies the issue number + a closing comment linking the merged fix. Write - # access lives ONLY in the isolated safe-outputs/conclusion jobs — the agent itself stays - # read-only. Deterministic guards: the safe-output validator will ONLY close an issue whose - # title starts with "[leak-scan] " AND that carries BOTH of this workflow's labels - # (`agentic-workflows` and `perf/memory-leak 💦` — the exact pair the hunter stamps, and locks - # via allowed-labels, on every [leak-scan] issue it creates). `agentic-workflows` alone is - # shared by other agentic workflows, so requiring the memory-leak label too prevents a - # hallucinated/injected number pointing at an unrelated agentic issue from being closed - # (rejected outright, not merely bounded by max:1). - close-issue: - target: "*" - required-title-prefix: "[leak-scan] " - required-labels: [agentic-workflows, "perf/memory-leak 💦"] - max: 1 # Most 6h runs are idle (every open leak already has a [leak-fix] PR). Without this, # gh-aw's auto-injected default (noop: report-as-issue: true) files a "no action taken" # issue every idle run. Mirror the hunter, which already opts out. @@ -196,7 +181,7 @@ You produce **only `[leak-fix]` PRs for empirically-proven leaks** — there is The PR base is always `main`. You build MAUI **from source** to validate. All scratch state goes under `/tmp/gh-aw/agent/` (each bash call is a fresh subshell; persist what you need). Your only writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch`, `add-comment`, -`close-issue`) — never push with raw git, never edit anything outside the fix/test. +and `noop`) — never push with raw git, never edit anything outside the fix/test. ## Hard rules — non-negotiable @@ -207,11 +192,11 @@ 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.** Close the scan issue **only** - when a merged `[leak-fix]` PR on `main` explicitly carries the exact same-repo - `Fixes #` provenance (Step 3 gate (d)). A `Refs` line or Type.Member match can never - authorize closure. Emit `close-issue` with an AI-attributed comment linking that exact fix and - record `skipped: already fixed on main`. If a different scan issue describes the same +2. **If a Track A leak is already fixed on `main`, open NO PR.** Treat it as proven only when + a merged `[leak-fix]` PR on `main` carries immutable exact same-repo `Fixes #` + provenance (Step 3 gate (d)). A `Refs` line or Type.Member match can never prove this. + Emit `noop` with `skipped: already fixed on main (scan issue left open; automatic closure + disabled)` and stop. If a different scan issue describes the same leak-scan-key/retention path, skip rebuilding but leave this issue open for manual reconciliation. If instead your freshly-authored regression test merely passes on unpatched source (Step 6) with no exact merged fix PR, that is NOT proof the leak is gone — do NOT close @@ -228,8 +213,8 @@ writes are the safe-outputs (`create-pull-request`, `push-to-pull-request-branch `WeakNotifyPropertyChangedProxy` / `WeakEventManager` — all already used in the codebase), or add the missing `-=` / teardown on the unload path. Prefer the minimal, idiomatic change that matches how the surrounding code already hardens similar subscriptions. -6. **One action per run.** Either respond to ONE PR's review (Track C) OR open ONE new draft PR - (Track A/B) OR close ONE already-fixed `[leak-scan]` issue (Step 3 gate (d)) — never two. +6. **One action per run.** Either respond to ONE PR's review (Track C), open ONE new draft PR + (Track A/B), or emit ONE `noop` for already-fixed work (Step 3 gate (d)) — never two. Honour the de-dup / attempt-cap / already-addressed gates so you never repeat yourself. 7. **AI attribution.** Every PR body and every comment must clearly state it was generated by this workflow. @@ -424,12 +409,12 @@ fi # MERGED [leak-fix] PRs — Step 3 first filters this raw set, then ancestry-checks only matches. # --limit 1000 = the GitHub SEARCH API's hard result ceiling (pagination cannot exceed it). If # the merged [leak-fix] set ever hits 1000, older fixes are TRUNCATED and this gate could miss a -# valid merged fix. Gate (d) stays fail-safe (it only CLOSES on a positive match, so a miss -# under-closes rather than wrongly closing), but close-coverage would be incomplete, so warn. +# valid merged fix. Gate (d) stays fail-safe (a miss rebuilds rather than suppressing a real leak), +# but de-dup coverage would be incomplete, so warn. gh pr list --repo "$GITHUB_REPOSITORY" --state merged --search '"[leak-fix]" in:title label:agentic-workflows' --limit 1000 \ - --json number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json + --json id,number,title,body,mergedAt,mergeCommit > /tmp/gh-aw/agent/merged-leakfix-raw.json if [ "$(jq 'length' /tmp/gh-aw/agent/merged-leakfix-raw.json)" -ge 1000 ]; then - echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. Exact-close remains fail-safe (under-closes) but coverage is incomplete — switch to date-windowed enumeration." + echo "WARNING: merged [leak-fix] provenance hit the 1000 search-API ceiling; older merged fixes may be TRUNCATED. De-dup remains fail-safe (may rebuild) but coverage is incomplete — switch to date-windowed enumeration." fi ``` @@ -441,9 +426,9 @@ Step 3 and re-apply its filters and targeted ancestry checks to the cached files ## Step 3 — De-dup + attempt cap (per-candidate filters against the Step 2.5 cache) A fix PR carries `Fixes #` in its body (where `` is the `[leak-scan]` issue) — that is the -sole scan-issue join and the ONLY key allowed to drive automatic closure. An optional -`Refs: /#` line points at a separate issue and can never authorize closing -`#`. Duplicate scan issues may still describe the same leak, so API matching is retained only +sole scan-issue join and the ONLY key allowed to prove an exact already-fixed match. An optional +`Refs: /#` line points at a separate issue and can never prove that +`#` was fixed. Duplicate scan issues may still describe the same leak, so API matching is retained only as a cheap prefilter. Before skipping work for a different issue number, compare its `leak-scan-key` or full retention path; the Type.Member alone is not a leak identity. @@ -508,13 +493,36 @@ jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg apiplain "$API" --a /tmp/gh-aw/agent/merged-leakfix-raw.json \ > /tmp/gh-aw/agent/merged-fix-candidates.json -# A merged PR is usable only when its merge commit is actually contained in main. Compare only -# the candidate subset above, not the entire up-to-1000-entry history. A failed compare omits the -# entry (fail open: rebuild rather than close/skip on unverified ancestry). +# A merged PR is usable only when its current body is proven to be its merge-time body and its +# merge commit is actually contained in main. PullRequest.lastEditedAt is available through +# GraphQL (including userContentEdits history); gh pr list does not expose it. A post-merge edit +# or unavailable/malformed edit metadata fails closed for provenance, so a later `Fixes` line +# cannot suppress work. Compare only the verified candidate subset, not the entire history. printf '' > /tmp/gh-aw/agent/merged-onmain-candidates.ndjson jq -c '.[]' /tmp/gh-aw/agent/merged-fix-candidates.json | while IFS= read -r pr; do + node_id=$(jq -r '.id // empty' <<<"$pr") sha=$(jq -r '.mergeCommit.oid // empty' <<<"$pr") + [ -z "$node_id" ] && continue [ -z "$sha" ] && continue + edit_meta=$(gh api graphql -f id="$node_id" -f query=' + query($id: ID!) { + node(id: $id) { + ... on PullRequest { + mergedAt + lastEditedAt + } + } + }' 2>/dev/null) || edit_meta="" + [ -z "$edit_meta" ] && continue + provenance_guard=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -c ' + include "leak-workflow-provenance"; + .data.node | leak_merge_provenance_guard' <<<"$edit_meta" 2>/dev/null) || provenance_guard="" + if [ -z "$provenance_guard" ] || + [ "$(jq -r '.verified' <<<"$provenance_guard")" != "true" ] || + [ "$(jq -r '.block_provenance' <<<"$provenance_guard")" = "true" ]; then + echo "WARNING: merged PR provenance is mutable or unverified; ignoring PR #$(jq -r '.number' <<<"$pr")" + continue + fi ahead=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" -q '.ahead_by' 2>/dev/null) || ahead="" if [ "$ahead" = "0" ]; then printf '%s\n' "$pr" >> /tmp/gh-aw/agent/merged-onmain-candidates.ndjson @@ -526,8 +534,9 @@ else echo '[]' > /tmp/gh-aw/agent/merged-onmain-candidates.json fi -# (d-exact) ONLY an exact same-repo Fixes #N match may drive close-issue. A same Type.Member with a -# different issue number is not provenance for this retention path and remains non-destructive. +# (d-exact) ONLY an exact immutable same-repo Fixes #N match may prove this selected scan issue is +# already fixed. A same Type.Member with a different issue number is not provenance for this +# retention path. This gate suppresses rebuilding only; automatic issue closure is disabled. jq -L "$GITHUB_WORKSPACE/.github/scripts" --arg n "$N" --arg repo "$REPO_RE" \ 'include "leak-workflow-provenance"; [.[] | select(leak_has_exact_fixes($repo; $n))]' \ @@ -583,79 +592,19 @@ else echo '[]' > /tmp/gh-aw/agent/same-api-merged-fixes.json fi -# Candidate-keyed sentinel: no shared path can leak state from an earlier candidate. `rm` is not -# in this workflow's shell allowlist, so initialize with allowlisted printf and test with `-s`. -REOPEN_OVERRIDE_FILE="/tmp/gh-aw/agent/reopen-override-${N}.txt" -printf '' > "$REOPEN_OVERRIDE_FILE" -# (d-override) MAINTAINER RE-OPEN GUARD: if this [leak-scan] issue was RE-OPENED *after* the -# newest matched merged [leak-fix] PR merged, a maintainer deliberately overrode the auto-close -# (the merged fix was incomplete / another retention edge remains). This workflow NEVER re-opens -# issues, so any 'reopened' event is an external human action. Respect it: never re-close (which -# would reverse the human decision every 6h and post a false "already fixed" comment) and never -# rebuild. Emit `skipped: scan issue re-opened after merged fix (maintainer override)` and stop. -if [ "$(jq 'length' /tmp/gh-aw/agent/merged-exact-fix-prs.json)" -ge 1 ]; then - FIX_MERGED_AT=$(jq -r '[.[].mergedAt] | map(select(. != null)) | max // empty' /tmp/gh-aw/agent/merged-exact-fix-prs.json) - # Fail CLOSED on this DESTRUCTIVE path: auto-closing a possibly maintainer-reopened issue must - # never happen on an unverified timeline. If the timeline cannot be fetched AND parsed as a JSON - # array, we cannot rule out a maintainer re-open, so write the override sentinel (skip close/ - # rebuild, leave the issue open) instead of treating a failed lookup as an empty timeline. - if gh api "repos/$GITHUB_REPOSITORY/issues/$N/timeline" --paginate --slurp -H "Accept: application/vnd.github+json" \ - > /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null; then - # --slurp collects paginated arrays into one array-of-arrays. The shared - # helper validates that shape, flattens every page, and compares the newest - # re-open timestamp with the newest merged fix timestamp. - REOPEN_GUARD=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -c --arg m "$FIX_MERGED_AT" ' - include "leak-workflow-provenance"; - leak_reopen_guard($m)' /tmp/gh-aw/agent/issue-timeline-pages.json 2>/dev/null) || REOPEN_GUARD="" - if [ -z "$REOPEN_GUARD" ] || [ "$(jq -r '.verified' <<<"$REOPEN_GUARD")" != "true" ]; then - echo "REOPEN GUARD UNVERIFIED: timeline lookup for #$N returned malformed pagination data — failing closed; will NOT close or rebuild (cannot rule out a maintainer re-open)." \ - > "$REOPEN_OVERRIDE_FILE" - cat "$REOPEN_OVERRIDE_FILE" - elif [ "$(jq -r '.block_close' <<<"$REOPEN_GUARD")" = "true" ]; then - REOPENED_AT=$(jq -r '.reopened_at' <<<"$REOPEN_GUARD") - echo "REOPEN OVERRIDE: issue #$N re-opened at $REOPENED_AT AFTER merged fix at $FIX_MERGED_AT — maintainer override; will NOT close or rebuild." \ - > "$REOPEN_OVERRIDE_FILE" - cat "$REOPEN_OVERRIDE_FILE" - fi - else - echo "REOPEN GUARD UNVERIFIED: timeline lookup for #$N failed or returned non-array JSON — failing closed; will NOT close or rebuild (cannot rule out a maintainer re-open)." \ - > "$REOPEN_OVERRIDE_FILE" - cat "$REOPEN_OVERRIDE_FILE" - fi -fi -if test -s "$REOPEN_OVERRIDE_FILE"; then - echo "re-open override active for candidate #$N" -else - echo "no re-open override for candidate #$N" -fi ``` -- **Re-open override (takes precedence over gate (d) and any rebuild):** if - `/tmp/gh-aw/agent/reopen-override-.txt` is **non-empty** — either THIS `[leak-scan]` issue was **re-opened - after** the newest matched merged `[leak-fix]` PR merged (a maintainer deliberately overrode the - auto-close), **or** the issue timeline could **not be fetched/parsed** so the re-open guard - failed closed — do NOT emit `close-issue` and do NOT rebuild. Read the sentinel and emit the - matching skip: `skipped: scan issue re-opened after merged fix (maintainer override)` for a real - re-open, or `skipped: re-open guard unverified (timeline lookup failed)` when the lookup failed; - then stop. Never reverse a deliberate human re-open, and never close on an unverified timeline - (either would re-close the issue every 6h and post a false "already fixed" comment). -- If an **exact** merged `[leak-fix]` PR in `merged-exact-fix-prs.json` carries this same-repo - `Fixes #` line **and the re-open override above did NOT fire** → this scan issue's fix is - already on `main`. Do NOT create a branch or rebuild. - Emit exactly one `close-issue` safe-output on THIS `[leak-scan]` issue `#` with a closing - comment (AI-attributed, linking the merged PR), e.g.: - > 🔍 **AI-generated action** (Memory Leak Fixer) — this leak is already fixed on `main` by - > # (``), whose body explicitly carries `Fixes #`. Closing this - > still-open `[leak-scan]` issue to stop the rebuild loop. Note: it may still reproduce in the - > shipped NuGet package until the fix ships in a release. - - **Dry-run gate** (control text — NOT part of the close comment above): if `dry_run == "true"`, - do NOT emit — print `DRY RUN — would close #` and the closing comment to the run log - instead, then stop. (Enforced job-side too: `safe-outputs.staged` stages, and does not - execute, `close-issue` when `dry_run` is true, so a manual preview run can never actually close - this issue even if a `close-issue` call is emitted.) - - This is the run's single action — stop after emitting `close-issue`. +- If an **exact immutable** merged `[leak-fix]` PR in `merged-exact-fix-prs.json` carries this + same-repo `Fixes #` line, the scan issue's fix is already on `main`. Do NOT create a branch + or rebuild. Emit exactly one `noop` with: + `skipped: already fixed on main (scan issue left open; automatic closure disabled)`, include + the merged PR number/title in the noop message, and stop. + + The workflow deliberately has no `close-issue` safe-output. In pinned gh-aw v0.85.4 the + close handler re-fetches title/labels/state, then posts a comment and PATCHes the issue closed; + it has no atomic timeline/version precondition. A maintainer can re-open between any check and + that deferred PATCH. Leaving the issue open while suppressing the redundant rebuild is the only + race-free way to preserve maintainer state with the available GitHub mutation APIs. - If an **open** fix PR already refs this exact issue (a) → `skipped: leak already being fixed` and stop (or, if `issue_number` was explicit, just stop). - For `same-api-open-fixes.json` and `same-api-merged-fixes.json`, API equality is only a From ebb7fb7e329696469cef07a5dead61227befae6b Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 05:35:23 +0200 Subject: [PATCH 40/68] Advance leak fixer past completed scans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 101 ++++++++++++++++--- .github/scripts/leak-workflow-provenance.jq | 14 ++- .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 11 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 43 +++++--- 6 files changed, 137 insertions(+), 36 deletions(-) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index 20520e75a440..1aa596990f5f 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -48,18 +48,19 @@ Describe 'Memory leak workflow provenance and safety' { return @($output) } - function Get-LeakScanKey { + function Get-ProductionLeakScanKey { param([string] $Body) - $match = [Regex]::Match( - $Body, - '(?i)') - - if ($match.Success) { - return $match.Groups['key'].Value.Trim() + $inputJson = @{ body = $Body } | ConvertTo-Json -Compress + $output = $inputJson | + & jq -L $script:provenanceModuleRoot -r ' + include "leak-workflow-provenance"; + leak_scan_key // empty' + if ($LASTEXITCODE -ne 0) { + throw "Production jq leak-scan-key parser failed with exit code $LASTEXITCODE" } - return $null + return @($output) } function Get-ProductionMergeProvenanceGuard { @@ -318,16 +319,78 @@ Describe 'Memory leak workflow provenance and safety' { @($result) | Should -Be @(1, 2, 6) } - It 'keeps two mechanisms on the same API as distinct leak identities' { - $collectionLeak = Get-LeakScanKey '' - $selectionLeak = Get-LeakScanKey '' + It 'keeps two mechanisms on the same API as distinct leak identities' -Skip:(-not $script:jqAvailable) { + $collectionLeak = Get-ProductionLeakScanKey '' + $selectionLeak = Get-ProductionLeakScanKey '' $collectionLeak | Should -Be 'Picker.ItemsSource|collectionchanged-retains-picker' $selectionLeak | Should -Be 'Picker.ItemsSource|selectedindexchanged-handler-retains-picker' $collectionLeak | Should -Not -Be $selectionLeak - Get-LeakScanKey 'legacy issue without a marker' | Should -BeNullOrEmpty - Get-LeakScanKey "" | Should -BeNullOrEmpty - Get-LeakScanKey "" | Should -BeNullOrEmpty + Get-ProductionLeakScanKey 'legacy issue without a marker' | Should -BeNullOrEmpty + Get-ProductionLeakScanKey "" | Should -BeNullOrEmpty + Get-ProductionLeakScanKey "" | Should -BeNullOrEmpty + } + + It 'ignores fenced and enclosing-comment marker decoys before the real marker' -Skip:(-not $script:jqAvailable) { + $backtickFence = @' +```text + +``` + +'@ + Get-ProductionLeakScanKey $backtickFence | + Should -Be 'Picker.ItemsSource|real-marker' + + $tildeFence = @' +~~~text + +~~~ + +'@ + Get-ProductionLeakScanKey $tildeFence | + Should -Be 'Picker.ItemsSource|real-marker' + + $enclosingComment = @' + + +'@ + Get-ProductionLeakScanKey $enclosingComment | + Should -Be 'Picker.ItemsSource|real-marker' + + $indentedCode = @' + + +'@ + Get-ProductionLeakScanKey $indentedCode | + Should -Be 'Picker.ItemsSource|real-marker' + + $inlineComment = @' +prefix + +'@ + Get-ProductionLeakScanKey $inlineComment | + Should -Be 'Picker.ItemsSource|real-marker' + + $fencedOnly = @' +```text + +``` +'@ + Get-ProductionLeakScanKey $fencedOnly | Should -BeNullOrEmpty + + $unclosedComment = @' + +'@ + Get-ProductionLeakScanKey $unclosedComment | Should -BeNullOrEmpty + } + + It 'routes both workflows through the shared Markdown-aware marker parser' { + $fixer | Should -Match 'def bodykey: leak_scan_key' + $hunter | Should -Match 'def leakkey: leak_scan_key' + $hunter | Should -Match 'leak_scan_key // empty' + $fixer | Should -Not -Match 'capture\("\(\?i\)").key catch null] + | leak_without_fenced_markdown + | scan("(?ms)^[ ]{0,3}()[ ]*\\r?$") + | .[0] + | try capture("(?i)^$").key catch null + | select(. != null)] | .[0] // null; # GitHub exposes PullRequest.lastEditedAt and the complete edit history via diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 7d103810828f..1f3b55dda170 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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"f02ef9f244a4755eaaa4938dbd4c9c93e42ee3cfa74a508494d033c70118b6d4","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":"9be9e717d96a1d71da81e5acfe7e50863d5762e0b89c851815da5f19547c2684","body_hash":"df14da836d00db898704d7a7a4823df785fb7e8693a9b436dd4cd5d81021fccd","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index b233fecc4f9a..cb51fa871bb7 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -160,13 +160,12 @@ gh issue list --repo "$GITHUB_REPOSITORY" --search '"[leak-scan]" in:title' \ # The rooting API is the LAST dotted Type.Member pair of the first identifier chain. Preserve the # issue title/body and canonical marker too: API equality is only a prefilter, because two # different retention paths can share one property. -jq ' +jq -L "$GITHUB_WORKSPACE/.github/scripts" ' + include "leak-workflow-provenance"; def titleapi: [(.title // "") | scan("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+")] | if length > 0 then (.[0] | split(".") | .[-2:] | join(".")) else null end; - def leakkey: - [(.body // "") | capture("(?i)").key] - | if length > 0 then .[0] else null end; + def leakkey: leak_scan_key; [.[] | {scan_issue:., rooting_api:titleapi, leak_scan_key:leakkey}] ' /tmp/gh-aw/agent/my-open-leakscan.json > /tmp/gh-aw/agent/open-leakscan-provenance.json jq -r '.[] | "open scan #\(.scan_issue.number): API \(.rooting_api // ""), key \(.leak_scan_key // "")"' \ @@ -257,7 +256,9 @@ jq -c '.[]' /tmp/gh-aw/agent/merged-leakfix-unshipped-candidates.json | while IF api=$(jq -r '.title // ""' <<<"$issue" \ | 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] } }') - leak_key=$(jq -r '(.body // "") | capture("(?i)").key // empty' <<<"$issue") + leak_key=$(jq -L "$GITHUB_WORKSPACE/.github/scripts" -r ' + include "leak-workflow-provenance"; + leak_scan_key // empty' <<<"$issue") jq -n --argjson pr "$pr" --argjson issue "$issue" --arg api "$api" --arg key "$leak_key" \ '{pull_request:{number:$pr.number,title:$pr.title,mergedAt:$pr.mergedAt,mergeCommit:$pr.mergeCommit}, scan_issue:$issue, rooting_api:$api, diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index dbf4d7938d6d..55e95f6c0faa 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":"12df86157ce4218f8fe3c700e2e76384b94c31ea5c6bf8997431ac3ad33956c5","body_hash":"df7eaf6d126c19117b0547921a6ef146bdba841b3ad212d1fa48162a28806f73","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":"12df86157ce4218f8fe3c700e2e76384b94c31ea5c6bf8997431ac3ad33956c5","body_hash":"18c0b79c8f7cf81fe1369e772b754b8aae0620f149754466568e367a8d4bd5dd","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index c31659104485..8a17fa8f9320 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -358,8 +358,10 @@ a response, fall through to Step 2. ## Step 2 — Select the target `[leak-scan]` issue 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). +auto-pick: materialize this scanner's open `[leak-scan]` issues as an ordered candidate queue +(oldest first). Evaluate candidates one at a time through every Step 3 gate until one is +actionable. Do not make the oldest issue a terminal choice before those gates run: a proven +merged fix remains open by design and must not starve newer unfixed issues. ```bash # Open [leak-scan] (Track A) issues, oldest first. @@ -372,7 +374,8 @@ echo "--- [leak-scan] (Track A) ---"; jq -r '.[] | "\(.number)\t\(.title)"' /tmp (The Daily Memory Leak Hunter files `[leak-scan]` issues with the `agentic-workflows` label.) -Read the chosen issue's body in full (`gh issue view --json title,body`). Extract: +For the current queue candidate, read the issue's body in full +(`gh issue view --json title,body`). Extract: - the **rooting API** (e.g. `IndicatorView.ItemsSource`), - the **retention path** `root -> … -> transient` with the cited file:line(s), @@ -594,19 +597,30 @@ fi ``` +**Gate disposition rule (prevents oldest-fixed starvation):** + +- When `issue_number` was explicitly supplied, a no-work gate emits one informative `noop` and + stops, because the caller requested that exact issue. +- During automatic selection, a no-work gate emits **no safe-output**, records the skip in the run + log, and immediately continues with the next entry in `leakscan-issues.json`. Never stop the run + merely because an auto-selected candidate is already fixed, already being fixed, duplicate, + capped, or otherwise ineligible. + - If an **exact immutable** merged `[leak-fix]` PR in `merged-exact-fix-prs.json` carries this same-repo `Fixes #` line, the scan issue's fix is already on `main`. Do NOT create a branch - or rebuild. Emit exactly one `noop` with: - `skipped: already fixed on main (scan issue left open; automatic closure disabled)`, include - the merged PR number/title in the noop message, and stop. + or rebuild. Record: + `skipped: already fixed on main (scan issue left open; automatic closure disabled)`, including + the merged PR number/title. If this candidate was auto-selected, continue to the next candidate + without emitting `noop`; if `issue_number` was explicit, emit that message as one `noop` and + stop. The workflow deliberately has no `close-issue` safe-output. In pinned gh-aw v0.85.4 the close handler re-fetches title/labels/state, then posts a comment and PATCHes the issue closed; it has no atomic timeline/version precondition. A maintainer can re-open between any check and that deferred PATCH. Leaving the issue open while suppressing the redundant rebuild is the only race-free way to preserve maintainer state with the available GitHub mutation APIs. -- If an **open** fix PR already refs this exact issue (a) → `skipped: leak already being fixed` - and stop (or, if `issue_number` was explicit, just stop). +- If an **open** fix PR already refs this exact issue (a) → record + `skipped: leak already being fixed`, then apply the gate disposition rule. - For `same-api-open-fixes.json` and `same-api-merged-fixes.json`, API equality is only a prefilter. Skip without closing/rebuilding only when the referenced scan issue has the same non-empty `leak-scan-key` as `TARGET_LEAK_KEY`, or its title/body describes the same @@ -614,10 +628,15 @@ fi If the Type.Member matches but the mechanism differs, continue normally. A same-leak merged fix under a different scan issue number is non-destructive: leave this issue open for manual reconciliation rather than claiming it was explicitly fixed. -- 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). - -If you auto-selected in Step 2 and the first candidate is gated out, move to the next oldest. +- If **3+ closed-unmerged** attempts exist → record `skipped: attempt cap reached (3)`, then + apply the gate disposition rule. +- An issue that is already CLOSED → record `skipped: issue closed`, then apply the gate + disposition rule. + +In automatic mode, keep advancing until a candidate reaches Track A or the ordered queue is +exhausted. If every candidate is gated out, emit one final `noop` summarizing the skip counts and +stop. This final queue-exhausted noop is the only safe-output produced by an all-skipped automatic +run. Then proceed with **Track A** (`[leak-scan]`) → Steps 4–10. From 82c1681ea2eedccb5c7906bdce115c491fd1a707 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 06:37:53 +0200 Subject: [PATCH 41/68] Verify workflow schedule lock synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowPrompt.Tests.ps1 | 89 ++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 index 1aa596990f5f..e2677fbcc6cf 100644 --- a/.github/scripts/LeakWorkflowPrompt.Tests.ps1 +++ b/.github/scripts/LeakWorkflowPrompt.Tests.ps1 @@ -208,6 +208,88 @@ Describe 'Memory leak workflow provenance and safety' { return $match.Groups['json'].Value | ConvertFrom-Json } + + function Get-FriendlyScheduleHours { + param([Parameter(Mandatory)][string] $Path) + + $frontmatter = (Get-WorkflowParts -Path $Path).Frontmatter + $lines = $frontmatter -split "`n" + $onIndent = $null + foreach ($line in $lines) { + $trimmed = $line.Trim() + $indent = $line.Length - $line.TrimStart().Length + if ($null -eq $onIndent) { + if ($trimmed -eq 'on:') { + $onIndent = $indent + } + continue + } + + if ($indent -le $onIndent -and $trimmed) { + break + } + + if ($trimmed -match '^schedule:\s*(?.+)$') { + $schedule = $Matches.value.Trim() + if ($schedule -notmatch '^every\s+(?[1-9][0-9]*)h$') { + throw "Unsupported friendly schedule '$schedule' in $Path" + } + return [int]$Matches.hours + } + } + + throw "No friendly schedule found in $Path" + } + + function Get-CompiledCronIntervalHours { + param([Parameter(Mandatory)][string] $Lock) + + $cronMatch = [Regex]::Match( + $Lock, + '(?ms)^on:\s*$.*?^\s+schedule:\s*$.*?^\s+-\s+cron:\s+["'']?(?[^"'']+?)["'']?\s*(?:#.*)?$') + if (-not $cronMatch.Success) { + throw 'Compiled lock has no scheduled cron trigger' + } + + $parts = $cronMatch.Groups['cron'].Value.Trim() -split '\s+' + if ($parts.Count -ne 5) { + throw "Unsupported cron '$($cronMatch.Groups['cron'].Value)'" + } + + $minute, $hour, $day, $month, $dayOfWeek = $parts + if ($minute -notmatch '^(?:[0-5]?[0-9])$' -or + $day -ne '*' -or $month -ne '*' -or $dayOfWeek -ne '*') { + throw "Cron '$($parts -join ' ')' is not a fixed-minute hourly cadence" + } + + if ($hour -match '^\*/(?[1-9][0-9]*)$') { + $step = [int]$Matches.step + if (24 % $step -ne 0) { + throw "Cron hour step '$hour' does not divide a day evenly" + } + $hours = @(for ($value = 0; $value -lt 24; $value += $step) { $value }) + } + elseif ($hour -match '^[0-9]+(?:,[0-9]+)+$') { + $hours = @($hour.Split(',') | ForEach-Object { [int]$_ } | Sort-Object -Unique) + if ($hours[0] -lt 0 -or $hours[-1] -gt 23) { + throw "Cron hour list '$hour' is outside 0-23" + } + } + else { + throw "Unsupported cron hour field '$hour'" + } + + $gaps = for ($index = 0; $index -lt $hours.Count; $index++) { + $next = if ($index -eq $hours.Count - 1) { $hours[0] + 24 } else { $hours[$index + 1] } + $next - $hours[$index] + } + $distinctGaps = @($gaps | Sort-Object -Unique) + if ($distinctGaps.Count -ne 1) { + throw "Cron hour field '$hour' is not a uniform cadence" + } + + return [int]$distinctGaps[0] + } } It 'runs provenance fixtures through the production jq parser' -Skip:(-not $script:jqAvailable) -ForEach @( @@ -510,4 +592,11 @@ prefix $hunterMetadata.body_hash | Should -Be (Get-WorkflowBodyHash -Path $hunterPath) $hunterLock | Should -Not -Match 'fixed-on-main-apis\.txt' } + + It 'keeps friendly source schedules aligned with compiled cron cadences' { + Get-CompiledCronIntervalHours -Lock $fixerLock | + Should -Be (Get-FriendlyScheduleHours -Path $fixerPath) + Get-CompiledCronIntervalHours -Lock $hunterLock | + Should -Be (Get-FriendlyScheduleHours -Path $hunterPath) + } } From d2f0b65394c323d0e15648ef3df46e224a03cd33 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 20:35:13 +0200 Subject: [PATCH 42/68] Harden leak workflow mutation gates Require structured same-API comparison disclosures and add a trusted final live de-dup gate for leak-hunter issue creation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 21 +++ .../Assert-LeakHunterSafeOutputGate.ps1 | 169 +++++++++++++++++ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 175 +++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 6 + .github/workflows/daily-leak-hunter.lock.yml | 28 ++- .github/workflows/daily-leak-hunter.md | 38 +++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 25 ++- 8 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/Assert-LeakHunterSafeOutputGate.ps1 diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index eb5e41d7454a..c246da8b7dd8 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -158,4 +158,25 @@ if ($result.Blocked) { throw "Final leak-fix de-dup gate blocked PR creation: $($result.Reason)." } +$body = [string]$item.body +foreach ($match in $result.ApiMatches) { + $number = [int]$match.number + $decisions = @($state.different_mechanism_prs | Where-Object { + [int]$_.number -eq $number + }) + if ($decisions.Count -ne 1) { + throw "Final leak-fix de-dup gate could not find exactly one mechanism decision for live same-API PR #$number." + } + + $basis = [string]$decisions[0].basis + $disclosurePattern = "(?m)^[ `t]*Same-API comparison:[ `t]*$repo#$number[ `t]*\|[ `t]*Different mechanism:[ `t]*(?[^|`r`n]{12,500}?)[ `t]*$" + $disclosures = [regex]::Matches($body, $disclosurePattern) + if ($disclosures.Count -ne 1) { + throw "The PR body must contain exactly one structured same-API disclosure for live PR #${number}: 'Same-API comparison: $Repository#$number | Different mechanism: '." + } + if ($disclosures[0].Groups['basis'].Value.Trim() -cne $basis) { + throw "The PR body same-API disclosure basis for PR #$number does not match the persisted comparison basis." + } +} + 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..ecab8817f837 --- /dev/null +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -0,0 +1,169 @@ +#!/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 + +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 Invoke-GhJson { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + $output = & gh @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "'gh $($Arguments -join ' ')' failed with exit code $LASTEXITCODE`: $output" + } + $raw = ($output -join [Environment]::NewLine) + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "'gh $($Arguments -join ' ')' returned an empty response." + } + try { + return $raw | ConvertFrom-Json + } catch { + throw "'gh $($Arguments -join ' ')' returned invalid JSON: $($_.Exception.Message)" + } +} + +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)." +} + +$requestedApis = [System.Collections.Generic.Dictionary[string, 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 ($requestedApis.ContainsKey($api)) { + throw "Multiple create-issue outputs target the same canonical API '$api'." + } + $requestedApis.Add($api, $title) +} + +$openIssues = @( + Invoke-GhJson -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-GhJson -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." +} + +$mergedReverts = @( + Invoke-GhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--limit', '1000', + '--search', '"Revert" in:title', + '--json', 'number,title,body,mergedAt' + ) +) +if ($mergedReverts.Count -ge 1000) { + throw "Merged Revert search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +} + +$eligibleMerged = @($merged | Where-Object { + $null -ne $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and + [string]$_.baseRefName -in @('main', 'inflight/current') + }) +$effectivelyReverted = @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository $Repository ` + -FixPullRequestNumbers @($eligibleMerged | ForEach-Object { [int]$_.number }) ` + -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) + }) + +$openApiMatches = @($openIssues | Where-Object { + $title = [string]$_.title + $api = Get-CanonicalLeakApi -Title $title + $title.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and + -not [string]::IsNullOrWhiteSpace($api) -and + $requestedApis.ContainsKey($api) + }) +$mergedApiMatches = @($eligibleMerged | Where-Object { + $api = Get-CanonicalLeakApi -Title ([string]$_.title) + -not [string]::IsNullOrWhiteSpace($api) -and + $requestedApis.ContainsKey($api) + }) + +if ($openApiMatches.Count -gt 0 -or $mergedApiMatches.Count -gt 0) { + $reasons = @() + if ($openApiMatches.Count -gt 0) { + $reasons += "open [leak-scan] issue match: $($openApiMatches.number -join ', ')" + } + if ($mergedApiMatches.Count -gt 0) { + $reasons += "active merged [leak-fix] PR match: $($mergedApiMatches.number -join ', ')" + } + throw "Final leak-hunter de-dup gate blocked issue creation: $($reasons -join '; ')." +} + +Write-Host "Final leak-hunter de-dup gate passed for APIs: $($requestedApis.Keys -join ', ')." diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 66c1cf71a8e0..e6024b87266b 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -60,6 +60,28 @@ Describe 'fresh-shell de-dup state' { -Repository 'dotnet/maui' } | Should -Throw '*lacks a specific basis*' } + + It 'rejects a comparison basis that cannot fit the structured body disclosure' { + $state = [pscustomobject]@{ + issue_number = 42 + api = 'Picker.ItemsSource' + repository = 'dotnet/maui' + different_mechanism_prs = @( + [pscustomobject]@{ + number = 100 + basis = "Existing teardown path|different reset path" + } + ) + } + + { + Assert-LeakDedupState ` + -State $state ` + -IssueNumber 42 ` + -Api 'Picker.ItemsSource' ` + -Repository 'dotnet/maui' + } | Should -Throw '*invalid basis format*' + } } Describe 'mechanism-aware final duplicate gate' { @@ -273,6 +295,17 @@ Describe 'workflow enforcement boundary' { )).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 + + $workflow | Should -Match '(?s)safe-outputs:.*steps:.*Assert-LeakHunterSafeOutputGate\.ps1.*create-issue:' + $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'\)" + } + Context 'safe-output gate script' { BeforeEach { $script:agentOutput = Join-Path $TestDrive 'agent_output.json' @@ -390,7 +423,7 @@ Describe 'workflow enforcement boundary' { } | Should -Throw '*failed with exit code 1*' } - It 'allows a live same-API match only after a persisted mechanism decision' { + It 'rejects a live same-API decision that is absent from the PR body' { $global:mockMerged = @( New-LeakPr ` -Number 501 ` @@ -410,6 +443,44 @@ Describe 'workflow enforcement boundary' { } | 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 '*structured same-API disclosure*#501*' + } + + It 'allows a live same-API match when the body discloses the exact persisted basis' { + $basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' + $global:mockMerged = @( + New-LeakPr ` + -Number 501 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` + -Body 'Fixes #10' + ) + @{ + issue_number = 20 + api = 'GradientBrush.GradientStops' + repository = 'dotnet/maui' + different_mechanism_prs = @( + @{ + number = 501 + basis = $basis + } + ) + } | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') + $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: $basis +"@ + $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput + { & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` -AgentOutputPath $script:agentOutput ` @@ -418,6 +489,41 @@ Describe 'workflow enforcement boundary' { } | Should -Not -Throw } + It 'rejects a same-API disclosure whose basis differs from persisted state' { + $global:mockMerged = @( + New-LeakPr ` + -Number 501 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` + -Body 'Fixes #10' + ) + @{ + issue_number = 20 + api = 'GradientBrush.GradientStops' + repository = 'dotnet/maui' + different_mechanism_prs = @( + @{ + number = 501 + basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' + } + ) + } | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].body = @' +Fixes #20 +Refs: dotnet/maui#20 +Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are definitely unrelated. +'@ + $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 '*does not match the persisted comparison basis*' + } + It 'allows a release-only open PR even when it directly references the issue' { $global:mockOpen = @( New-LeakPr ` @@ -458,4 +564,71 @@ Describe 'workflow enforcement boundary' { } | 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 + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:LASTEXITCODE = $global:mockHunterGhExitCode + 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 '"Revert" in:title') { + 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 ` + -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 '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*active merged*701*' + } + } } diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 90e5802b4fc4..9cec10d75431 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -67,6 +67,12 @@ function Assert-LeakDedupState { ([string]$decision.basis).Length -lt 12) { throw "Different-mechanism decision for PR #$number lacks a specific basis." } + $basis = [string]$decision.basis + if ($basis -cne $basis.Trim() -or + $basis.Length -gt 500 -or + $basis -match "[|`r`n]") { + throw "Different-mechanism decision for PR #$number has an invalid basis format." + } } return @($seen | Sort-Object) diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 2ca7f64bea9a..34db3f420678 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":"cbb55912d96480e7388049dcc41ed09c7db0cf8157e7542ef23d8793f1c2db4a","body_hash":"2ad3941200e41de8faff1bc70e8a4220599e8ee5d6c2486f2228974ceb474b70","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":"4c8525d06fccb902ed3b22490e4eb69cc5a991ee19b16cadaa4d3378f5668f03","body_hash":"4ee33649153d143eccbc6985c87494a4bcd6ce81face6a614dd81dd4dd828e02","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 # @@ -1039,6 +1039,7 @@ jobs: environment: copilot-pat-pool permissions: actions: read + contents: read issues: write concurrency: group: "gh-aw-conclusion-daily-leak-hunter" @@ -1615,6 +1616,7 @@ jobs: runs-on: ubuntu-slim environment: copilot-pat-pool permissions: + contents: read issues: write timeout-minutes: 45 env: @@ -1682,6 +1684,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 aafd23d190e4..3efb2bbf2f2c 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -186,6 +186,36 @@ 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 so a late merge/open + # issue blocks mutation. Restore the gate from the read-only default branch rather than + # executing workflow-dispatch-selected repository 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` @@ -300,6 +330,10 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts - Re-filing a leak this scanner already has open or that already has a merged fix (even with different issue wording/number) is the primary failure mode, so be strict about the canonical `Type.Member`. +- Immediately before issue mutation, a trusted safe-output step independently re-fetches open + scanner issues, merged fixes, and effective revert state. A late issue or fix that appears + during this run blocks all matching `create-issue` output fail-closed; do not treat the + pre-agent snapshot as the final authority. A candidate whose only prior scanner issue is CLOSED may be re-filed only when its API is absent from `already-merged-fix-apis.txt`. @@ -426,7 +460,9 @@ no MAUI source build, no emulator. 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, supported-branch merged `[leak-fix]` PRs, AND 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 84c0467c4bed..dbc98cf6574a 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":"5afd872b8f7398cff2b159862aac7319ecc442be448aaf45fad12267e6d6d6cb","body_hash":"5ec543e26f26347bf48bb9ad30a9c85896fe319cf6a9b9982510d2775206d00f","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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"f2fca7fce16c3ebf5775d7c02b6b21eb57d2000dfebe6eeccde7812128f2b66b","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 4a0bef369d9b..1bb8d7162b39 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -104,7 +104,8 @@ safe-outputs: # safe-output job immediately before Process Safe Outputs and fails the job before any PR # mutation when live metadata or the persisted mechanism decisions are incomplete/stale. # The boundary validates live match coverage and decision structure; the retention-mechanism - # comparison itself remains an agent-authored semantic judgment for human review. + # comparison itself remains an agent-authored semantic judgment, but every relied-on decision + # must also appear in a bounded structured PR-body line for human review. steps: - name: Restore trusted leak-fix de-dup gate if: ${{ contains(needs.agent.outputs.output_types, 'create_pull_request') }} @@ -647,10 +648,12 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json (a) or (c), append one object to the `different_mechanism_prs` array in `/tmp/gh-aw/agent/dedup-state.json`: `{"number": , "basis": ""}`. - Keep the `basis` concise but specific (at least 12 characters), do not copy untrusted PR text - verbatim, and preserve the other state fields. Use `jq` plus a `.next.json` file and `cat` - back over the state file. The safe-output gate rejects missing, duplicate, malformed, or - identity-mismatched decisions. + Keep the `basis` concise but specific (12–500 characters), single-line, with no `|`; do not + copy untrusted PR text verbatim, and preserve the other state fields. Use `jq` plus a + `.next.json` file and `cat` back over the state file. The safe-output gate rejects missing, + duplicate, malformed, or identity-mismatched decisions. It also requires the emitted PR body + to contain exactly one matching human-visible line for every live approved PR: + `Same-API comparison: /# | Different mechanism: `. - 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). @@ -815,7 +818,8 @@ gate. The generated safe-output job independently re-fetches live metadata and r `create_pull_request` immediately before Process Safe Outputs unless every current API-only match has a structurally valid different-mechanism decision and no direct issue-reference match exists. The gate does not claim to independently prove the semantic comparison in each -decision; that agent-authored basis remains visible for human review. +decision; it requires the exact persisted basis to be disclosed in the structured PR-body form +so that the agent-authored judgment remains visible for human review. ```bash set -euo pipefail @@ -955,6 +959,10 @@ echo "API-only matches without a persisted mechanism decision:"; cat /tmp/gh-aw/ `{"number": , "basis": ""}` to `dedup-state.json.different_mechanism_prs` with the same atomic `jq`/`.next.json`/`cat` pattern as Step 3. +- For every live different-mechanism decision, include exactly one body line using the basis + byte-for-byte from state: + `Same-API comparison: /# | Different mechanism: `. + The mutation-boundary gate rejects missing, duplicated, or mismatched disclosure lines. - Do not rely on `exit 1` here as enforcement. Even if you route around a failed tool call or forget a decision, the safe-output mutation-boundary step independently re-fetches both lists and blocks creation fail-closed. @@ -1007,6 +1015,11 @@ control is collected. ## Scope Managed cross-platform change → all platforms. No public API change (or: list the PublicAPI.Unshipped.txt entries added). + +## Same-API comparisons + +Same-API comparison: /# | Different mechanism: ``` Before emitting, re-read your body and confirm the `Target branch:` line says `main` and a From dc8c4e1132150144adbc207ec3507dc959b8d526 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 21:52:34 +0200 Subject: [PATCH 43/68] Harden leak de-dup metadata gates Grant the hunter gate its read scope, support structured mechanism overrides, scope revert parity by branch, and separate gh stdout from diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 26 +-- .../Assert-LeakHunterSafeOutputGate.ps1 | 99 ++++++---- .../Get-EffectiveRevertedLeakFixes.ps1 | 15 +- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 187 +++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 84 +++++++- .github/workflows/daily-leak-hunter.lock.yml | 5 +- .github/workflows/daily-leak-hunter.md | 86 +++++--- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 11 +- 9 files changed, 395 insertions(+), 120 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index c246da8b7dd8..a52ba5e53ad2 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -31,24 +31,6 @@ function Read-RegularJsonFile { } } -function Invoke-GhJson { - param([Parameter(Mandatory = $true)][string[]]$Arguments) - - $output = & gh @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "'gh $($Arguments -join ' ')' failed with exit code $LASTEXITCODE`: $output" - } - $raw = ($output -join [Environment]::NewLine) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "'gh $($Arguments -join ' ')' returned an empty response." - } - try { - return $raw | ConvertFrom-Json - } catch { - throw "'gh $($Arguments -join ' ')' returned invalid JSON: $($_.Exception.Message)" - } -} - if ([string]::IsNullOrWhiteSpace($Repository)) { throw 'GITHUB_REPOSITORY is required.' } @@ -104,7 +86,7 @@ $approved = @( ) $merged = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'merged', @@ -118,13 +100,13 @@ if ($merged.Count -ge 1000) { } $mergedReverts = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'merged', '--limit', '1000', '--search', '"Revert" in:title', - '--json', 'number,title,body,mergedAt' + '--json', 'number,title,body,baseRefName,mergedAt' ) ) if ($mergedReverts.Count -ge 1000) { @@ -132,7 +114,7 @@ if ($mergedReverts.Count -ge 1000) { } $open = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'open', diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index ecab8817f837..742beb8e4e81 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -30,21 +30,25 @@ function Read-RegularJsonFile { } } -function Invoke-GhJson { - param([Parameter(Mandatory = $true)][string[]]$Arguments) +function Assert-SameApiDisclosure { + param( + [Parameter(Mandatory = $true)][string]$Body, + [Parameter(Mandatory = $true)][ValidateSet('issue', 'pull-request')][string]$Kind, + [Parameter(Mandatory = $true)][int]$Number, + [Parameter(Mandatory = $true)][string]$Repository + ) - $output = & gh @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "'gh $($Arguments -join ' ')' failed with exit code $LASTEXITCODE`: $output" - } - $raw = ($output -join [Environment]::NewLine) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "'gh $($Arguments -join ' ')' returned an empty response." + $repo = [regex]::Escape($Repository) + $label = if ($Kind -eq 'issue') { + 'Same-API issue comparison' + } else { + 'Same-API comparison' } - try { - return $raw | ConvertFrom-Json - } catch { - throw "'gh $($Arguments -join ' ')' returned invalid JSON: $($_.Exception.Message)" + $labelPattern = [regex]::Escape($label) + $pattern = "(?m)^[ `t]*${labelPattern}:[ `t]*$repo#$Number[ `t]*\|[ `t]*Different mechanism:[ `t]*(?[^|`r`n]{12,500}?)[ `t]*$" + $matches = [regex]::Matches($Body, $pattern) + if ($matches.Count -ne 1) { + throw "Final leak-hunter de-dup gate requires exactly one structured $Kind override for same-API $Repository#${Number}: '$label`: $Repository#$Number | Different mechanism: '." } } @@ -62,7 +66,8 @@ if ($createItems.Count -gt 8) { throw "Expected at most eight create_issue items, found $($createItems.Count)." } -$requestedApis = [System.Collections.Generic.Dictionary[string, string]]::new( +$requestedItems = [System.Collections.Generic.List[object]]::new() +$requestedTitles = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::Ordinal ) foreach ($item in $createItems) { @@ -74,14 +79,17 @@ foreach ($item in $createItems) { if ([string]::IsNullOrWhiteSpace($api)) { throw "Could not derive a canonical Type.Member from create-issue title '$title'." } - if ($requestedApis.ContainsKey($api)) { - throw "Multiple create-issue outputs target the same canonical API '$api'." + if (-not $requestedTitles.Add($title)) { + throw "Multiple create-issue outputs use the same exact title '$title'." } - $requestedApis.Add($api, $title) + $requestedItems.Add([pscustomobject]@{ + Api = $api + Item = $item + }) } $openIssues = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'issue', 'list', '--repo', $Repository, '--search', '"[leak-scan]" in:title', @@ -96,7 +104,7 @@ if ($openIssues.Count -ge 1000) { } $merged = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'merged', @@ -110,13 +118,13 @@ if ($merged.Count -ge 1000) { } $mergedReverts = @( - Invoke-GhJson -Arguments @( + Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'merged', '--limit', '1000', '--search', '"Revert" in:title', - '--json', 'number,title,body,mergedAt' + '--json', 'number,title,body,baseRefName,mergedAt' ) ) if ($mergedReverts.Count -ge 1000) { @@ -131,7 +139,7 @@ $eligibleMerged = @($merged | Where-Object { $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` -Repository $Repository ` - -FixPullRequestNumbers @($eligibleMerged | ForEach-Object { [int]$_.number }) ` + -FixPullRequests $eligibleMerged ` -MergedRevertPullRequests $mergedReverts ) $reverted = [System.Collections.Generic.HashSet[int]]::new() @@ -142,28 +150,33 @@ $eligibleMerged = @($eligibleMerged | Where-Object { -not $reverted.Contains([int]$_.number) }) -$openApiMatches = @($openIssues | Where-Object { - $title = [string]$_.title - $api = Get-CanonicalLeakApi -Title $title - $title.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and - -not [string]::IsNullOrWhiteSpace($api) -and - $requestedApis.ContainsKey($api) - }) -$mergedApiMatches = @($eligibleMerged | Where-Object { - $api = Get-CanonicalLeakApi -Title ([string]$_.title) - -not [string]::IsNullOrWhiteSpace($api) -and - $requestedApis.ContainsKey($api) - }) - -if ($openApiMatches.Count -gt 0 -or $mergedApiMatches.Count -gt 0) { - $reasons = @() - if ($openApiMatches.Count -gt 0) { - $reasons += "open [leak-scan] issue match: $($openApiMatches.number -join ', ')" +foreach ($requested in $requestedItems) { + $api = [string]$requested.Api + $body = [string]$requested.Item.body + $openApiMatches = @($openIssues | Where-Object { + $issueTitle = [string]$_.title + $issueTitle.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and + (Get-CanonicalLeakApi -Title $issueTitle) -ceq $api + }) + foreach ($match in $openApiMatches) { + Assert-SameApiDisclosure ` + -Body $body ` + -Kind issue ` + -Number ([int]$match.number) ` + -Repository $Repository } - if ($mergedApiMatches.Count -gt 0) { - $reasons += "active merged [leak-fix] PR match: $($mergedApiMatches.number -join ', ')" + + $mergedApiMatches = @($eligibleMerged | Where-Object { + (Get-CanonicalLeakApi -Title ([string]$_.title)) -ceq $api + }) + foreach ($match in $mergedApiMatches) { + Assert-SameApiDisclosure ` + -Body $body ` + -Kind pull-request ` + -Number ([int]$match.number) ` + -Repository $Repository } - throw "Final leak-hunter de-dup gate blocked issue creation: $($reasons -join '; ')." } -Write-Host "Final leak-hunter de-dup gate passed for APIs: $($requestedApis.Keys -join ', ')." +$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-EffectiveRevertedLeakFixes.ps1 b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 index 55372377af5c..32cd0cf49f3e 100644 --- a/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 +++ b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 @@ -11,15 +11,20 @@ param( $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force -$fixNumbers = @( +$fixPullRequests = @( Get-Content -LiteralPath $MergedFixTsvPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { - $fields = $_ -split "`t", 3 - if ($fields.Count -lt 2 -or $fields[1] -notmatch '^[1-9][0-9]*$') { + $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: $_" } - [int]$fields[1] + [pscustomobject]@{ + number = [int]$fields[1] + baseRefName = $fields[2] + } } ) @@ -32,7 +37,7 @@ $reverts = @( $effectiveReverted = @( Get-EffectiveRevertedPullRequestNumbers ` -Repository $Repository ` - -FixPullRequestNumbers $fixNumbers ` + -FixPullRequests $fixPullRequests ` -MergedRevertPullRequests $reverts ) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index e6024b87266b..2a956f025c71 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -184,27 +184,31 @@ Describe 'mechanism-aware final duplicate gate' { Describe 'effective recursive revert state' { It 'keeps an unreverted fix active' { + $fix = New-LeakPr -Number 100 -Title '[leak-fix] Fix Picker.ItemsSource leak' + @( Get-EffectiveRevertedPullRequestNumbers ` -Repository 'dotnet/maui' ` - -FixPullRequestNumbers @(100) ` + -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' ` - -FixPullRequestNumbers @(100) ` + -FixPullRequests @($fix) ` -MergedRevertPullRequests $reverts | Should -Be @(100) } 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' @@ -213,12 +217,13 @@ Describe 'effective recursive revert state' { @( Get-EffectiveRevertedPullRequestNumbers ` -Repository 'dotnet/maui' ` - -FixPullRequestNumbers @(100) ` + -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' @@ -227,12 +232,13 @@ Describe 'effective recursive revert state' { Get-EffectiveRevertedPullRequestNumbers ` -Repository 'dotnet/maui' ` - -FixPullRequestNumbers @(100) ` + -FixPullRequests @($fix) ` -MergedRevertPullRequests $reverts | Should -Be @(100) } It 'combines multiple active sibling reverts by parity' { + $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' @@ -241,10 +247,63 @@ Describe 'effective recursive revert state' { @( Get-EffectiveRevertedPullRequestNumbers ` -Repository 'dotnet/maui' ` - -FixPullRequestNumbers @(100) ` + -FixPullRequests @($fix) ` -MergedRevertPullRequests $reverts ).Count | Should -Be 0 } + + 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 'workflow enforcement boundary' { @@ -297,13 +356,16 @@ Describe 'workflow enforcement boundary' { 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$' } Context 'safe-output gate script' { @@ -333,9 +395,13 @@ Describe 'workflow enforcement boundary' { $global:mockReverts = @() $global:mockOpen = @() $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 @@ -358,7 +424,8 @@ Describe 'workflow enforcement boundary' { AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable mockMerged, mockReverts, mockOpen, mockGhExitCode -Scope Global -ErrorAction SilentlyContinue + Remove-Variable mockMerged, mockReverts, mockOpen, mockGhExitCode, mockGhStderr ` + -Scope Global -ErrorAction SilentlyContinue } It 'rejects an untagged create-pull-request title' { @@ -414,13 +481,32 @@ Describe 'workflow enforcement boundary' { 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 -Throw '*failed with exit code 1*' + } | Should -Not -Throw } It 'rejects a live same-API decision that is absent from the PR body' { @@ -582,9 +668,13 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are $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 @@ -605,7 +695,8 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable mockHunterOpenIssues, mockHunterMerged, mockHunterReverts, mockHunterGhExitCode ` + Remove-Variable mockHunterOpenIssues, mockHunterMerged, mockHunterReverts, ` + mockHunterGhExitCode, mockHunterGhStderr ` -Scope Global -ErrorAction SilentlyContinue } @@ -617,6 +708,23 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are } | Should -Not -Throw } + It 'allows distinct mechanisms on the same 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 -Not -Throw + } + It 'blocks issue emission when a matching fix merged after the pre-agent snapshot' { $global:mockHunterMerged = @( New-LeakPr ` @@ -628,7 +736,68 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` -AgentOutputPath $script:hunterAgentOutput ` -Repository 'dotnet/maui' - } | Should -Throw '*blocked issue creation*active merged*701*' + } | Should -Throw '*structured pull-request override*#701*' + } + + It 'allows a distinct same-API mechanism with bounded human-visible evidence' { + $basis = 'Existing PR fixes detach teardown; this issue proves Reset unsubscription.' + $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: $basis +"@ + $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 'requires a separate structured comparison for a same-API open issue' { + $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 '*structured issue override*#702*' + } + + 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 index 9cec10d75431..cbd220899d1f 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -32,6 +32,43 @@ function Test-LeakPrReferencesIssue { $text -match "(?m)^[ `t]*Refs:[ `t]*$repo#$IssueNumber\b" } +function Invoke-LeakGhJson { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + $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 -ne 0) { + $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." + if (-not [string]::IsNullOrWhiteSpace($detail)) { + $message = "$message Output: $detail" + } + throw $message + } + 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)" + } +} + function Assert-LeakDedupState { param( [Parameter(Mandatory = $true)]$State, @@ -102,7 +139,7 @@ function Get-LeakFixFinalDedupResult { $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` -Repository $Repository ` - -FixPullRequestNumbers @($eligibleMerged | ForEach-Object { [int]$_.number }) ` + -FixPullRequests $eligibleMerged ` -MergedRevertPullRequests $MergedRevertPullRequests ) $reverted = [System.Collections.Generic.HashSet[int]]::new() @@ -156,18 +193,58 @@ function Get-LeakFixFinalDedupResult { function Get-EffectiveRevertedPullRequestNumbers { param( [Parameter(Mandatory = $true)][string]$Repository, - [Parameter(Mandatory = $true)][AllowEmptyCollection()][int[]]$FixPullRequestNumbers, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$FixPullRequests, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedRevertPullRequests ) $revertersByTarget = @{} + $branchByNumber = @{} + $fixNumbers = [System.Collections.Generic.List[int]]::new() $repo = [regex]::Escape($Repository) $pattern = "(?m)^[ `t]*Reverts[ `t]+$repo#(?[1-9][0-9]*)\b" + 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 ($match in [regex]::Matches(([string]$revert.body), $pattern)) { $target = [int]$match.Groups['number'].Value + if (-not $branchByNumber.ContainsKey($target) -or + $branchByNumber[$target] -cne $reverterBase) { + continue + } if (-not $revertersByTarget.ContainsKey($target)) { $revertersByTarget[$target] = [System.Collections.Generic.List[int]]::new() } @@ -205,7 +282,7 @@ function Get-EffectiveRevertedPullRequestNumbers { return $active } - return @($FixPullRequestNumbers | + return @($fixNumbers | Where-Object { -not (Test-EffectActive -PullRequestNumber $_) } | Sort-Object -Unique) } @@ -213,6 +290,7 @@ function Get-EffectiveRevertedPullRequestNumbers { Export-ModuleMember -Function ` Get-CanonicalLeakApi, ` Test-LeakPrReferencesIssue, ` + Invoke-LeakGhJson, ` 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 34db3f420678..6e847de51d2b 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":"4c8525d06fccb902ed3b22490e4eb69cc5a991ee19b16cadaa4d3378f5668f03","body_hash":"4ee33649153d143eccbc6985c87494a4bcd6ce81face6a614dd81dd4dd828e02","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":"4690315faab3a87bdd42d9a67545c0bc3ec2d9ca33b66c16f62d45f7bb2dd749","body_hash":"edc8f79de0d6dbfa525df2523e080bcfdefc9083500bedfbc16a9a734e5781f5","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 # @@ -505,7 +505,7 @@ jobs: 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a live revert\n# removes its target's effect, a revert-of-that-revert restores it, and multiple live\n# reverts combine by parity. Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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; reverting the revert\n# reinstates the original fix. Multiple active reverts are combined by parity.\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" + 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a live revert\n# removes its target's effect, a revert-of-that-revert restores it, and multiple live\n# reverts combine by parity. Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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# reverting the revert reinstates the original fix. Multiple active same-branch\n# reverts are combined by parity; servicing-branch reverts cannot 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 @@ -1618,6 +1618,7 @@ jobs: permissions: contents: read issues: write + pull-requests: read timeout-minutes: 45 env: GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 3efb2bbf2f2c..70bea7a26a5b 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -67,6 +67,14 @@ 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` @@ -150,7 +158,7 @@ pre-agent-steps: # reverts combine by parity. Drop only effectively reverted fixes from the # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search '"Revert" in:title' --json number,title,body,mergedAt \ + --search '"Revert" in:title' --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/revert-prs-raw.json REVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json) if test "$REVERT_RAW_COUNT" -ge 1000; then @@ -160,8 +168,9 @@ pre-agent-steps: jq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \ > /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; reverting the revert - # reinstates the original fix. Multiple active reverts are combined by parity. + # its target only while that revert itself remains active on the SAME base branch; + # reverting the revert reinstates the original fix. Multiple active same-branch + # reverts are combined by parity; 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 \ @@ -189,8 +198,10 @@ 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 so a late merge/open - # issue blocks mutation. Restore the gate from the read-only default branch rather than - # executing workflow-dispatch-selected repository code with the write-capable job token. + # issue blocks mutation unless the emitted issue carries a bounded structured comparison + # proving the same API uses a distinct retention mechanism. Restore the gate from the + # read-only default branch rather than executing workflow-dispatch-selected repository 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') }} @@ -263,10 +274,11 @@ Never push, never open a PR, never comment, never edit product or test code in t `ConditionalWeakTable`, `WeakReference`, or any `Weak*Proxy`, it does not leak — move on. 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 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 supported-branch merged fix exists. + 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. @@ -310,15 +322,17 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts - `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 from GitHub's standard `Reverts /#` body line: one active - revert excludes the fix, a revert-of-that-revert reinstates it, and deeper/multiple chains - are combined by parity. Only an effectively reverted fix is treated as re-filable rather - than permanent proof the fix is still active. - -- 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, OR it appears in - `already-merged-fix-apis.txt`. **Check this for EVERY candidate before you write its test.** + recursively and per base branch from GitHub's standard + `Reverts /#` body line: one active same-branch revert excludes the fix, a + same-branch revert-of-that-revert reinstates it, and deeper/multiple chains are combined by + parity. 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 rooting API **and retention mechanism**. API identity alone is not leak identity: + distinct retention paths can legitimately share one `Type.Member`. Use + `already-filed-apis.txt` / `already-merged-fix-apis.txt` as fast match signals, then inspect + the matching issue/PR before deciding. **Check this for EVERY candidate before its test.** - Normalize each candidate with the same last-`Type.Member` extraction convention (LAST dotted `Type.Member` pair of the first identifier chain in the title/name — e.g. a fully-qualified `Microsoft.Maui.Controls.Picker.ItemsSource` yields `Picker.ItemsSource`), then use @@ -326,17 +340,21 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts matching. - For a merged-fix match, print the matching row(s) from `already-merged-fix-apis.tsv` and record - `skipped: equivalent fix already merged via # to `. -- Re-filing a leak this scanner already has open or that already has a merged fix (even with - different issue wording/number) is the primary failure mode, so be strict about the - canonical `Type.Member`. + `skipped: equivalent fix already merged via # to ` only when the retention + path is equivalent. If the mechanism differs, proceed and include the structured PR + comparison line required below. +- For an open issue match, skip only when its retention path is equivalent. If the mechanism + differs, proceed and include the structured issue comparison line required below. +- Re-filing the same retention mechanism under different wording/number is the primary failure + mode, so be strict about both the canonical `Type.Member` and the root-to-transient path. - Immediately before issue mutation, a trusted safe-output step independently re-fetches open - scanner issues, merged fixes, and effective revert state. A late issue or fix that appears - during this run blocks all matching `create-issue` output fail-closed; do not treat the - pre-agent snapshot as the final authority. + scanner issues, merged fixes, and branch-scoped effective revert state. A late same-API + issue/fix blocks matching `create-issue` output unless the emitted body contains exactly one + bounded, human-visible different-mechanism comparison for it; do not treat the pre-agent + snapshot as the final authority. -A candidate whose only prior scanner issue is CLOSED may be re-filed only when its API is -absent from `already-merged-fix-apis.txt`. +A candidate whose only prior scanner issue is CLOSED may be re-filed when no active merged fix +covers the same API and retention mechanism. # ===================== RUNTIME LEAK HUNT ===================== @@ -460,9 +478,17 @@ no MAUI source build, no emulator. 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, supported-branch merged `[leak-fix]` PRs, AND the other issues you're filing this run (no two issues for the same -rooting API). A trusted final gate repeats the live de-dup immediately before mutation and -rejects the batch if a matching issue or active merged fix appeared after the pre-agent -snapshot. Each title MUST be of the form **`[leak-scan] ./# | Different mechanism: ` + +`Same-API comparison: /# | Different mechanism: ` + +The gate blocks missing/malformed comparisons; omit these lines when there is no same-API match. +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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index dbc98cf6574a..3199026bd437 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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"f2fca7fce16c3ebf5775d7c02b6b21eb57d2000dfebe6eeccde7812128f2b66b","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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"cb7d2ef3955782e4e731abcc743062dd4a5e8101bd2867aa05719270847ed98a","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 1bb8d7162b39..f3244af9fda1 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -464,7 +464,7 @@ jq '[.[] | 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] | @tsv' \ +jq -r '.[] | ["_", .number, .baseRefName, .title] | @tsv' \ /tmp/gh-aw/agent/merged-leak-fix-prs.json \ > /tmp/gh-aw/agent/merged-leak-fix-prs.tsv @@ -484,10 +484,11 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv # A merged fix is not authoritative if it was later effectively reverted. Resolve recursive -# revert chains before either the direct issue-reference or same-API merged gate consumes it. +# same-base revert chains before either the direct issue-reference or same-API merged gate +# consumes it. A servicing-branch revert cannot toggle a main/inflight fix. if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ --search '"Revert" in:title' \ - --json number,title,body,mergedAt \ + --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/merged-revert-prs-raw.json; then echo "ERROR: merged Revert search failed — aborting because effective fix state is unknown." >&2 exit 1 @@ -855,7 +856,7 @@ jq '[.[] | if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ --search '"Revert" in:title' \ - --json number,title,body,mergedAt \ + --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/final-merged-revert-prs-raw.json; then echo "ERROR: final merged Revert search failed — aborting because effective fix state is unknown." >&2 exit 1 @@ -868,7 +869,7 @@ fi jq '[.[] | select(.mergedAt != null)]' \ /tmp/gh-aw/agent/final-merged-revert-prs-raw.json \ > /tmp/gh-aw/agent/final-merged-revert-prs.json -jq -r '.[] | ["_", .number, .title] | @tsv' \ +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 pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ From 16b08433ced14c1b43def40eb943a95a77c0b9aa Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 22:13:59 +0200 Subject: [PATCH 44/68] Stabilize leak gate gh failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 13 +++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 2 ++ 2 files changed, 15 insertions(+) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 2a956f025c71..dcffb4097fa5 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -24,6 +24,19 @@ BeforeAll { } } +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 + + $helper.IndexOf('$PSNativeCommandUseErrorActionPreference = $false') | + Should -BeLessThan $helper.IndexOf('$output = & gh @Arguments 2>&1') + } +} + Describe 'fresh-shell de-dup state' { It 'fails closed when persisted identity does not match the requested PR' { $state = [pscustomobject]@{ diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index cbd220899d1f..7b09efbe0547 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -35,6 +35,8 @@ function Test-LeakPrReferencesIssue { function Invoke-LeakGhJson { param([Parameter(Mandatory = $true)][string[]]$Arguments) + # Preserve structured exit-code handling if a future host flips the native-command default. + $PSNativeCommandUseErrorActionPreference = $false $output = & gh @Arguments 2>&1 $exitCode = $LASTEXITCODE From 328d70d116caf1a0807109ee451cb8b3e8bb85e5 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 23:05:01 +0200 Subject: [PATCH 45/68] Harden native command assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index dcffb4097fa5..273a9d66e41e 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -32,8 +32,12 @@ Describe 'native gh invocation' { '(?s)function Invoke-LeakGhJson \{.*?\n\}' ).Value - $helper.IndexOf('$PSNativeCommandUseErrorActionPreference = $false') | - Should -BeLessThan $helper.IndexOf('$output = & gh @Arguments 2>&1') + $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 } } From 1ef6fd8e4c6dd7f2df5cedb8e9d9159559de3d9c Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Tue, 18 Aug 2026 23:11:57 +0200 Subject: [PATCH 46/68] Fix independent revert handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 19 +++++++++++++++++-- .github/scripts/LeakWorkflowDedup.psm1 | 6 +++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 273a9d66e41e..0165540dd494 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -254,7 +254,7 @@ Describe 'effective recursive revert state' { Should -Be @(100) } - It 'combines multiple active sibling reverts by parity' { + 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' @@ -266,7 +266,22 @@ Describe 'effective recursive revert state' { -Repository 'dotnet/maui' ` -FixPullRequests @($fix) ` -MergedRevertPullRequests $reverts - ).Count | Should -Be 0 + ) | 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' { diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 7b09efbe0547..6d98962320dd 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -269,17 +269,17 @@ function Get-EffectiveRevertedPullRequestNumbers { throw "Cycle detected while resolving revert chain at PR #$PullRequestNumber." } - $activeRevertCount = 0 + $activeReverterCount = 0 if ($revertersByTarget.ContainsKey($PullRequestNumber)) { foreach ($reverter in $revertersByTarget[$PullRequestNumber]) { if (Test-EffectActive -PullRequestNumber $reverter) { - $activeRevertCount++ + $activeReverterCount++ } } } [void]$visiting.Remove($PullRequestNumber) - $active = ($activeRevertCount % 2) -eq 0 + $active = $activeReverterCount -eq 0 $memo[$PullRequestNumber] = $active return $active } From 5a1f032d4619024d8044de761591ea09c95fe271 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 00:40:54 +0200 Subject: [PATCH 47/68] Reject duplicate leak APIs per batch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Assert-LeakHunterSafeOutputGate.ps1 | 6 ++++++ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 12 +++++++++-- .github/workflows/daily-leak-hunter.lock.yml | 4 ++-- .github/workflows/daily-leak-hunter.md | 20 ++++++++++--------- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index 742beb8e4e81..a4420c4fb84d 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -70,6 +70,9 @@ $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)) { @@ -82,6 +85,9 @@ foreach ($item in $createItems) { 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 diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 0165540dd494..3b59aa103db2 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -400,6 +400,14 @@ Describe 'workflow enforcement boundary' { $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 exists' + $workflow | Should -Match 'independent sibling reverts\s+never cancel each other' + $workflow | Should -Not -Match 'combined by parity|combine by parity' + } + Context 'safe-output gate script' { BeforeEach { $script:agentOutput = Join-Path $TestDrive 'agent_output.json' @@ -740,7 +748,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are } | Should -Not -Throw } - It 'allows distinct mechanisms on the same API in one output batch' { + 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' @@ -754,7 +762,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` -AgentOutputPath $script:hunterAgentOutput ` -Repository 'dotnet/maui' - } | Should -Not -Throw + } | Should -Throw "*same canonical API 'GradientBrush.GradientStops'*" } It 'blocks issue emission when a matching fix merged after the pre-agent snapshot' { diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 6e847de51d2b..0bc870837c7d 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":"4690315faab3a87bdd42d9a67545c0bc3ec2d9ca33b66c16f62d45f7bb2dd749","body_hash":"edc8f79de0d6dbfa525df2523e080bcfdefc9083500bedfbc16a9a734e5781f5","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":"c6578821298c0731d8e95687169ab305bafe222bc7e335d5c26a671cf3f382fa","body_hash":"6133db9b2d745f972d6b379f24fd09461e0e2677a93f7519faa21cb3e4742bbe","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 # @@ -505,7 +505,7 @@ jobs: 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a live revert\n# removes its target's effect, a revert-of-that-revert restores it, and multiple live\n# reverts combine by parity. Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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# reverting the revert reinstates the original fix. Multiple active same-branch\n# reverts are combined by parity; servicing-branch reverts cannot 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" + 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a target remains\n# reverted while any active same-branch direct reverter exists. Reverting a reverter can\n# deactivate that reverter, but independent sibling reverts never cancel each other.\n# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 70bea7a26a5b..7ce422c471c7 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -153,9 +153,10 @@ pre-agent-steps: # 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's "Revert" button creates a PR whose body contains an exact - # "Reverts /#" line. Resolve those links recursively: a live revert - # removes its target's effect, a revert-of-that-revert restores it, and multiple live - # reverts combine by parity. Drop only effectively reverted fixes from the + # "Reverts /#" line. Resolve those links recursively: a target remains + # reverted while any active same-branch direct reverter exists. Reverting a reverter can + # deactivate that reverter, but independent sibling reverts never cancel each other. + # Drop only effectively reverted fixes from the # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ --search '"Revert" in:title' --json number,title,body,baseRefName,mergedAt \ @@ -168,9 +169,10 @@ pre-agent-steps: jq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \ > /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; - # reverting the revert reinstates the original fix. Multiple active same-branch - # reverts are combined by parity; servicing-branch reverts cannot alter main/inflight. + # 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 \ @@ -323,9 +325,9 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts 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 - `Reverts /#` body line: one active same-branch revert excludes the fix, a - same-branch revert-of-that-revert reinstates it, and deeper/multiple chains are combined by - parity. A servicing-branch revert cannot toggle a main/inflight fix. Only an effectively + `Reverts /#` body line: 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 From e56a8b02016946f5b645760eee7347f7e511865c Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 01:12:14 +0200 Subject: [PATCH 48/68] Align leak hunter batch contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 8 ++++++++ .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 10 ++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 3b59aa103db2..b60a50b9148f 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -408,6 +408,14 @@ Describe 'workflow enforcement boundary' { $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' + } + Context 'safe-output gate script' { BeforeEach { $script:agentOutput = Join-Path $TestDrive 'agent_output.json' diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 0bc870837c7d..52ca262e2d24 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":"c6578821298c0731d8e95687169ab305bafe222bc7e335d5c26a671cf3f382fa","body_hash":"6133db9b2d745f972d6b379f24fd09461e0e2677a93f7519faa21cb3e4742bbe","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":"c6578821298c0731d8e95687169ab305bafe222bc7e335d5c26a671cf3f382fa","body_hash":"b5002226bd151e3ef369f7142d96487679d4827ec508eba569213da706592b41","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 7ce422c471c7..0a78c9885911 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -477,10 +477,12 @@ 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, supported-branch merged -`[leak-fix]` PRs, AND the other issues you're filing this run (no two issues for the same -rooting API **and retention mechanism**; distinct mechanisms on one API are separate leaks). +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. A trusted final gate repeats the live de-dup immediately before mutation. For every live same-API match that uses a genuinely different retention mechanism, the issue body must include exactly one applicable bounded line (12–500 character single-line basis, no `|`): From 002c18a576536114e60719e147380e3dab2c5df2 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 04:01:53 +0200 Subject: [PATCH 49/68] Harden leak title and revert parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/Get-CanonicalLeakApi.ps1 | 16 +++ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 113 ++++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 21 ++-- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 35 +++--- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 34 ++---- 7 files changed, 174 insertions(+), 51 deletions(-) create mode 100644 .github/scripts/Get-CanonicalLeakApi.ps1 diff --git a/.github/scripts/Get-CanonicalLeakApi.ps1 b/.github/scripts/Get-CanonicalLeakApi.ps1 new file mode 100644 index 000000000000..27fccbfd36f7 --- /dev/null +++ b/.github/scripts/Get-CanonicalLeakApi.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Title +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$api = Get-CanonicalLeakApi -Title $Title +if (-not [string]::IsNullOrWhiteSpace($api)) { + Write-Output $api +} diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index b60a50b9148f..41c6c9a99708 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -101,6 +101,39 @@ Describe 'fresh-shell de-dup state' { } } +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 '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 + } +} + Describe 'mechanism-aware final duplicate gate' { It 'preserves an approved same-API different-mechanism exception' { $existing = New-LeakPr ` @@ -224,6 +257,46 @@ Describe 'effective recursive revert state' { 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 = @( @@ -403,7 +476,7 @@ Describe 'workflow enforcement boundary' { 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 exists' + $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' } @@ -416,6 +489,16 @@ Describe 'workflow enforcement boundary' { $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 + $hunter | Should -Not -Match 'awk.*A-Za-z_' + $fixer | Should -Not -Match 'awk.*A-Za-z_' + } + Context 'safe-output gate script' { BeforeEach { $script:agentOutput = Join-Path $TestDrive 'agent_output.json' @@ -498,6 +581,20 @@ Describe 'workflow enforcement boundary' { } | 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 '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" @@ -756,6 +853,20 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are } | 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 differently titled issues for the same canonical API in one output batch' { $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json $output.items += [pscustomobject]@{ diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 6d98962320dd..2e920944f646 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -5,17 +5,20 @@ function Get-CanonicalLeakApi { return $null } - $normalized = $Title -replace "[`r`n]+", ' ' - $normalized = $normalized -replace '^\[leak-(?:scan|fix)\]\s*', '' - $match = [regex]::Match( - $normalized, - '[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+' - ) + $normalized = ($Title -replace "[`r`n]+", ' ').Trim() + $identifierChain = '[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+' + $match = if ($normalized.StartsWith('[leak-scan] ', [StringComparison]::Ordinal)) { + [regex]::Match($normalized, "^\[leak-scan\][ `t]+(?$identifierChain)(?=[ `t]|$)") + } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + [regex]::Match($normalized, "^\[leak-fix\][ `t]+Fix[ `t]+(?$identifierChain)(?=[ `t]|$)") + } else { + return $null + } if (-not $match.Success) { return $null } - $segments = $match.Value.Split('.') + $segments = $match.Groups['api'].Value.Split('.') return "$($segments[-2]).$($segments[-1])" } @@ -203,7 +206,9 @@ function Get-EffectiveRevertedPullRequestNumbers { $branchByNumber = @{} $fixNumbers = [System.Collections.Generic.List[int]]::new() $repo = [regex]::Escape($Repository) - $pattern = "(?m)^[ `t]*Reverts[ `t]+$repo#(?[1-9][0-9]*)\b" + $markdownPrefix = '(?:>[ \t]*)?(?:[-+*][ \t]+)?(?:\*{1,2}|_{1,2})?' + $pattern = "(?m)^[ `t]*$markdownPrefix" + + "Reverts[ `t]+(?:$repo#|#)(?[1-9][0-9]*)\b" foreach ($fix in $FixPullRequests) { $number = 0 diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 52ca262e2d24..6e9c224bde79 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":"c6578821298c0731d8e95687169ab305bafe222bc7e335d5c26a671cf3f382fa","body_hash":"b5002226bd151e3ef369f7142d96487679d4827ec508eba569213da706592b41","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":"55b03415eec9e20fb06d888214bc1cc887ed55e9c580d69938eacb527e35c3e3","body_hash":"0b2d9879771c3efb63abebca6b63475cb998eb199d33f2e3d2445b9fd032c448","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 # @@ -505,7 +505,7 @@ jobs: 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 | sed -E 's/^\\[leak-scan\\] *//' \\\n | 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] } }' \\\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=$(printf '%s\\n' \"$TITLE\" \\\n | sed -E 's/^\\[leak-fix\\] *//' \\\n | 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] } }')\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's \"Revert\" button creates a PR whose body contains an exact\n# \"Reverts /#\" line. Resolve those links recursively: a target remains\n# reverted while any active same-branch direct reverter exists. Reverting a reverter can\n# deactivate that reverter, but independent sibling reverts never cancel each other.\n# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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" + 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\"\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\")\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# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 0a78c9885911..815f87e96ef9 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -102,8 +102,9 @@ pre-agent-steps: exit 1 fi jq -r '.[].title | gsub("[\r\n]+";" ")' /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] } }' \ + | while IFS= read -r TITLE; do + pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" + done \ | sort -u \ > /tmp/gh-aw/agent/already-filed-apis.txt echo "already-filed rooting APIs:" @@ -135,9 +136,7 @@ pre-agent-steps: 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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") if test -n "$API"; then printf '%s\t%s\t%s\t%s\t%s\n' "$API" "$PR" "$BASE" "$URL" "$TITLE" fi @@ -152,10 +151,11 @@ pre-agent-steps: # 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's "Revert" button creates a PR whose body contains an exact - # "Reverts /#" line. Resolve those links recursively: a target remains - # reverted while any active same-branch direct reverter exists. Reverting a reverter can - # deactivate that reverter, but independent sibling reverts never cancel each other. + # 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. # Drop only effectively reverted fixes from the # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ @@ -325,19 +325,22 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts 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 - `Reverts /#` body line: 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. + 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 rooting API **and retention mechanism**. API identity alone is not leak identity: distinct retention paths can legitimately share one `Type.Member`. Use `already-filed-apis.txt` / `already-merged-fix-apis.txt` as fast match signals, then inspect the matching issue/PR before deciding. **Check this for EVERY candidate before its test.** -- Normalize each candidate with the same last-`Type.Member` extraction convention (LAST dotted - `Type.Member` pair of the first identifier chain in the title/name — e.g. a fully-qualified - `Microsoft.Maui.Controls.Picker.ItemsSource` yields `Picker.ItemsSource`), then use +- 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 `), and + the last `Type.Member` pair of a fully-qualified chain is the canonical key (for example, + `Microsoft.Maui.Controls.Picker.ItemsSource` yields `Picker.ItemsSource`). 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 3199026bd437..329875e7853f 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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"cb7d2ef3955782e4e731abcc743062dd4a5e8101bd2867aa05719270847ed98a","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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"c3e5a4330f7d1215ac45da8d2939e4f7e58d7c9a05a9ad859f25f7d96c1eb85f","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index f3244af9fda1..c445009c85b9 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -408,8 +408,8 @@ 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. +# 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. @@ -418,9 +418,7 @@ 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=$(printf '%s' "$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] } }') +API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") echo "target rooting API: $API" 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 @@ -468,14 +466,12 @@ 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 last-Type.Member extraction used for the -# selected issue. This handles fully-qualified/off-contract wording without substring matches. +# 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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") if test -n "$PR_API"; then printf '%s\t%s\t%s\t%s\t%s\n' "$PR_API" "$PR" "$BASE" "$URL" "$TITLE" fi @@ -560,7 +556,7 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | > /tmp/gh-aw/agent/open-fix-prs.json jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # (c) Open [leak-fix] PR already fixing the SAME rooting Type.Member (any issue number)? -# Canonicalize each open PR title with the SAME last-Type.Member extraction used for the +# 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 @@ -579,9 +575,7 @@ jq -r '.[] | [.number, .title] | @tsv' \ /tmp/gh-aw/agent/same-api-prs-raw.json \ | while IFS=$'\t' read -r PR TITLE; do - PR_API=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") if test "$PR_API" = "$API"; then jq -n --arg number "$PR" --arg title "$TITLE" '{number: ($number|tonumber), title: $title}' fi @@ -608,9 +602,7 @@ jq '[.[] | select(.title | startswith("[leak-fix] ")) | select(.mergedAt == null # 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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") 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" '[.[] | @@ -896,9 +888,7 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | 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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") test "$PR_API" = "$API" && printf 'merged\t%s\t%s\n' "$PR" "$TITLE" done > /tmp/gh-aw/agent/final-merged-api-matches.tsv @@ -927,9 +917,7 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | > /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=$(printf '%s\n' "$TITLE" \ - | sed -E 's/^\[leak-fix\] *//' \ - | 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] } }') + PR_API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE") test "$PR_API" = "$API" && printf 'open\t%s\t%s\n' "$PR" "$TITLE" done > /tmp/gh-aw/agent/final-open-api-matches.tsv From d30e8cac1749da464988068ca51afb74362436a4 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 05:47:14 +0200 Subject: [PATCH 50/68] Isolate cyclic leak revert chains Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 37 ++++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 56 +++++++++++++++------ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 41c6c9a99708..d8a332a4025b 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -230,6 +230,43 @@ Describe 'mechanism-aware final duplicate gate' { $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.UnapprovedApiMatches.number | Should -Be 100 + $result.EffectivelyReverted | Should -Be @(110) + } } Describe 'effective recursive revert state' { diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 2e920944f646..3795529ee2a2 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -262,36 +262,60 @@ function Get-EffectiveRevertedPullRequestNumbers { } $memo = @{} - $visiting = [System.Collections.Generic.HashSet[int]]::new() function Test-EffectActive { - param([int]$PullRequestNumber) + param( + [int]$PullRequestNumber, + [System.Collections.Generic.HashSet[int]]$Visiting, + [ref]$CycleDetected + ) if ($memo.ContainsKey($PullRequestNumber)) { return [bool]$memo[$PullRequestNumber] } - if (-not $visiting.Add($PullRequestNumber)) { - throw "Cycle detected while resolving revert chain at PR #$PullRequestNumber." + if (-not $Visiting.Add($PullRequestNumber)) { + $CycleDetected.Value = $true + return $true } - $activeReverterCount = 0 - if ($revertersByTarget.ContainsKey($PullRequestNumber)) { - foreach ($reverter in $revertersByTarget[$PullRequestNumber]) { - if (Test-EffectActive -PullRequestNumber $reverter) { - $activeReverterCount++ + try { + $activeReverterCount = 0 + if ($revertersByTarget.ContainsKey($PullRequestNumber)) { + foreach ($reverter in $revertersByTarget[$PullRequestNumber]) { + if (Test-EffectActive ` + -PullRequestNumber $reverter ` + -Visiting $Visiting ` + -CycleDetected $CycleDetected) { + $activeReverterCount++ + } + if ($CycleDetected.Value) { + return $true + } } } + + $active = $activeReverterCount -eq 0 + $memo[$PullRequestNumber] = $active + return $active + } finally { + [void]$Visiting.Remove($PullRequestNumber) } + } - [void]$visiting.Remove($PullRequestNumber) - $active = $activeReverterCount -eq 0 - $memo[$PullRequestNumber] = $active - return $active + $effectivelyReverted = [System.Collections.Generic.List[int]]::new() + foreach ($number in $fixNumbers) { + $visiting = [System.Collections.Generic.HashSet[int]]::new() + $cycleDetected = $false + $active = Test-EffectActive ` + -PullRequestNumber $number ` + -Visiting $visiting ` + -CycleDetected ([ref]$cycleDetected) + if (-not $cycleDetected -and -not $active) { + $effectivelyReverted.Add($number) + } } - return @($fixNumbers | - Where-Object { -not (Test-EffectActive -PullRequestNumber $_) } | - Sort-Object -Unique) + return @($effectivelyReverted | Sort-Object -Unique) } Export-ModuleMember -Function ` From 18ccaad9279a7da05eacc126ec61ff3697fd84bb Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 06:46:20 +0200 Subject: [PATCH 51/68] Refine cyclic revert sibling handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 32 ++++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 46 +++++++++++---------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index d8a332a4025b..ef7092a4ca9b 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -267,6 +267,38 @@ Describe 'mechanism-aware final duplicate gate' { $result.UnapprovedApiMatches.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' { diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 3795529ee2a2..32256b692d93 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -262,41 +262,45 @@ function Get-EffectiveRevertedPullRequestNumbers { } $memo = @{} + $activeState = 'active' + $inactiveState = 'inactive' + $ambiguousState = 'ambiguous' - function Test-EffectActive { + function Get-EffectState { param( [int]$PullRequestNumber, - [System.Collections.Generic.HashSet[int]]$Visiting, - [ref]$CycleDetected + [System.Collections.Generic.HashSet[int]]$Visiting ) if ($memo.ContainsKey($PullRequestNumber)) { - return [bool]$memo[$PullRequestNumber] + return [string]$memo[$PullRequestNumber] } if (-not $Visiting.Add($PullRequestNumber)) { - $CycleDetected.Value = $true - return $true + return $ambiguousState } try { - $activeReverterCount = 0 + $hasAmbiguousReverter = $false if ($revertersByTarget.ContainsKey($PullRequestNumber)) { foreach ($reverter in $revertersByTarget[$PullRequestNumber]) { - if (Test-EffectActive ` - -PullRequestNumber $reverter ` - -Visiting $Visiting ` - -CycleDetected $CycleDetected) { - $activeReverterCount++ + $reverterState = Get-EffectState ` + -PullRequestNumber $reverter ` + -Visiting $Visiting + if ($reverterState -eq $activeState) { + $memo[$PullRequestNumber] = $inactiveState + return $inactiveState } - if ($CycleDetected.Value) { - return $true + if ($reverterState -eq $ambiguousState) { + $hasAmbiguousReverter = $true } } } - $active = $activeReverterCount -eq 0 - $memo[$PullRequestNumber] = $active - return $active + if ($hasAmbiguousReverter) { + return $ambiguousState + } + $memo[$PullRequestNumber] = $activeState + return $activeState } finally { [void]$Visiting.Remove($PullRequestNumber) } @@ -305,12 +309,10 @@ function Get-EffectiveRevertedPullRequestNumbers { $effectivelyReverted = [System.Collections.Generic.List[int]]::new() foreach ($number in $fixNumbers) { $visiting = [System.Collections.Generic.HashSet[int]]::new() - $cycleDetected = $false - $active = Test-EffectActive ` + $state = Get-EffectState ` -PullRequestNumber $number ` - -Visiting $visiting ` - -CycleDetected ([ref]$cycleDetected) - if (-not $cycleDetected -and -not $active) { + -Visiting $visiting + if ($state -eq $inactiveState) { $effectivelyReverted.Add($number) } } From 2c2620695dbc9c248ccc7722bfa43910cab8f169 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 07:49:20 +0200 Subject: [PATCH 52/68] Align leak dedup parsing contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 25 +--- .../Assert-LeakHunterSafeOutputGate.ps1 | 25 +--- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 107 +++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 27 ++++- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 4 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 12 +- 8 files changed, 144 insertions(+), 62 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index a52ba5e53ad2..7123322ce19b 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -10,27 +10,6 @@ param( $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force -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)" - } -} - if ([string]::IsNullOrWhiteSpace($Repository)) { throw 'GITHUB_REPOSITORY is required.' } @@ -105,12 +84,12 @@ $mergedReverts = @( '--repo', $Repository, '--state', 'merged', '--limit', '1000', - '--search', '"Revert" in:title', + '--search', 'Reverts in:body', '--json', 'number,title,body,baseRefName,mergedAt' ) ) if ($mergedReverts.Count -ge 1000) { - throw "Merged Revert search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." + throw "Merged revert-body search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." } $open = @( diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index a4420c4fb84d..c37323b13a1b 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -9,27 +9,6 @@ param( $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force -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 Assert-SameApiDisclosure { param( [Parameter(Mandatory = $true)][string]$Body, @@ -129,12 +108,12 @@ $mergedReverts = @( '--repo', $Repository, '--state', 'merged', '--limit', '1000', - '--search', '"Revert" in:title', + '--search', 'Reverts in:body', '--json', 'number,title,body,baseRefName,mergedAt' ) ) if ($mergedReverts.Count -ge 1000) { - throw "Merged Revert search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." + throw "Merged revert-body search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." } $eligibleMerged = @($merged | Where-Object { diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index ef7092a4ca9b..ee57af1b2b79 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -41,6 +41,63 @@ Describe 'native gh invocation' { } } +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 'fresh-shell de-dup state' { It 'fails closed when persisted identity does not match the requested PR' { $state = [pscustomobject]@{ @@ -114,6 +171,19 @@ Describe 'canonical leak API title parsing' { 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)' + ) | ForEach-Object { + Get-CanonicalLeakApi -Title $_ | 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') | @@ -131,6 +201,8 @@ Describe 'canonical leak API title parsing' { 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 } } @@ -568,6 +640,21 @@ Describe 'workflow enforcement boundary' { $fixer | Should -Not -Match 'awk.*A-Za-z_' } + It 'discovers merged reverter candidates by explicit body-reference text, not title' { + $paths = @( + 'Assert-LeakFixSafeOutputGate.ps1' + 'Assert-LeakHunterSafeOutputGate.ps1' + '../workflows/daily-leak-hunter.md' + '../workflows/leak-fixer.md' + ) + + foreach ($path in $paths) { + $content = Get-Content -LiteralPath (Join-Path $PSScriptRoot $path) -Raw + $content | Should -Match 'Reverts in:body' + $content | Should -Not -Match '"Revert" in:title' + } + } + Context 'safe-output gate script' { BeforeEach { $script:agentOutput = Join-Path $TestDrive 'agent_output.json' @@ -611,7 +698,7 @@ Describe 'workflow enforcement boundary' { $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] if ($state -eq 'merged') { - if ($search -eq '"Revert" in:title') { + 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) @@ -650,6 +737,20 @@ Describe 'workflow enforcement boundary' { } | 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 = @@ -852,7 +953,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are $global:mockReverts = @( New-LeakPr ` -Number 504 ` - -Title 'Revert leak fix' ` + -Title 'Back out the collection cleanup' ` -Body 'Reverts dotnet/maui#503' ) @@ -899,7 +1000,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are } $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] - if ($search -eq '"Revert" in:title') { + 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) diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 32256b692d93..2a91ddc4fdc6 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -7,10 +7,11 @@ function Get-CanonicalLeakApi { $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]|$)' $match = if ($normalized.StartsWith('[leak-scan] ', [StringComparison]::Ordinal)) { - [regex]::Match($normalized, "^\[leak-scan\][ `t]+(?$identifierChain)(?=[ `t]|$)") + [regex]::Match($normalized, "^\[leak-scan\][ `t]+(?$identifierChain)$apiBoundary") } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { - [regex]::Match($normalized, "^\[leak-fix\][ `t]+Fix[ `t]+(?$identifierChain)(?=[ `t]|$)") + [regex]::Match($normalized, "^\[leak-fix\][ `t]+Fix[ `t]+(?$identifierChain)$apiBoundary") } else { return $null } @@ -22,6 +23,27 @@ function Get-CanonicalLeakApi { return "$($segments[-2]).$($segments[-1])" } +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 Test-LeakPrReferencesIssue { param( [AllowEmptyString()][string]$Body, @@ -322,6 +344,7 @@ function Get-EffectiveRevertedPullRequestNumbers { Export-ModuleMember -Function ` Get-CanonicalLeakApi, ` + Read-RegularJsonFile, ` Test-LeakPrReferencesIssue, ` Invoke-LeakGhJson, ` Assert-LeakDedupState, ` diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 6e9c224bde79..2ff48e60fadf 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":"55b03415eec9e20fb06d888214bc1cc887ed55e9c580d69938eacb527e35c3e3","body_hash":"0b2d9879771c3efb63abebca6b63475cb998eb199d33f2e3d2445b9fd032c448","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":"2c9ebc2d2d1d03b43d0033c85b81bc335a1257ce4fc4a7e3d0bd820b17fdcdd9","body_hash":"0b2d9879771c3efb63abebca6b63475cb998eb199d33f2e3d2445b9fd032c448","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 # @@ -505,7 +505,7 @@ jobs: 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\"\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\")\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# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search '\"Revert\" in:title' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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" + 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\"\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\")\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# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search 'Reverts in:body' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged revert-body search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 815f87e96ef9..04b29e43cb96 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -159,11 +159,11 @@ pre-agent-steps: # Drop only effectively reverted fixes from the # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search '"Revert" in:title' --json number,title,body,baseRefName,mergedAt \ + --search 'Reverts in:body' --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/revert-prs-raw.json REVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json) if test "$REVERT_RAW_COUNT" -ge 1000; then - echo "ERROR: merged Revert search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed." >&2 + echo "ERROR: merged revert-body search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed." >&2 exit 1 fi jq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \ diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 329875e7853f..6b148ba8aa91 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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"c3e5a4330f7d1215ac45da8d2939e4f7e58d7c9a05a9ad859f25f7d96c1eb85f","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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"11e2680cc1126863c58b3747c2865a82090b2f82f26e71323db3fba7e3468ec1","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index c445009c85b9..604ad4f8303d 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -483,15 +483,15 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ # same-base revert chains before either the direct issue-reference or same-API merged gate # consumes it. A servicing-branch revert cannot toggle a main/inflight fix. if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search '"Revert" in:title' \ + --search 'Reverts in:body' \ --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/merged-revert-prs-raw.json; then - echo "ERROR: merged Revert search failed — aborting because effective fix state is unknown." >&2 + echo "ERROR: merged revert-body search failed — aborting because effective fix state is unknown." >&2 exit 1 fi MERGED_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-revert-prs-raw.json) if test "$MERGED_REVERT_COUNT" -ge 1000; then - echo "ERROR: merged Revert search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 + echo "ERROR: merged revert-body search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 exit 1 fi jq '[.[] | select(.mergedAt != null)]' \ @@ -847,15 +847,15 @@ jq '[.[] | > /tmp/gh-aw/agent/final-merged-leak-fix-prs.json if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search '"Revert" in:title' \ + --search 'Reverts in:body' \ --json number,title,body,baseRefName,mergedAt \ > /tmp/gh-aw/agent/final-merged-revert-prs-raw.json; then - echo "ERROR: final merged Revert search failed — aborting because effective fix state is unknown." >&2 + echo "ERROR: final merged revert-body search failed — aborting because effective fix state is unknown." >&2 exit 1 fi FINAL_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/final-merged-revert-prs-raw.json) if test "$FINAL_REVERT_COUNT" -ge 1000; then - echo "ERROR: final merged Revert search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 + echo "ERROR: final merged revert-body search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 exit 1 fi jq '[.[] | select(.mergedAt != null)]' \ From 4bdafc70ae6a92f5e1656450b0aca4d265effa55 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 08:57:48 +0200 Subject: [PATCH 53/68] Harden trusted leak dedup gates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 80 ++--- .../Assert-LeakHunterSafeOutputGate.ps1 | 58 +--- .../scripts/Get-RelevantMergedLeakReverts.ps1 | 36 +++ .github/scripts/LeakWorkflowDedup.Tests.ps1 | 282 +++++++++++------- .github/scripts/LeakWorkflowDedup.psm1 | 165 +++++++--- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 70 ++--- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 133 +++------ 9 files changed, 436 insertions(+), 394 deletions(-) create mode 100644 .github/scripts/Get-RelevantMergedLeakReverts.ps1 diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index 7123322ce19b..5886992395ff 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -56,13 +56,11 @@ if ($targetRefsMatches.Count -ne 1) { $statePath = Join-Path $StateDirectory 'dedup-state.json' $state = Read-RegularJsonFile -Path $statePath -$approved = @( - Assert-LeakDedupState ` - -State $state ` - -IssueNumber $issueNumber ` - -Api $api ` - -Repository $Repository -) +Assert-LeakDedupState ` + -State $state ` + -IssueNumber $issueNumber ` + -Api $api ` + -Repository $Repository $merged = @( Invoke-LeakGhJson -Arguments @( @@ -78,32 +76,56 @@ 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." } +$eligibleMerged = @($merged | Where-Object { + $null -ne $_.mergedAt -and + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and + [string]$_.baseRefName -in @('main', 'inflight/current') + }) $mergedReverts = @( + Get-RelevantMergedLeakReverts ` + -Repository $Repository ` + -TargetPullRequests $eligibleMerged +) + +$open = @( Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, - '--state', 'merged', + '--state', 'open', '--limit', '1000', - '--search', 'Reverts in:body', - '--json', 'number,title,body,baseRefName,mergedAt' + '--search', '"[leak-fix]" in:title', + '--json', 'number,title,body,baseRefName,mergedAt,url' ) ) -if ($mergedReverts.Count -ge 1000) { - throw "Merged revert-body search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." +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." } -$open = @( +$closed = @( Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, - '--state', 'open', + '--state', 'closed', '--limit', '1000', '--search', '"[leak-fix]" in:title', - '--json', 'number,title,body,baseRefName,mergedAt,url' + '--json', 'number,title,body,mergedAt' ) ) -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." +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." +} +$closedAttempts = @($closed | 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-CanonicalLeakApi -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 ` @@ -112,32 +134,10 @@ $result = Get-LeakFixFinalDedupResult ` -Repository $Repository ` -MergedPullRequests $merged ` -OpenPullRequests $open ` - -MergedRevertPullRequests $mergedReverts ` - -ApprovedDifferentMechanismPullRequests $approved + -MergedRevertPullRequests $mergedReverts if ($result.Blocked) { throw "Final leak-fix de-dup gate blocked PR creation: $($result.Reason)." } -$body = [string]$item.body -foreach ($match in $result.ApiMatches) { - $number = [int]$match.number - $decisions = @($state.different_mechanism_prs | Where-Object { - [int]$_.number -eq $number - }) - if ($decisions.Count -ne 1) { - throw "Final leak-fix de-dup gate could not find exactly one mechanism decision for live same-API PR #$number." - } - - $basis = [string]$decisions[0].basis - $disclosurePattern = "(?m)^[ `t]*Same-API comparison:[ `t]*$repo#$number[ `t]*\|[ `t]*Different mechanism:[ `t]*(?[^|`r`n]{12,500}?)[ `t]*$" - $disclosures = [regex]::Matches($body, $disclosurePattern) - if ($disclosures.Count -ne 1) { - throw "The PR body must contain exactly one structured same-API disclosure for live PR #${number}: 'Same-API comparison: $Repository#$number | Different mechanism: '." - } - if ($disclosures[0].Groups['basis'].Value.Trim() -cne $basis) { - throw "The PR body same-API disclosure basis for PR #$number does not match the persisted comparison basis." - } -} - 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 index c37323b13a1b..f01668116bbb 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -9,28 +9,6 @@ param( $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force -function Assert-SameApiDisclosure { - param( - [Parameter(Mandatory = $true)][string]$Body, - [Parameter(Mandatory = $true)][ValidateSet('issue', 'pull-request')][string]$Kind, - [Parameter(Mandatory = $true)][int]$Number, - [Parameter(Mandatory = $true)][string]$Repository - ) - - $repo = [regex]::Escape($Repository) - $label = if ($Kind -eq 'issue') { - 'Same-API issue comparison' - } else { - 'Same-API comparison' - } - $labelPattern = [regex]::Escape($label) - $pattern = "(?m)^[ `t]*${labelPattern}:[ `t]*$repo#$Number[ `t]*\|[ `t]*Different mechanism:[ `t]*(?[^|`r`n]{12,500}?)[ `t]*$" - $matches = [regex]::Matches($Body, $pattern) - if ($matches.Count -ne 1) { - throw "Final leak-hunter de-dup gate requires exactly one structured $Kind override for same-API $Repository#${Number}: '$label`: $Repository#$Number | Different mechanism: '." - } -} - if ([string]::IsNullOrWhiteSpace($Repository)) { throw 'GITHUB_REPOSITORY is required.' } @@ -102,25 +80,16 @@ 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." } -$mergedReverts = @( - Invoke-LeakGhJson -Arguments @( - 'pr', 'list', - '--repo', $Repository, - '--state', 'merged', - '--limit', '1000', - '--search', 'Reverts in:body', - '--json', 'number,title,body,baseRefName,mergedAt' - ) -) -if ($mergedReverts.Count -ge 1000) { - throw "Merged revert-body search returned $($mergedReverts.Count) rows at the GitHub Search API ceiling; refusing a potentially truncated final gate." -} - $eligibleMerged = @($merged | Where-Object { $null -ne $_.mergedAt -and ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and [string]$_.baseRefName -in @('main', 'inflight/current') }) +$mergedReverts = @( + Get-RelevantMergedLeakReverts ` + -Repository $Repository ` + -TargetPullRequests $eligibleMerged +) $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` -Repository $Repository ` @@ -137,29 +106,20 @@ $eligibleMerged = @($eligibleMerged | Where-Object { foreach ($requested in $requestedItems) { $api = [string]$requested.Api - $body = [string]$requested.Item.body $openApiMatches = @($openIssues | Where-Object { $issueTitle = [string]$_.title $issueTitle.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and (Get-CanonicalLeakApi -Title $issueTitle) -ceq $api }) - foreach ($match in $openApiMatches) { - Assert-SameApiDisclosure ` - -Body $body ` - -Kind issue ` - -Number ([int]$match.number) ` - -Repository $Repository + 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 ', ')." } $mergedApiMatches = @($eligibleMerged | Where-Object { (Get-CanonicalLeakApi -Title ([string]$_.title)) -ceq $api }) - foreach ($match in $mergedApiMatches) { - Assert-SameApiDisclosure ` - -Body $body ` - -Kind pull-request ` - -Number ([int]$match.number) ` - -Repository $Repository + 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 ', ')." } } 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 index ee57af1b2b79..3cb36baf868e 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -116,7 +116,7 @@ Describe 'fresh-shell de-dup state' { } | Should -Throw '*does not match PR issue*' } - It 'rejects malformed different-mechanism decisions' { + It 'rejects agent-authored different-mechanism overrides' { $state = [pscustomobject]@{ issue_number = 42 api = 'Picker.ItemsSource' @@ -132,29 +132,7 @@ Describe 'fresh-shell de-dup state' { -IssueNumber 42 ` -Api 'Picker.ItemsSource' ` -Repository 'dotnet/maui' - } | Should -Throw '*lacks a specific basis*' - } - - It 'rejects a comparison basis that cannot fit the structured body disclosure' { - $state = [pscustomobject]@{ - issue_number = 42 - api = 'Picker.ItemsSource' - repository = 'dotnet/maui' - different_mechanism_prs = @( - [pscustomobject]@{ - number = 100 - basis = "Existing teardown path|different reset path" - } - ) - } - - { - Assert-LeakDedupState ` - -State $state ` - -IssueNumber 42 ` - -Api 'Picker.ItemsSource' ` - -Repository 'dotnet/maui' - } | Should -Throw '*invalid basis format*' + } | Should -Throw '*do not accept agent-authored different-mechanism overrides*' } } @@ -179,11 +157,31 @@ Describe 'canonical leak API title parsing' { '[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') | @@ -206,8 +204,8 @@ Describe 'canonical leak API title parsing' { } } -Describe 'mechanism-aware final duplicate gate' { - It 'preserves an approved same-API different-mechanism exception' { +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' ` @@ -218,13 +216,13 @@ Describe 'mechanism-aware final duplicate gate' { -Api 'GradientBrush.GradientStops' ` -Repository 'dotnet/maui' ` -MergedPullRequests @($existing) ` - -OpenPullRequests @() ` - -ApprovedDifferentMechanismPullRequests @(100) + -OpenPullRequests @() - $result.Blocked | Should -BeFalse + $result.Blocked | Should -BeTrue + $result.ApiMatches.number | Should -Be 100 } - It 'blocks a same-API PR that appeared after Step 3 until its mechanism is compared' { + 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' ` @@ -235,11 +233,10 @@ Describe 'mechanism-aware final duplicate gate' { -Api 'GradientBrush.GradientStops' ` -Repository 'dotnet/maui' ` -MergedPullRequests @() ` - -OpenPullRequests @($newOpen) ` - -ApprovedDifferentMechanismPullRequests @() + -OpenPullRequests @($newOpen) $result.Blocked | Should -BeTrue - $result.UnapprovedApiMatches.number | Should -Be 101 + $result.ApiMatches.number | Should -Be 101 } It 'ignores a same-API open PR targeting an unrelated release branch' { @@ -255,15 +252,14 @@ Describe 'mechanism-aware final duplicate gate' { -Api 'GradientBrush.GradientStops' ` -Repository 'dotnet/maui' ` -MergedPullRequests @() ` - -OpenPullRequests @($releaseOpen) ` - -ApprovedDifferentMechanismPullRequests @() + -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 even when the PR was approved by API' { + It 'always blocks a direct issue reference' { $direct = New-LeakPr ` -Number 102 ` -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` @@ -274,8 +270,7 @@ Describe 'mechanism-aware final duplicate gate' { -Api 'GradientBrush.GradientStops' ` -Repository 'dotnet/maui' ` -MergedPullRequests @($direct) ` - -OpenPullRequests @() ` - -ApprovedDifferentMechanismPullRequests @(102) + -OpenPullRequests @() $result.Blocked | Should -BeTrue $result.DirectMatches.number | Should -Be 102 @@ -336,7 +331,7 @@ Describe 'mechanism-aware final duplicate gate' { -MergedRevertPullRequests $reverts $result.Blocked | Should -BeTrue - $result.UnapprovedApiMatches.number | Should -Be 100 + $result.ApiMatches.number | Should -Be 100 $result.EffectivelyReverted | Should -Be @(110) } @@ -374,6 +369,17 @@ Describe 'mechanism-aware final duplicate gate' { } 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' @@ -552,6 +558,81 @@ Describe 'effective recursive revert state' { } } +Describe 'target-scoped merged revert discovery' { + BeforeEach { + $script:discoverySearches = [System.Collections.Generic.List[string]]::new() + $script:discoveryRowsByTarget = @{} + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:LASTEXITCODE = 0 + $searchIndex = [Array]::IndexOf($GhArgs, '--search') + $search = $GhArgs[$searchIndex + 1] + $script:discoverySearches.Add($search) + $target = [regex]::Match($search, '#(?[1-9][0-9]*)').Groups['number'].Value + $rows = if ($script:discoveryRowsByTarget.ContainsKey($target)) { + @($script:discoveryRowsByTarget[$target]) + } 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:discoveryRowsByTarget['100'] = @( + New-LeakPr ` + -Number 200 ` + -Title 'Back out cleanup without Revert in the title' ` + -Body 'Reverts #100' + New-LeakPr ` + -Number 201 ` + -Title 'Unrelated mention' ` + -Body 'Discusses #100 but does not revert it' + ) + $script:discoveryRowsByTarget['200'] = @( + 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:discoverySearches | Should -Be @( + 'Reverts "#100" in:body' + 'Reverts "#200" in:body' + 'Reverts "#300" in:body' + ) + } + + It 'fails closed when a target-scoped query reaches its result ceiling' { + $script:discoveryRowsByTarget['100'] = @( + 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 '*Scoped merged-revert search for PR #100*2-result ceiling*' + } +} + 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 @@ -640,18 +721,30 @@ Describe 'workflow enforcement boundary' { $fixer | Should -Not -Match 'awk.*A-Za-z_' } - It 'discovers merged reverter candidates by explicit body-reference text, not title' { - $paths = @( - 'Assert-LeakFixSafeOutputGate.ps1' - 'Assert-LeakHunterSafeOutputGate.ps1' - '../workflows/daily-leak-hunter.md' - '../workflows/leak-fixer.md' - ) + It 'uses target-scoped recursive revert discovery in both gates and workflows' { + $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 - foreach ($path in $paths) { - $content = Get-Content -LiteralPath (Join-Path $PSScriptRoot $path) -Raw - $content | Should -Match 'Reverts in:body' - $content | Should -Not -Match '"Revert" in:title' + $module | Should -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' + @($module, $fixGate, $hunterGate, $hunter, $fixer) | ForEach-Object { + $_ | Should -Not -Match "Reverts in:body|'Reverts in:body'" } } @@ -681,6 +774,7 @@ Describe 'workflow enforcement boundary' { $global:mockMerged = @() $global:mockReverts = @() $global:mockOpen = @() + $global:mockClosed = @() $global:mockGhExitCode = 0 $global:mockGhStderr = '' function global:gh { @@ -698,11 +792,13 @@ Describe 'workflow enforcement boundary' { $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] if ($state -eq 'merged') { - if ($search -eq 'Reverts in:body') { + if ($search -match '^Reverts "#[1-9][0-9]*" 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) } @@ -711,7 +807,7 @@ Describe 'workflow enforcement boundary' { AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable mockMerged, mockReverts, mockOpen, mockGhExitCode, mockGhStderr ` + Remove-Variable mockMerged, mockReverts, mockOpen, mockClosed, mockGhExitCode, mockGhStderr ` -Scope Global -ErrorAction SilentlyContinue } @@ -824,13 +920,7 @@ Describe 'workflow enforcement boundary' { } | Should -Not -Throw } - It 'rejects a live same-API decision that is absent from the PR body' { - $global:mockMerged = @( - New-LeakPr ` - -Number 501 ` - -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` - -Body 'Fixes #10' - ) + It 'rejects an agent-authored different-mechanism state override' { @{ issue_number = 20 api = 'GradientBrush.GradientStops' @@ -838,7 +928,7 @@ Describe 'workflow enforcement boundary' { different_mechanism_prs = @( @{ number = 501 - basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' + basis = 'Agent-authored mechanism claim' } ) } | ConvertTo-Json -Depth 5 | @@ -849,36 +939,23 @@ Describe 'workflow enforcement boundary' { -AgentOutputPath $script:agentOutput ` -StateDirectory $script:stateDirectory ` -Repository 'dotnet/maui' - } | Should -Throw '*structured same-API disclosure*#501*' + } | Should -Throw '*do not accept agent-authored different-mechanism overrides*' } - It 'allows a live same-API match when the body discloses the exact persisted basis' { - $basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' + 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' ) - @{ - issue_number = 20 - api = 'GradientBrush.GradientStops' - repository = 'dotnet/maui' - different_mechanism_prs = @( - @{ - number = 501 - basis = $basis - } - ) - } | ConvertTo-Json -Depth 5 | - Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') $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: $basis +Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim "@ $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:agentOutput @@ -887,42 +964,24 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: $basis -AgentOutputPath $script:agentOutput ` -StateDirectory $script:stateDirectory ` -Repository 'dotnet/maui' - } | Should -Not -Throw + } | Should -Throw '*blocked PR creation*same-API match: 501*' } - It 'rejects a same-API disclosure whose basis differs from persisted state' { - $global:mockMerged = @( - New-LeakPr ` - -Number 501 ` - -Title '[leak-fix] Fix GradientBrush.GradientStops teardown leak' ` - -Body 'Fixes #10' + It 'blocks a fourth closed-unmerged attempt for the same issue or API' { + $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' -Merged $false + New-LeakPr -Number 603 -Title '[leak-fix] Fix Other.Api leak again' ` + -Body 'Refs: dotnet/maui#20' -Merged $false ) - @{ - issue_number = 20 - api = 'GradientBrush.GradientStops' - repository = 'dotnet/maui' - different_mechanism_prs = @( - @{ - number = 501 - basis = 'Existing PR fixes detach teardown; this issue fixes Reset unsubscription.' - } - ) - } | ConvertTo-Json -Depth 5 | - Set-Content -LiteralPath (Join-Path $script:stateDirectory 'dedup-state.json') - $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json - $output.items[0].body = @' -Fixes #20 -Refs: dotnet/maui#20 -Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are definitely unrelated. -'@ - $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 '*does not match the persisted comparison basis*' + } | Should -Throw '*attempt-cap gate blocked PR creation: 3 closed-unmerged attempts*' } It 'allows a release-only open PR even when it directly references the issue' { @@ -1000,7 +1059,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are } $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] - if ($search -eq 'Reverts in:body') { + if ($search -match '^Reverts "#[1-9][0-9]*" in:body$') { Write-Output (ConvertTo-Json -InputObject @($global:mockHunterReverts) -Depth 5) } else { Write-Output (ConvertTo-Json -InputObject @($global:mockHunterMerged) -Depth 5) @@ -1065,11 +1124,10 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` -AgentOutputPath $script:hunterAgentOutput ` -Repository 'dotnet/maui' - } | Should -Throw '*structured pull-request override*#701*' + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*701*" } - It 'allows a distinct same-API mechanism with bounded human-visible evidence' { - $basis = 'Existing PR fixes detach teardown; this issue proves Reset unsubscription.' + It 'blocks agent-authored different-mechanism evidence for a same-API fix' { $global:mockHunterMerged = @( New-LeakPr ` -Number 701 ` @@ -1080,7 +1138,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: These mechanisms are AI-generated leak report ## Same-API comparisons -Same-API comparison: dotnet/maui#701 | Different mechanism: $basis +Same-API comparison: dotnet/maui#701 | Different mechanism: Agent-authored claim "@ $output | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:hunterAgentOutput @@ -1089,10 +1147,10 @@ Same-API comparison: dotnet/maui#701 | Different mechanism: $basis & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` -AgentOutputPath $script:hunterAgentOutput ` -Repository 'dotnet/maui' - } | Should -Not -Throw + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*701*" } - It 'requires a separate structured comparison for a same-API open issue' { + It 'blocks a same-API open issue without accepting an override' { $global:mockHunterOpenIssues = @( [pscustomobject]@{ number = 702 @@ -1106,7 +1164,7 @@ Same-API comparison: dotnet/maui#701 | Different mechanism: $basis & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` -AgentOutputPath $script:hunterAgentOutput ` -Repository 'dotnet/maui' - } | Should -Throw '*structured issue override*#702*' + } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*702*" } It 'parses successful hunter JSON without mixing benign gh stderr into stdout' { diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 2a91ddc4fdc6..c8c356b44ac5 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -7,7 +7,7 @@ function Get-CanonicalLeakApi { $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]|$)' + $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)) { @@ -19,8 +19,13 @@ function Get-CanonicalLeakApi { return $null } - $segments = $match.Groups['api'].Value.Split('.') - return "$($segments[-2]).$($segments[-1])" + $api = $match.Groups['api'].Value + $segments = $api.Split('.') + if ($segments.Count -eq 2 -or + $api.StartsWith('Microsoft.Maui.', [StringComparison]::Ordinal)) { + return "$($segments[-2]).$($segments[-1])" + } + return $api } function Read-RegularJsonFile { @@ -57,6 +62,21 @@ function Test-LeakPrReferencesIssue { $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 Invoke-LeakGhJson { param([Parameter(Mandatory = $true)][string[]]$Arguments) @@ -96,6 +116,84 @@ function Invoke-LeakGhJson { } } +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 + ) + + $queue = [System.Collections.Generic.Queue[object]]::new() + 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." + } + $queue.Enqueue([pscustomobject]@{ + number = $number + baseRefName = $base + }) + } + + $queried = [System.Collections.Generic.HashSet[int]]::new() + $discovered = @{} + while ($queue.Count -gt 0) { + $target = $queue.Dequeue() + $targetNumber = [int]$target.number + if (-not $queried.Add($targetNumber)) { + continue + } + + $rows = @( + Invoke-LeakGhJson -Arguments @( + 'pr', 'list', + '--repo', $Repository, + '--state', 'merged', + '--limit', [string]$SearchLimit, + '--search', "Reverts `"#${targetNumber}`" in:body", + '--json', 'number,title,body,baseRefName,mergedAt' + ) + ) + if ($rows.Count -ge $SearchLimit) { + throw "Scoped merged-revert search for PR #$targetNumber returned $($rows.Count) rows at its $SearchLimit-result ceiling." + } + + foreach ($row in $rows) { + if ($null -eq $row.mergedAt -or + $targetNumber -notin @( + Get-LeakRevertTargets ` + -Body ([string]$row.body) ` + -Repository $Repository + ) -or + [string]$row.baseRefName -cne [string]$target.baseRefName) { + continue + } + + $reverter = 0 + if (-not [int]::TryParse([string]$row.number, [ref]$reverter) -or $reverter -le 0) { + throw "Invalid discovered merged-revert PR number '$($row.number)'." + } + if (-not $discovered.ContainsKey($reverter)) { + if ($discovered.Count -ge $MaximumDiscoveredPullRequests) { + throw "Relevant merged-revert discovery exceeded the $MaximumDiscoveredPullRequests-PR safety bound." + } + $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, @@ -118,28 +216,9 @@ function Assert-LeakDedupState { throw "De-dup state is missing required array 'different_mechanism_prs'." } - $seen = [System.Collections.Generic.HashSet[int]]::new() - foreach ($decision in @($State.different_mechanism_prs)) { - $number = 0 - if (-not [int]::TryParse([string]$decision.number, [ref]$number) -or $number -le 0) { - throw "Invalid different-mechanism PR number '$($decision.number)'." - } - if (-not $seen.Add($number)) { - throw "Duplicate different-mechanism decision for PR #$number." - } - if ([string]::IsNullOrWhiteSpace([string]$decision.basis) -or - ([string]$decision.basis).Length -lt 12) { - throw "Different-mechanism decision for PR #$number lacks a specific basis." - } - $basis = [string]$decision.basis - if ($basis -cne $basis.Trim() -or - $basis.Length -gt 500 -or - $basis -match "[|`r`n]") { - throw "Different-mechanism decision for PR #$number has an invalid basis format." - } + if (@($State.different_mechanism_prs).Count -ne 0) { + throw 'Trusted de-dup gates do not accept agent-authored different-mechanism overrides.' } - - return @($seen | Sort-Object) } function Get-LeakFixFinalDedupResult { @@ -149,15 +228,9 @@ function Get-LeakFixFinalDedupResult { [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedPullRequests, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$OpenPullRequests, - [AllowEmptyCollection()][object[]]$MergedRevertPullRequests = @(), - [int[]]$ApprovedDifferentMechanismPullRequests = @() + [AllowEmptyCollection()][object[]]$MergedRevertPullRequests = @() ) - $approved = [System.Collections.Generic.HashSet[int]]::new() - foreach ($number in $ApprovedDifferentMechanismPullRequests) { - [void]$approved.Add($number) - } - $eligibleMerged = @($MergedPullRequests | Where-Object { $null -ne $_.mergedAt -and ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) -and @@ -192,19 +265,14 @@ function Get-LeakFixFinalDedupResult { $apiMatches = @($eligible | Where-Object { (Get-CanonicalLeakApi -Title ([string]$_.title)) -ceq $Api } | Sort-Object number -Unique) - $unapprovedApiMatches = @($apiMatches | Where-Object { - -not $approved.Contains([int]$_.number) - }) - $blocked = $directMatches.Count -gt 0 -or $unapprovedApiMatches.Count -gt 0 + $blocked = $directMatches.Count -gt 0 -or $apiMatches.Count -gt 0 $reason = if ($directMatches.Count -gt 0) { "direct issue-reference match: $($directMatches.number -join ', ')" - } elseif ($unapprovedApiMatches.Count -gt 0) { - "same-API match without a different-mechanism decision: $($unapprovedApiMatches.number -join ', ')" - } elseif ($apiMatches.Count -eq 0) { - 'no live direct-reference or same-API duplicate matches' + } elseif ($apiMatches.Count -gt 0) { + "same-API match: $($apiMatches.number -join ', ')" } else { - 'all live same-API matches were explicitly judged to use different retention mechanisms' + 'no live direct-reference or same-API duplicate matches' } return [pscustomobject]@{ @@ -212,7 +280,6 @@ function Get-LeakFixFinalDedupResult { Reason = $reason DirectMatches = $directMatches ApiMatches = $apiMatches - UnapprovedApiMatches = $unapprovedApiMatches EffectivelyReverted = $effectivelyReverted } } @@ -227,11 +294,6 @@ function Get-EffectiveRevertedPullRequestNumbers { $revertersByTarget = @{} $branchByNumber = @{} $fixNumbers = [System.Collections.Generic.List[int]]::new() - $repo = [regex]::Escape($Repository) - $markdownPrefix = '(?:>[ \t]*)?(?:[-+*][ \t]+)?(?:\*{1,2}|_{1,2})?' - $pattern = "(?m)^[ `t]*$markdownPrefix" + - "Reverts[ `t]+(?:$repo#|#)(?[1-9][0-9]*)\b" - foreach ($fix in $FixPullRequests) { $number = 0 if (-not [int]::TryParse([string]$fix.number, [ref]$number) -or $number -le 0) { @@ -268,8 +330,11 @@ function Get-EffectiveRevertedPullRequestNumbers { foreach ($revert in $MergedRevertPullRequests) { $reverter = [int]$revert.number $reverterBase = [string]$revert.baseRefName - foreach ($match in [regex]::Matches(([string]$revert.body), $pattern)) { - $target = [int]$match.Groups['number'].Value + foreach ($target in @( + Get-LeakRevertTargets ` + -Body ([string]$revert.body) ` + -Repository $Repository + )) { if (-not $branchByNumber.ContainsKey($target) -or $branchByNumber[$target] -cne $reverterBase) { continue @@ -298,6 +363,7 @@ function Get-EffectiveRevertedPullRequestNumbers { return [string]$memo[$PullRequestNumber] } if (-not $Visiting.Add($PullRequestNumber)) { + $memo[$PullRequestNumber] = $ambiguousState return $ambiguousState } @@ -319,6 +385,7 @@ function Get-EffectiveRevertedPullRequestNumbers { } if ($hasAmbiguousReverter) { + $memo[$PullRequestNumber] = $ambiguousState return $ambiguousState } $memo[$PullRequestNumber] = $activeState @@ -346,7 +413,9 @@ Export-ModuleMember -Function ` Get-CanonicalLeakApi, ` Read-RegularJsonFile, ` 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 2ff48e60fadf..8439d552560f 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":"2c9ebc2d2d1d03b43d0033c85b81bc335a1257ce4fc4a7e3d0bd820b17fdcdd9","body_hash":"0b2d9879771c3efb63abebca6b63475cb998eb199d33f2e3d2445b9fd032c448","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":"67d00997673557209857073e1da6e04219cb9f29e9dc69c33988ca372706ef50","body_hash":"72dda183dc4abd0a5983ad56918104fcea2c6fccbfcde297cabd8d15a601826f","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 # @@ -505,7 +505,7 @@ jobs: 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\"\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\")\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# Drop only effectively reverted fixes from the\n# permanent-proof set (they fall back to being re-filable, same as an unmerged attempt).\ngh pr list --repo \"$GITHUB_REPOSITORY\" --state merged --limit 1000 \\\n --search 'Reverts in:body' --json number,title,body,baseRefName,mergedAt \\\n > /tmp/gh-aw/agent/revert-prs-raw.json\nREVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json)\nif test \"$REVERT_RAW_COUNT\" -ge 1000; then\n echo \"ERROR: merged revert-body search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed.\" >&2\n exit 1\nfi\njq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \\\n > /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" + 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\"\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\")\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 (then recursively their reverters). Each target-scoped query has\n# its own fail-closed Search API ceiling, avoiding an unrelated repository-wide cap.\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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 04b29e43cb96..87a1691b2f0f 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -156,18 +156,13 @@ pre-agent-steps: # 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. - # Drop only effectively reverted fixes from the - # permanent-proof set (they fall back to being re-filable, same as an unmerged attempt). - gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search 'Reverts in:body' --json number,title,body,baseRefName,mergedAt \ - > /tmp/gh-aw/agent/revert-prs-raw.json - REVERT_RAW_COUNT=$(jq 'length' /tmp/gh-aw/agent/revert-prs-raw.json) - if test "$REVERT_RAW_COUNT" -ge 1000; then - echo "ERROR: merged revert-body search returned $REVERT_RAW_COUNT rows — at/above the GitHub Search API ceiling. Effective revert chains may be truncated, so aborting fail-closed." >&2 - exit 1 - fi - jq '[.[] | select(.mergedAt != null)]' /tmp/gh-aw/agent/revert-prs-raw.json \ - > /tmp/gh-aw/agent/merged-revert-prs.json + # Discover only same-branch merged PRs whose bodies explicitly revert one of the + # relevant leak fixes (then recursively their reverters). Each target-scoped query has + # its own fail-closed Search API ceiling, avoiding an unrelated repository-wide cap. + 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; @@ -333,33 +328,27 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts the fix is still active. - A candidate is **OUT** if an open `[leak-scan]` issue or active merged `[leak-fix]` PR covers - the same rooting API **and retention mechanism**. API identity alone is not leak identity: - distinct retention paths can legitimately share one `Type.Member`. Use - `already-filed-apis.txt` / `already-merged-fix-apis.txt` as fast match signals, then inspect - the matching issue/PR before deciding. **Check this for EVERY candidate before its test.** + 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 `), and - the last `Type.Member` pair of a fully-qualified chain is the canonical key (for example, - `Microsoft.Maui.Controls.Picker.ItemsSource` yields `Picker.ItemsSource`). Then use + 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: equivalent fix already merged via # to ` only when the retention - path is equivalent. If the mechanism differs, proceed and include the structured PR - comparison line required below. -- For an open issue match, skip only when its retention path is equivalent. If the mechanism - differs, proceed and include the structured issue comparison line required below. -- Re-filing the same retention mechanism under different wording/number is the primary failure - mode, so be strict about both the canonical `Type.Member` and the root-to-transient path. + `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 blocks matching `create-issue` output unless the emitted body contains exactly one - bounded, human-visible different-mechanism comparison for it; do not treat the pre-agent + issue/fix unconditionally blocks matching `create-issue` output; do not treat the pre-agent snapshot as the final authority. A candidate whose only prior scanner issue is CLOSED may be re-filed when no active merged fix -covers the same API and retention mechanism. +covers the same canonical API. # ===================== RUNTIME LEAK HUNT ===================== @@ -486,20 +475,15 @@ one output per canonical rooting API in the current batch. If multiple confirmed 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. -A trusted final gate repeats the live de-dup immediately before mutation. For -every live same-API match that uses a genuinely different retention mechanism, the issue body -must include exactly one applicable bounded line (12–500 character single-line basis, no `|`): - -`Same-API issue comparison: /# | Different mechanism: ` - -`Same-API comparison: /# | Different mechanism: ` - -The gate blocks missing/malformed comparisons; omit these lines when there is no same-API match. -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. +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. +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 6b148ba8aa91..45ecee02502a 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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"11e2680cc1126863c58b3747c2865a82090b2f82f26e71323db3fba7e3468ec1","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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"530f847e251f321b60d4c7833f5d11c0332e3d055e5fff7b5d9daf470aadbf12","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 604ad4f8303d..b33ac86ab9dd 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -426,10 +426,11 @@ if test -z "$API"; then 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 and the -# mechanism-aware exceptions that the agent makes below; Step 9.5 and, authoritatively, the -# safe-output mutation-boundary gate reload and validate this file. Missing/mismatched state -# is a fail-closed refusal to create a PR, never an empty-variable fall-through. +# 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" \ @@ -479,24 +480,13 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ | sort -u \ > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv -# A merged fix is not authoritative if it was later effectively reverted. Resolve recursive -# same-base revert chains before either the direct issue-reference or same-API merged gate -# consumes it. A servicing-branch revert cannot toggle a main/inflight fix. -if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search 'Reverts in:body' \ - --json number,title,body,baseRefName,mergedAt \ - > /tmp/gh-aw/agent/merged-revert-prs-raw.json; then - echo "ERROR: merged revert-body search failed — aborting because effective fix state is unknown." >&2 - exit 1 -fi -MERGED_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/merged-revert-prs-raw.json) -if test "$MERGED_REVERT_COUNT" -ge 1000; then - echo "ERROR: merged revert-body search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 - exit 1 -fi -jq '[.[] | select(.mergedAt != null)]' \ - /tmp/gh-aw/agent/merged-revert-prs-raw.json \ - > /tmp/gh-aw/agent/merged-revert-prs.json +# A merged fix is not authoritative if it was later effectively reverted. Discover only +# same-branch merged PRs with explicit body references to the relevant leak-fix set, recursively. +# Each target-scoped query fails closed at its own Search API ceiling. +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 \ @@ -620,33 +610,16 @@ jq 'length' /tmp/gh-aw/agent/closed-fix-prs.json 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 but NO direct issue-reference match above — - the match is only on the canonical `Type.Member`, which is **API identity, not leak - identity**: distinct retention mechanisms can legitimately share one rooting member (e.g. - `GradientBrush.GradientStops` has both a no-detach-teardown subscription leak, #36363, and a - separate `Clear()`/Reset unsubscribe-miss leak, #36743 — a fix for one does not fix the - other). Before skipping on an API-only match, open the matched PR and compare its stated - retention path (root → … → transient) against THIS issue's retention path. Skip only if the - mechanism is the same (`skipped: equivalent fix already merged via # to `); - if the mechanism differs, this is a distinct leak — proceed with Steps 4–10 and cite the - API-match PR in your own PR body so a human reviewer can double-check. +- 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 rooting `Type.Member` with no direct issue - reference (c) → apply the SAME retention-mechanism check as the merged-API case above before - skipping; a same-API open PR that targets a different mechanism is not a duplicate. - Also double-check the leak isn't already fixed on `main` (Step 6 gate). -- **Persist every different-mechanism decision.** If you proceed past any API-only match from - (a) or (c), append one object to the `different_mechanism_prs` array in - `/tmp/gh-aw/agent/dedup-state.json`: - `{"number": , "basis": ""}`. - Keep the `basis` concise but specific (12–500 characters), single-line, with no `|`; do not - copy untrusted PR text verbatim, and preserve the other state fields. Use `jq` plus a - `.next.json` file and `cat` back over the state file. The safe-output gate rejects missing, - duplicate, malformed, or identity-mismatched decisions. It also requires the emitted PR body - to contain exactly one matching human-visible line for every live approved PR: - `Same-API comparison: /# | Different mechanism: `. +- 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). @@ -807,25 +780,21 @@ has a persisted retention-mechanism decision. This bash call runs in a fresh she reload the persisted target identity and fail closed if the state is missing or malformed. This prompt step prepares semantic mechanism decisions; it is not the authoritative mutation -gate. The generated safe-output job independently re-fetches live metadata and refuses -`create_pull_request` immediately before Process Safe Outputs unless every current API-only -match has a structurally valid different-mechanism decision and no direct issue-reference -match exists. The gate does not claim to independently prove the semantic comparison in each -decision; it requires the exact persisted basis to be disclosed in the structured PR-body form -so that the agent-authored judgment remains visible for human review. +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`; agent-authored mechanism +overrides are not accepted. ```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") +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 | type == "array") -' "$STATE" > /dev/null +jq -e '.different_mechanism_prs == []' "$STATE" > /dev/null if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ --search '"[leak-fix]" in:title' \ @@ -846,24 +815,13 @@ jq '[.[] | /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json \ > /tmp/gh-aw/agent/final-merged-leak-fix-prs.json -if ! gh pr list --repo "$GITHUB_REPOSITORY" --state merged --limit 1000 \ - --search 'Reverts in:body' \ - --json number,title,body,baseRefName,mergedAt \ - > /tmp/gh-aw/agent/final-merged-revert-prs-raw.json; then - echo "ERROR: final merged revert-body search failed — aborting because effective fix state is unknown." >&2 - exit 1 -fi -FINAL_REVERT_COUNT=$(jq 'length' /tmp/gh-aw/agent/final-merged-revert-prs-raw.json) -if test "$FINAL_REVERT_COUNT" -ge 1000; then - echo "ERROR: final merged revert-body search reached the 1000-result ceiling — aborting because revert chains may be truncated." >&2 - exit 1 -fi -jq '[.[] | select(.mergedAt != null)]' \ - /tmp/gh-aw/agent/final-merged-revert-prs-raw.json \ - > /tmp/gh-aw/agent/final-merged-revert-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 +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 \ @@ -924,37 +882,18 @@ jq -r '.[] | [.number, .title] | @tsv' /tmp/gh-aw/agent/final-open-fix-prs.json 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 -cut -f2 /tmp/gh-aw/agent/final-api-matches.tsv \ - | jq -R --slurpfile state "$STATE" ' - select(length > 0) | - tonumber as $number | - select(($state[0].different_mechanism_prs | map(.number) | index($number)) == null) | - $number - ' > /tmp/gh-aw/agent/final-unapproved-api-match-pr-numbers.txt 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 API-only matches:"; cat /tmp/gh-aw/agent/final-api-matches.tsv -echo "API-only matches without a persisted mechanism decision:"; cat /tmp/gh-aw/agent/final-unapproved-api-match-pr-numbers.txt +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. -- Compare mechanisms for every PR number in - `final-unapproved-api-match-pr-numbers.txt`. Existing decisions remain valid only while - their PR number is still a live same-API match; extra stale decisions are harmless. - For each unapproved API-only match, open the PR and compare its retention path to this issue. - If equivalent, stop. If different, append to the `different_mechanism_prs` array in - `/tmp/gh-aw/agent/dedup-state.json`: - `{"number": , "basis": ""}` - to `dedup-state.json.different_mechanism_prs` with the same atomic `jq`/`.next.json`/`cat` - pattern as Step 3. -- For every live different-mechanism decision, include exactly one body line using the basis - byte-for-byte from state: - `Same-API comparison: /# | Different mechanism: `. - The mutation-boundary gate rejects missing, duplicated, or mismatched disclosure lines. +- 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 - forget a decision, the safe-output mutation-boundary step independently re-fetches both - lists and blocks creation fail-closed. + 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) @@ -1005,10 +944,6 @@ control is collected. Managed cross-platform change → all platforms. No public API change (or: list the PublicAPI.Unshipped.txt entries added). -## Same-API comparisons - -Same-API comparison: /# | Different mechanism: ``` Before emitting, re-read your body and confirm the `Target branch:` line says `main` and a From 83f6fb7ec31421ca54d8b251351facad62eeb57e Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 10:00:35 +0200 Subject: [PATCH 54/68] Document atomic leak-hunter retries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Assert-LeakHunterSafeOutputGate.ps1 | 6 ++-- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 35 +++++++++++++++++++ .github/workflows/daily-leak-hunter.lock.yml | 2 +- .github/workflows/daily-leak-hunter.md | 19 +++++----- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 20 +++++------ 6 files changed, 62 insertions(+), 22 deletions(-) diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index f01668116bbb..2f324ee551b7 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -104,6 +104,8 @@ $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 { @@ -112,14 +114,14 @@ foreach ($requested in $requestedItems) { (Get-CanonicalLeakApi -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 ', ')." + 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-CanonicalLeakApi -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 ', ')." + 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." } } diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 3cb36baf868e..7a20af82824f 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -1167,6 +1167,41 @@ Same-API comparison: dotnet/maui#701 | Different mechanism: Agent-authored claim } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*702*" } + 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' diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 8439d552560f..a13ef82fb9fc 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":"67d00997673557209857073e1da6e04219cb9f29e9dc69c33988ca372706ef50","body_hash":"72dda183dc4abd0a5983ad56918104fcea2c6fccbfcde297cabd8d15a601826f","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":"46816bb3343a1d036afb588ffb26818995a36f0034f3fbbdab59c19bc389f683","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 # diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 87a1691b2f0f..a975d7a08548 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -194,11 +194,11 @@ network: 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 so a late merge/open - # issue blocks mutation unless the emitted issue carries a bounded structured comparison - # proving the same API uses a distinct retention mechanism. Restore the gate from the - # read-only default branch rather than executing workflow-dispatch-selected repository code - # with the write-capable job token. + # 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') }} @@ -344,8 +344,9 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts - 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 unconditionally blocks matching `create-issue` output; do not treat the pre-agent - snapshot as the final authority. + 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. @@ -477,7 +478,9 @@ than producing same-API siblings together. De-dup each selected leak against ope 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. +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 — diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 45ecee02502a..ca075a3b8bac 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":"a66737fb5296eae7e6f6225aaa786846fb79d1cdaa17e8e1b51dddef31c152a5","body_hash":"530f847e251f321b60d4c7833f5d11c0332e3d055e5fff7b5d9daf470aadbf12","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":"fedfea0ec6a862f569e283442bc0adab3e677c8d4f11eb5c4d68206d9275cb95","body_hash":"839a4574e5568bb05b302a9259e1effb7832fafdf55faf22fb0b907866e3d970","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index b33ac86ab9dd..c762902f81c4 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -102,10 +102,10 @@ safe-outputs: # 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 mechanism decisions are incomplete/stale. - # The boundary validates live match coverage and decision structure; the retention-mechanism - # comparison itself remains an agent-authored semantic judgment, but every relied-on decision - # must also appear in a bounded structured PR-body line for human review. + # 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') }} @@ -775,15 +775,15 @@ If nothing was committed (count `0`) → `skipped: no commit produced` and stop. 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 ensure every current API-only match -has a persisted retention-mechanism decision. 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. +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 prepares semantic mechanism decisions; it is not the authoritative mutation +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`; agent-authored mechanism -overrides are not accepted. +issue-reference or same-API match blocks `create_pull_request`; no mechanism decision is +persisted or accepted as an override. ```bash set -euo pipefail From 363b1dff60541ed1c3dec025e30ad1c27d32a77f Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 11:14:08 +0200 Subject: [PATCH 55/68] Harden leak workflow remediation gates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 2 +- .../Assert-LeakHunterSafeOutputGate.ps1 | 4 +- .github/scripts/Get-CanonicalLeakApi.ps1 | 9 +- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 168 ++++++++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 70 +++++++- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 7 +- .github/workflows/leak-fixer.lock.yml | 5 +- .github/workflows/leak-fixer.md | 18 +- 9 files changed, 258 insertions(+), 29 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index 5886992395ff..913f910403a3 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -122,7 +122,7 @@ $closedAttempts = @($closed | Where-Object { $null -eq $_.mergedAt -and ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and ($referencesIssue -or - (Get-CanonicalLeakApi -Title ([string]$_.title)) -ceq $api) + (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'." diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index 2f324ee551b7..2238435bbfd0 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -111,14 +111,14 @@ foreach ($requested in $requestedItems) { $openApiMatches = @($openIssues | Where-Object { $issueTitle = [string]$_.title $issueTitle.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and - (Get-CanonicalLeakApi -Title $issueTitle) -ceq $api + (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-CanonicalLeakApi -Title ([string]$_.title)) -ceq $api + (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." diff --git a/.github/scripts/Get-CanonicalLeakApi.ps1 b/.github/scripts/Get-CanonicalLeakApi.ps1 index 27fccbfd36f7..b3b6ea9f0581 100644 --- a/.github/scripts/Get-CanonicalLeakApi.ps1 +++ b/.github/scripts/Get-CanonicalLeakApi.ps1 @@ -4,13 +4,18 @@ param( [Parameter(Mandatory = $true)] [AllowEmptyString()] - [string]$Title + [string]$Title, + [switch]$ExistingTitle ) $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force -$api = Get-CanonicalLeakApi -Title $Title +$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/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 7a20af82824f..4f44106e533e 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -202,6 +202,35 @@ Describe 'canonical leak API title parsing' { (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' { @@ -222,6 +251,22 @@ Describe 'trusted final duplicate gate' { $result.ApiMatches.number | Should -Be 100 } + 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 ` @@ -631,6 +676,44 @@ Describe 'target-scoped merged revert discovery' { -SearchLimit 2 } | Should -Throw '*Scoped merged-revert search for PR #100*2-result ceiling*' } + + It 'fails closed before querying when the seed set exceeds the aggregate budget' { + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + [pscustomobject]@{ number = 101; baseRefName = 'main' } + [pscustomobject]@{ number = 102; baseRefName = 'main' } + ) ` + -MaximumSearchQueries 2 + } | Should -Throw '*seed set exceeded the 2-query aggregate safety budget*' + + $script:discoverySearches.Count | Should -Be 0 + } + + It 'fails closed when recursive discovery exhausts the aggregate query budget' { + $script:discoveryRowsByTarget['100'] = @( + New-LeakPr -Number 200 -Title 'First revert' -Body 'Reverts #100' + ) + $script:discoveryRowsByTarget['200'] = @( + New-LeakPr -Number 300 -Title 'Second revert' -Body 'Reverts #200' + ) + + { + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @( + [pscustomobject]@{ number = 100; baseRefName = 'main' } + ) ` + -MaximumSearchQueries 2 + } | Should -Throw '*exhausted the 2-query aggregate safety budget*' + + $script:discoverySearches | Should -Be @( + 'Reverts "#100" in:body' + 'Reverts "#200" in:body' + ) + } } Describe 'workflow enforcement boundary' { @@ -717,10 +800,45 @@ Describe 'workflow enforcement boundary' { ([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 one shared aggregate revert-query budget 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 = 100' + @($wrapper, $fixGate, $hunterGate) | ForEach-Object { + $_ | Should -Match 'Get-RelevantMergedLeakReverts' + $_ | Should -Not -Match 'MaximumSearchQueries' + } + } + It 'uses target-scoped recursive revert discovery in both gates and workflows' { $module = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' @@ -861,6 +979,20 @@ Describe 'workflow enforcement boundary' { } | 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" @@ -1096,6 +1228,20 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | 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]@{ @@ -1167,6 +1313,28 @@ Same-API comparison: dotnet/maui#701 | Different mechanism: Agent-authored claim } | 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]@{ diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index c8c356b44ac5..ade65427566e 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -1,3 +1,14 @@ +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) @@ -19,13 +30,43 @@ function Get-CanonicalLeakApi { return $null } - $api = $match.Groups['api'].Value - $segments = $api.Split('.') - if ($segments.Count -eq 2 -or - $api.StartsWith('Microsoft.Maui.', [StringComparison]::Ordinal)) { - return "$($segments[-2]).$($segments[-1])" + 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 } - 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 { @@ -121,10 +162,12 @@ function Get-RelevantMergedLeakReverts { [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, 1000)][int]$MaximumDiscoveredPullRequests = 1000, + [ValidateRange(1, 1000)][int]$MaximumSearchQueries = 100 ) $queue = [System.Collections.Generic.Queue[object]]::new() + $seedNumbers = [System.Collections.Generic.HashSet[int]]::new() foreach ($target in $TargetPullRequests) { $number = 0 if (-not [int]::TryParse([string]$target.number, [ref]$number) -or $number -le 0) { @@ -134,6 +177,10 @@ function Get-RelevantMergedLeakReverts { if ([string]::IsNullOrWhiteSpace($base)) { throw "Revert-discovery target PR #$number is missing baseRefName." } + if ($seedNumbers.Add($number) -and + $seedNumbers.Count -gt $MaximumSearchQueries) { + throw "Revert-discovery seed set exceeded the $MaximumSearchQueries-query aggregate safety budget." + } $queue.Enqueue([pscustomobject]@{ number = $number baseRefName = $base @@ -145,9 +192,13 @@ function Get-RelevantMergedLeakReverts { while ($queue.Count -gt 0) { $target = $queue.Dequeue() $targetNumber = [int]$target.number - if (-not $queried.Add($targetNumber)) { + if ($queried.Contains($targetNumber)) { continue } + if ($queried.Count -ge $MaximumSearchQueries) { + throw "Relevant merged-revert discovery exhausted the $MaximumSearchQueries-query aggregate safety budget." + } + [void]$queried.Add($targetNumber) $rows = @( Invoke-LeakGhJson -Arguments @( @@ -263,7 +314,7 @@ function Get-LeakFixFinalDedupResult { } | Sort-Object number -Unique) $apiMatches = @($eligible | Where-Object { - (Get-CanonicalLeakApi -Title ([string]$_.title)) -ceq $Api + (Get-CanonicalExistingLeakApi -Title ([string]$_.title)) -ceq $Api } | Sort-Object number -Unique) $blocked = $directMatches.Count -gt 0 -or $apiMatches.Count -gt 0 @@ -411,6 +462,7 @@ function Get-EffectiveRevertedPullRequestNumbers { Export-ModuleMember -Function ` Get-CanonicalLeakApi, ` + Get-CanonicalExistingLeakApi, ` Read-RegularJsonFile, ` Test-LeakPrReferencesIssue, ` Get-LeakRevertTargets, ` diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index a13ef82fb9fc..e35d5adfbd4e 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":"46816bb3343a1d036afb588ffb26818995a36f0034f3fbbdab59c19bc389f683","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-metadata: {"schema_version":"v4","frontmatter_hash":"3cefebb0211303e1d8899cba16fa399293e23c6634e51d3c7a6d37029605c384","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 # @@ -505,7 +505,7 @@ jobs: 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\"\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\")\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 (then recursively their reverters). Each target-scoped query has\n# its own fail-closed Search API ceiling, avoiding an unrelated repository-wide cap.\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" + 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 (then recursively their reverters). Each target-scoped query has\n# its own fail-closed Search API ceiling, and the shared helper caps aggregate unique\n# target searches so the fixed job cannot be exhausted by a deep/wide chain.\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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index a975d7a08548..16c83a36e3e4 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -103,7 +103,7 @@ pre-agent-steps: 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" + pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle done \ | sort -u \ > /tmp/gh-aw/agent/already-filed-apis.txt @@ -136,7 +136,7 @@ pre-agent-steps: 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") + 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 @@ -158,7 +158,8 @@ pre-agent-steps: # independent sibling reverts never cancel each other. # Discover only same-branch merged PRs whose bodies explicitly revert one of the # relevant leak fixes (then recursively their reverters). Each target-scoped query has - # its own fail-closed Search API ceiling, avoiding an unrelated repository-wide cap. + # its own fail-closed Search API ceiling, and the shared helper caps aggregate unique + # target searches so the fixed job cannot be exhausted by a deep/wide chain. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index ca075a3b8bac..9356f222b2a4 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":"fedfea0ec6a862f569e283442bc0adab3e677c8d4f11eb5c4d68206d9275cb95","body_hash":"839a4574e5568bb05b302a9259e1effb7832fafdf55faf22fb0b907866e3d970","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":"0547dcf9859087f02f53713deb9781b804e106488f1ab5d04742848abbe53185","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 diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index c762902f81c4..53508574dc52 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 @@ -418,7 +418,7 @@ 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") +API=$(pwsh .github/scripts/Get-CanonicalLeakApi.ps1 -Title "$TITLE" -ExistingTitle) echo "target rooting API: $API" 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 @@ -472,7 +472,7 @@ jq -r '.[] | ["_", .number, .baseRefName, .title] | @tsv' \ 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") + 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 @@ -482,7 +482,8 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ # A merged fix is not authoritative if it was later effectively reverted. Discover only # same-branch merged PRs with explicit body references to the relevant leak-fix set, recursively. -# Each target-scoped query fails closed at its own Search API ceiling. +# Each target-scoped query fails closed at its own Search API ceiling, while the shared helper +# caps aggregate unique target searches so a deep/wide chain cannot exhaust the job. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/merged-leak-fix-prs.tsv \ @@ -565,7 +566,7 @@ jq -r '.[] | [.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") + 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 @@ -592,7 +593,7 @@ jq '[.[] | select(.title | startswith("[leak-fix] ")) | select(.mergedAt == null # 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") + 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" '[.[] | @@ -818,6 +819,7 @@ jq '[.[] | 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 \ @@ -846,7 +848,7 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | 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") + 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 @@ -875,7 +877,7 @@ jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | > /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") + 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 From a92c7024a4904882648f86421d1be2d6de6f62b3 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 17:46:34 +0200 Subject: [PATCH 56/68] Fix leak revert traversal budget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 36 +++++++++++++-------- .github/scripts/LeakWorkflowDedup.psm1 | 15 +++++---- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 4f44106e533e..7960af7a6844 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -677,28 +677,37 @@ Describe 'target-scoped merged revert discovery' { } | Should -Throw '*Scoped merged-revert search for PR #100*2-result ceiling*' } - It 'fails closed before querying when the seed set exceeds the aggregate budget' { - { + It 'accommodates more than 100 initial seeds and charges only recursive queries' { + $targets = @(1000..1100 | ForEach-Object { + [pscustomobject]@{ number = $_; baseRefName = 'main' } + }) + $script:discoveryRowsByTarget['1000'] = @( + New-LeakPr -Number 2000 -Title 'Relevant revert' -Body 'Reverts #1000' + ) + + $result = @( Get-RelevantMergedLeakReverts ` -Repository 'dotnet/maui' ` - -TargetPullRequests @( - [pscustomobject]@{ number = 100; baseRefName = 'main' } - [pscustomobject]@{ number = 101; baseRefName = 'main' } - [pscustomobject]@{ number = 102; baseRefName = 'main' } - ) ` - -MaximumSearchQueries 2 - } | Should -Throw '*seed set exceeded the 2-query aggregate safety budget*' + -TargetPullRequests $targets ` + -MaximumSearchQueries 1 + ) - $script:discoverySearches.Count | Should -Be 0 + $result.number | Should -Be 2000 + $script:discoverySearches.Count | Should -Be 102 + $script:discoverySearches[0] | Should -Be 'Reverts "#1000" in:body' + $script:discoverySearches[-1] | Should -Be 'Reverts "#2000" in:body' } - It 'fails closed when recursive discovery exhausts the aggregate query budget' { + It 'fails closed when recursive discovery exhausts its query budget' { $script:discoveryRowsByTarget['100'] = @( New-LeakPr -Number 200 -Title 'First revert' -Body 'Reverts #100' ) $script:discoveryRowsByTarget['200'] = @( New-LeakPr -Number 300 -Title 'Second revert' -Body 'Reverts #200' ) + $script:discoveryRowsByTarget['300'] = @( + New-LeakPr -Number 400 -Title 'Third revert' -Body 'Reverts #300' + ) { Get-RelevantMergedLeakReverts ` @@ -707,11 +716,12 @@ Describe 'target-scoped merged revert discovery' { [pscustomobject]@{ number = 100; baseRefName = 'main' } ) ` -MaximumSearchQueries 2 - } | Should -Throw '*exhausted the 2-query aggregate safety budget*' + } | Should -Throw '*exhausted the 2-query recursive safety budget*' $script:discoverySearches | Should -Be @( 'Reverts "#100" in:body' 'Reverts "#200" in:body' + 'Reverts "#300" in:body' ) } } @@ -818,7 +828,7 @@ Describe 'workflow enforcement boundary' { $fixer | Should -Match '(?m)^ bash: \[[^\r\n]*"pwsh"\]$' } - It 'defines one shared aggregate revert-query budget for every caller' { + It 'defines one shared recursive revert-query budget for every caller' { $module = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' ) -Raw diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index ade65427566e..202afdd2b096 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -177,10 +177,7 @@ function Get-RelevantMergedLeakReverts { if ([string]::IsNullOrWhiteSpace($base)) { throw "Revert-discovery target PR #$number is missing baseRefName." } - if ($seedNumbers.Add($number) -and - $seedNumbers.Count -gt $MaximumSearchQueries) { - throw "Revert-discovery seed set exceeded the $MaximumSearchQueries-query aggregate safety budget." - } + [void]$seedNumbers.Add($number) $queue.Enqueue([pscustomobject]@{ number = $number baseRefName = $base @@ -188,6 +185,7 @@ function Get-RelevantMergedLeakReverts { } $queried = [System.Collections.Generic.HashSet[int]]::new() + $recursiveQueryCount = 0 $discovered = @{} while ($queue.Count -gt 0) { $target = $queue.Dequeue() @@ -195,8 +193,13 @@ function Get-RelevantMergedLeakReverts { if ($queried.Contains($targetNumber)) { continue } - if ($queried.Count -ge $MaximumSearchQueries) { - throw "Relevant merged-revert discovery exhausted the $MaximumSearchQueries-query aggregate safety budget." + # Initial merged-fix history is bounded by its upstream Search API ceiling. + # Reserve this budget for the additional queries introduced by recursive discovery. + if (-not $seedNumbers.Contains($targetNumber)) { + if ($recursiveQueryCount -ge $MaximumSearchQueries) { + throw "Relevant merged-revert discovery exhausted the $MaximumSearchQueries-query recursive safety budget." + } + $recursiveQueryCount++ } [void]$queried.Add($targetNumber) From a40b43d940894dd00d4fc25ab0c584357b8eaafa Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 18:52:28 +0200 Subject: [PATCH 57/68] Harden leak revert discovery Retry only clearly transient gh failures and replace per-seed revert searches with bounded branch snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 294 ++++++++++++++++---- .github/scripts/LeakWorkflowDedup.psm1 | 172 ++++++++---- 2 files changed, 370 insertions(+), 96 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 7960af7a6844..2749c89df5b2 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -39,6 +39,116 @@ Describe 'native gh invocation' { $invokeIndex | Should -BeGreaterOrEqual 0 $preferenceIndex | Should -BeLessThan $invokeIndex } + + Context 'bounded transient retries' { + BeforeEach { + $global:leakGhAttemptCount = 0 + $global:leakGhResponses = [System.Collections.Generic.Queue[object]]::new() + 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 ` + -Scope Global -ErrorAction SilentlyContinue + } + + It 'retries a clearly transient failure and returns the later valid JSON' { + $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 0 + + $result.value | Should -Be 42 + $global:leakGhAttemptCount | Should -Be 2 + } + + 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 0 + } | Should -Throw '*failed with exit code 1 after 3 attempt(s)*' + + $global:leakGhAttemptCount | Should -Be 3 + } + + 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 + } | 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 + } | 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 + } | Should -Throw '*returned invalid JSON*' + $global:leakGhAttemptCount | Should -Be 2 + } + } } Describe 'shared regular JSON file validation' { @@ -251,6 +361,28 @@ Describe 'trusted final duplicate gate' { $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 ` @@ -603,19 +735,23 @@ Describe 'effective recursive revert state' { } } -Describe 'target-scoped merged revert discovery' { +Describe 'branch-scoped merged revert discovery' { BeforeEach { - $script:discoverySearches = [System.Collections.Generic.List[string]]::new() - $script:discoveryRowsByTarget = @{} + $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] - $script:discoverySearches.Add($search) - $target = [regex]::Match($search, '#(?[1-9][0-9]*)').Groups['number'].Value - $rows = if ($script:discoveryRowsByTarget.ContainsKey($target)) { - @($script:discoveryRowsByTarget[$target]) + $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 { @() } @@ -628,7 +764,7 @@ Describe 'target-scoped merged revert discovery' { } It 'recursively discovers only explicit same-branch reverters of the target set' { - $script:discoveryRowsByTarget['100'] = @( + $script:discoveryRowsByBase['main'] = @( New-LeakPr ` -Number 200 ` -Title 'Back out cleanup without Revert in the title' ` @@ -636,9 +772,16 @@ Describe 'target-scoped merged revert discovery' { New-LeakPr ` -Number 201 ` -Title 'Unrelated mention' ` - -Body 'Discusses #100 but does not revert it' - ) - $script:discoveryRowsByTarget['200'] = @( + -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' ` @@ -654,15 +797,13 @@ Describe 'target-scoped merged revert discovery' { ) $result.number | Should -Be @(200, 300) - $script:discoverySearches | Should -Be @( - 'Reverts "#100" in:body' - 'Reverts "#200" in:body' - 'Reverts "#300" in:body' - ) + $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 target-scoped query reaches its result ceiling' { - $script:discoveryRowsByTarget['100'] = @( + 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' ) @@ -674,15 +815,33 @@ Describe 'target-scoped merged revert discovery' { [pscustomobject]@{ number = 100; baseRefName = 'main' } ) ` -SearchLimit 2 - } | Should -Throw '*Scoped merged-revert search for PR #100*2-result ceiling*' + } | Should -Throw "*Branch-scoped merged-revert search for 'main'*2-result ceiling*" } - It 'accommodates more than 100 initial seeds and charges only recursive queries' { + 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:discoveryRowsByTarget['1000'] = @( + $script:discoveryRowsByBase['main'] = @( New-LeakPr -Number 2000 -Title 'Relevant revert' -Body 'Reverts #1000' + New-LeakPr -Number 2001 -Title 'Recursive revert' -Body 'Reverts #2000' ) $result = @( @@ -692,20 +851,46 @@ Describe 'target-scoped merged revert discovery' { -MaximumSearchQueries 1 ) - $result.number | Should -Be 2000 - $script:discoverySearches.Count | Should -Be 102 - $script:discoverySearches[0] | Should -Be 'Reverts "#1000" in:body' - $script:discoverySearches[-1] | Should -Be 'Reverts "#2000" in:body' + $result.number | Should -Be @(2000, 2001) + $script:discoveryCalls.Count | Should -Be 1 } - It 'fails closed when recursive discovery exhausts its query budget' { - $script:discoveryRowsByTarget['100'] = @( + 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' - ) - $script:discoveryRowsByTarget['200'] = @( New-LeakPr -Number 300 -Title 'Second revert' -Body 'Reverts #200' - ) - $script:discoveryRowsByTarget['300'] = @( New-LeakPr -Number 400 -Title 'Third revert' -Body 'Reverts #300' ) @@ -715,14 +900,10 @@ Describe 'target-scoped merged revert discovery' { -TargetPullRequests @( [pscustomobject]@{ number = 100; baseRefName = 'main' } ) ` - -MaximumSearchQueries 2 - } | Should -Throw '*exhausted the 2-query recursive safety budget*' + -MaximumTraversalPullRequests 3 + } | Should -Throw '*exhausted the 3-PR aggregate traversal safety budget*' - $script:discoverySearches | Should -Be @( - 'Reverts "#100" in:body' - 'Reverts "#200" in:body' - 'Reverts "#300" in:body' - ) + $script:discoveryCalls.Count | Should -Be 1 } } @@ -828,7 +1009,7 @@ Describe 'workflow enforcement boundary' { $fixer | Should -Match '(?m)^ bash: \[[^\r\n]*"pwsh"\]$' } - It 'defines one shared recursive revert-query budget for every caller' { + It 'defines shared rate-aware query and aggregate traversal budgets for every caller' { $module = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' ) -Raw @@ -842,14 +1023,16 @@ Describe 'workflow enforcement boundary' { Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' ) -Raw - $module | Should -Match '\$MaximumSearchQueries = 100' + $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 target-scoped recursive revert discovery in both gates and workflows' { + It 'uses constant-size branch snapshots with exact local revert verification' { $module = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' ) -Raw @@ -866,14 +1049,14 @@ Describe 'workflow enforcement boundary' { Join-Path $PSScriptRoot '../workflows/leak-fixer.md' ) -Raw - $module | Should -Match 'Reverts.*#\$\{targetNumber\}.*in:body' + $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' - @($module, $fixGate, $hunterGate, $hunter, $fixer) | ForEach-Object { - $_ | Should -Not -Match "Reverts in:body|'Reverts in:body'" - } } Context 'safe-output gate script' { @@ -920,7 +1103,7 @@ Describe 'workflow enforcement boundary' { $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] if ($state -eq 'merged') { - if ($search -match '^Reverts "#[1-9][0-9]*" in:body$') { + 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) @@ -1201,7 +1384,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } $searchIndex = [Array]::IndexOf($GhArgs, '--search') $search = $GhArgs[$searchIndex + 1] - if ($search -match '^Reverts "#[1-9][0-9]*" in:body$') { + 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) @@ -1269,6 +1452,23 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | 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 ` diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 202afdd2b096..153f6448c7da 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -118,22 +118,43 @@ function Get-LeakRevertTargets { Sort-Object -Unique) } +function Test-IsTransientLeakGhFailure { + param([AllowEmptyString()][string]$Detail) + + return [bool]($Detail -match '(?i)(?:\b(?:primary |secondary )?rate limit\b|\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 Invoke-LeakGhJson { - param([Parameter(Mandatory = $true)][string[]]$Arguments) + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [ValidateRange(1, 5)][int]$MaximumAttempts = 3, + [ValidateRange(0, 60)][int]$RetryBaseDelaySeconds = 2 + ) # Preserve structured exit-code handling if a future host flips the native-command default. $PSNativeCommandUseErrorActionPreference = $false - $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 + 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)" + } + } - if ($exitCode -ne 0) { $detail = (@($stderr, $stdout) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' ' $detail = ($detail -replace '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '?' ` @@ -141,20 +162,27 @@ function Invoke-LeakGhJson { if ($detail.Length -gt 2000) { $detail = "$($detail.Substring(0, 2000))..." } - $message = "'gh $($Arguments -join ' ')' failed with exit code $exitCode." + $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) { + $delaySeconds = [int]( + $RetryBaseDelaySeconds * [Math]::Pow(2, $attempt - 1) + ) + Write-Warning "$message Retrying in $delaySeconds second(s)." + if ($delaySeconds -gt 0) { + Start-Sleep -Seconds $delaySeconds + } + continue + } + throw $message } - 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)" - } + + throw "'gh $($Arguments -join ' ')' exhausted its retry budget unexpectedly." } function Get-RelevantMergedLeakReverts { @@ -163,11 +191,15 @@ function Get-RelevantMergedLeakReverts { [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$TargetPullRequests, [ValidateRange(1, 1000)][int]$SearchLimit = 1000, [ValidateRange(1, 1000)][int]$MaximumDiscoveredPullRequests = 1000, - [ValidateRange(1, 1000)][int]$MaximumSearchQueries = 100 + [ValidateRange(1, 2)][int]$MaximumSearchQueries = 2, + [ValidateRange(1, 2000)][int]$MaximumTraversalPullRequests = 2000 ) $queue = [System.Collections.Generic.Queue[object]]::new() - $seedNumbers = [System.Collections.Generic.HashSet[int]]::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) { @@ -177,65 +209,107 @@ function Get-RelevantMergedLeakReverts { if ([string]::IsNullOrWhiteSpace($base)) { throw "Revert-discovery target PR #$number is missing baseRefName." } - [void]$seedNumbers.Add($number) + 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 }) } - $queried = [System.Collections.Generic.HashSet[int]]::new() - $recursiveQueryCount = 0 - $discovered = @{} - while ($queue.Count -gt 0) { - $target = $queue.Dequeue() - $targetNumber = [int]$target.number - if ($queried.Contains($targetNumber)) { - continue - } - # Initial merged-fix history is bounded by its upstream Search API ceiling. - # Reserve this budget for the additional queries introduced by recursive discovery. - if (-not $seedNumbers.Contains($targetNumber)) { - if ($recursiveQueryCount -ge $MaximumSearchQueries) { - throw "Relevant merged-revert discovery exhausted the $MaximumSearchQueries-query recursive safety budget." - } - $recursiveQueryCount++ + 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." } - [void]$queried.Add($targetNumber) $rows = @( Invoke-LeakGhJson -Arguments @( 'pr', 'list', '--repo', $Repository, '--state', 'merged', + '--base', $base, '--limit', [string]$SearchLimit, - '--search', "Reverts `"#${targetNumber}`" in:body", + '--search', $searchQuery, '--json', 'number,title,body,baseRefName,mergedAt' ) ) if ($rows.Count -ge $SearchLimit) { - throw "Scoped merged-revert search for PR #$targetNumber returned $($rows.Count) rows at its $SearchLimit-result ceiling." + 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 - $targetNumber -notin @( + [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 - ) -or - [string]$row.baseRefName -cne [string]$target.baseRefName) { - continue + )) { + if (-not $revertersByTarget.ContainsKey($targetNumber)) { + $revertersByTarget[$targetNumber] = + [System.Collections.Generic.List[object]]::new() + } + $revertersByTarget[$targetNumber].Add($row) } + } + } - $reverter = 0 - if (-not [int]::TryParse([string]$row.number, [ref]$reverter) -or $reverter -le 0) { - throw "Invalid discovered merged-revert PR number '$($row.number)'." + $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 From c0153c94b5acb0984e37f9a90b7a9b97d7ae50a4 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 20:07:45 +0200 Subject: [PATCH 58/68] Scope leak fix attempt cap to authoritative lanes Validate live base-branch metadata and aggregate closed attempts only across main and inflight/current in both the workflow prompt and trusted mutation gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 21 +++- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 97 ++++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 89 +++++++++++++++-- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 18 +++- 5 files changed, 211 insertions(+), 16 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index 913f910403a3..b71503d14c2b 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -76,10 +76,14 @@ 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." } -$eligibleMerged = @($merged | Where-Object { +$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) -and - [string]$_.baseRefName -in @('main', 'inflight/current') + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) }) $mergedReverts = @( Get-RelevantMergedLeakReverts ` @@ -108,13 +112,20 @@ $closed = @( '--state', 'closed', '--limit', '1000', '--search', '"[leak-fix]" in:title', - '--json', 'number,title,body,mergedAt' + '--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." } -$closedAttempts = @($closed | Where-Object { +$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 ` diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 2749c89df5b2..55e68241f86d 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -208,6 +208,44 @@ Describe 'shared regular JSON file validation' { } } +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]@{ @@ -940,6 +978,23 @@ Describe 'workflow enforcement boundary' { ($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 '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 @@ -1292,14 +1347,16 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | Should -Throw '*blocked PR creation*same-API match: 501*' } - It 'blocks a fourth closed-unmerged attempt for the same issue or API' { + 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' -Merged $false + -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') ` @@ -1309,6 +1366,42 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | 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 ` diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 153f6448c7da..c8120533860a 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -90,6 +90,74 @@ function Read-RegularJsonFile { } } +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, @@ -359,10 +427,14 @@ function Get-LeakFixFinalDedupResult { [AllowEmptyCollection()][object[]]$MergedRevertPullRequests = @() ) - $eligibleMerged = @($MergedPullRequests | Where-Object { + $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) -and - [string]$_.baseRefName -in @('main', 'inflight/current') + ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) }) $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` @@ -377,9 +449,13 @@ function Get-LeakFixFinalDedupResult { $eligibleMerged = @($eligibleMerged | Where-Object { -not $reverted.Contains([int]$_.number) }) - $eligibleOpen = @($OpenPullRequests | Where-Object { - ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) -and - [string]$_.baseRefName -in @('main', 'inflight/current') + $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) @@ -541,6 +617,7 @@ Export-ModuleMember -Function ` Get-CanonicalLeakApi, ` Get-CanonicalExistingLeakApi, ` Read-RegularJsonFile, ` + Select-LeakAuthoritativePullRequests, ` Test-LeakPrReferencesIssue, ` Get-LeakRevertTargets, ` Invoke-LeakGhJson, ` diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 9356f222b2a4..6447c3066914 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":"904f5e7198714e63042518c4e15989be57d04d917590f91e1f831e0795018cb9","body_hash":"0547dcf9859087f02f53713deb9781b804e106488f1ab5d04742848abbe53185","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":"0474961efb04c6642698f23bfccaf4df662b42bb38c9a0dbb3831a6e24a9f7fc","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 53508574dc52..8316591d61f0 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -578,13 +578,27 @@ jq -r '.[] | "same-API open fix PR: #\(.number) \(.title)"' /tmp/gh-aw/agent/sam # 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,mergedAt \ + --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-raw.json \ + /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 From cdac18a8a6aa2992d47735a281e61dc4f06cf0b5 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 20:34:11 +0200 Subject: [PATCH 59/68] Honor server-directed leak retry timing Parse and cap Retry-After and rate-limit reset delays for transient gh failures, with deterministic coverage and corrected snapshot traversal documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 145 ++++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 125 +++++++++++++++- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 11 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 12 +- 6 files changed, 275 insertions(+), 24 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 55e68241f86d..0dc6ed9b2edc 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -44,6 +44,8 @@ Describe 'native gh invocation' { 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++ @@ -58,11 +60,11 @@ Describe 'native gh invocation' { AfterEach { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable leakGhAttemptCount, leakGhResponses ` + Remove-Variable leakGhAttemptCount, leakGhResponses, leakGhDelays, leakGhNow ` -Scope Global -ErrorAction SilentlyContinue } - It 'retries a clearly transient failure and returns the later valid JSON' { + It 'uses the exponential fallback for transient failures without server timing' { $global:leakGhResponses.Enqueue([pscustomobject]@{ ExitCode = 1 Stderr = 'HTTP 503: Service Unavailable' @@ -76,10 +78,117 @@ Describe 'native gh invocation' { $result = Invoke-LeakGhJson ` -Arguments @('api', 'test') ` - -RetryBaseDelaySeconds 0 + -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' { @@ -95,10 +204,16 @@ Describe 'native gh invocation' { Invoke-LeakGhJson ` -Arguments @('api', 'test') ` -MaximumAttempts 3 ` - -RetryBaseDelaySeconds 0 + -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' { @@ -116,7 +231,8 @@ Describe 'native gh invocation' { { Invoke-LeakGhJson ` -Arguments @('api', 'test') ` - -RetryBaseDelaySeconds 0 + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } } | Should -Throw '*failed with exit code 1 after 1 attempt(s)*' $global:leakGhAttemptCount | Should -Be 1 @@ -132,7 +248,8 @@ Describe 'native gh invocation' { { Invoke-LeakGhJson ` -Arguments @('api', 'empty') ` - -RetryBaseDelaySeconds 0 + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } } | Should -Throw '*returned an empty response*' $global:leakGhAttemptCount | Should -Be 1 @@ -144,7 +261,8 @@ Describe 'native gh invocation' { { Invoke-LeakGhJson ` -Arguments @('api', 'invalid') ` - -RetryBaseDelaySeconds 0 + -RetryBaseDelaySeconds 0 ` + -UtcNowProvider { $global:leakGhNow } } | Should -Throw '*returned invalid JSON*' $global:leakGhAttemptCount | Should -Be 2 } @@ -1112,6 +1230,19 @@ Describe 'workflow enforcement boundary' { $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' { diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index c8120533860a..70e1bfb01d88 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -189,14 +189,106 @@ function Get-LeakRevertTargets { function Test-IsTransientLeakGhFailure { param([AllowEmptyString()][string]$Detail) - return [bool]($Detail -match '(?i)(?:\b(?:primary |secondary )?rate limit\b|\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)') + 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, 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. @@ -237,12 +329,31 @@ function Invoke-LeakGhJson { if ((Test-IsTransientLeakGhFailure -Detail $detail) -and $attempt -lt $MaximumAttempts) { - $delaySeconds = [int]( - $RetryBaseDelaySeconds * [Math]::Pow(2, $attempt - 1) - ) - Write-Warning "$message Retrying in $delaySeconds second(s)." + 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) { - Start-Sleep -Seconds $delaySeconds + & $DelayAction $delaySeconds } continue } diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index e35d5adfbd4e..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":"3cefebb0211303e1d8899cba16fa399293e23c6634e51d3c7a6d37029605c384","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-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 # @@ -505,7 +505,7 @@ jobs: 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 (then recursively their reverters). Each target-scoped query has\n# its own fail-closed Search API ceiling, and the shared helper caps aggregate unique\n# target searches so the fixed job cannot be exhausted by a deep/wide chain.\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" + 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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 16c83a36e3e4..3d15cf24fa4b 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -157,9 +157,14 @@ pre-agent-steps: # 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 (then recursively their reverters). Each target-scoped query has - # its own fail-closed Search API ceiling, and the shared helper caps aggregate unique - # target searches so the fixed job cannot be exhausted by a deep/wide chain. + # 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 \ diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index 6447c3066914..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":"904f5e7198714e63042518c4e15989be57d04d917590f91e1f831e0795018cb9","body_hash":"0474961efb04c6642698f23bfccaf4df662b42bb38c9a0dbb3831a6e24a9f7fc","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 8316591d61f0..bfba6cfa95a6 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -480,10 +480,14 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ | sort -u \ > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv -# A merged fix is not authoritative if it was later effectively reverted. Discover only -# same-branch merged PRs with explicit body references to the relevant leak-fix set, recursively. -# Each target-scoped query fails closed at its own Search API ceiling, while the shared helper -# caps aggregate unique target searches so a deep/wide chain cannot exhaust the job. +# 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 \ From 9dc16fd6dd34f6b068e0be55918ddb2ce90bb227 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 20:43:53 +0200 Subject: [PATCH 60/68] Harden leak hunter branch validation Use the shared authoritative PR selector in the hunter safe-output gate so malformed base metadata fails closed before issue mutation. Add parity and release-lane regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Assert-LeakHunterSafeOutputGate.ps1 | 10 ++- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index 2238435bbfd0..24beb469d6a7 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -80,10 +80,14 @@ 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." } -$eligibleMerged = @($merged | Where-Object { +$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) -and - [string]$_.baseRefName -in @('main', 'inflight/current') + ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) }) $mergedReverts = @( Get-RelevantMergedLeakReverts ` diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 0dc6ed9b2edc..49d5e2b36107 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -1113,6 +1113,28 @@ Describe 'workflow enforcement boundary' { } } + 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 @@ -1707,6 +1729,62 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | 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 ` From d91cf3cc96d8aac641234929b373139c5156e446 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 22:17:01 +0200 Subject: [PATCH 61/68] Harden leak de-dup history pagination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 53 +- .../Assert-LeakHunterSafeOutputGate.ps1 | 38 +- .github/scripts/Get-CompleteLeakIssues.ps1 | 14 + .../scripts/Get-CompleteLeakPullRequests.ps1 | 21 + .../scripts/Get-RelevantMergedLeakReverts.ps1 | 9 +- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 687 ++++++++++++++---- .github/scripts/LeakWorkflowDedup.psm1 | 531 ++++++++++++-- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 62 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 113 +-- 11 files changed, 1135 insertions(+), 399 deletions(-) create mode 100644 .github/scripts/Get-CompleteLeakIssues.ps1 create mode 100644 .github/scripts/Get-CompleteLeakPullRequests.ps1 diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index b71503d14c2b..b39567e1f100 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -29,8 +29,8 @@ if ($createItems.Count -ne 1) { $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." +if (-not (Test-LeakTitlePrefix -Title $title -Kind Fix)) { + throw "The create-pull-request title must start with '[leak-fix]' followed by a space or tab." } $api = Get-CanonicalLeakApi -Title $title @@ -63,18 +63,10 @@ Assert-LeakDedupState ` -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' - ) + Get-CompleteLeakPullRequests ` + -Repository $Repository ` + -State MERGED ) -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 ` @@ -83,41 +75,26 @@ $authoritativeMerged = @( ) $eligibleMerged = @($authoritativeMerged | Where-Object { $null -ne $_.mergedAt -and - ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) + (Test-LeakTitlePrefix -Title ([string]$_.title) -Kind Fix) }) $mergedReverts = @( Get-RelevantMergedLeakReverts ` -Repository $Repository ` - -TargetPullRequests $eligibleMerged + -TargetPullRequests $eligibleMerged ` + -MergedPullRequests $merged ) $open = @( - Invoke-LeakGhJson -Arguments @( - 'pr', 'list', - '--repo', $Repository, - '--state', 'open', - '--limit', '1000', - '--search', '"[leak-fix]" in:title', - '--json', 'number,title,body,baseRefName,mergedAt,url' - ) + Get-CompleteLeakPullRequests ` + -Repository $Repository ` + -State OPEN ) -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' - ) + Get-CompleteLeakPullRequests ` + -Repository $Repository ` + -State CLOSED ) -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 ` @@ -131,7 +108,7 @@ $closedAttempts = @($authoritativeClosed | Where-Object { -IssueNumber $issueNumber ` -Repository $Repository $null -eq $_.mergedAt -and - ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) -and + (Test-LeakTitlePrefix -Title ([string]$_.title) -Kind Fix) -and ($referencesIssue -or (Get-CanonicalExistingLeakApi -Title ([string]$_.title)) -ceq $api) } | Sort-Object number -Unique) diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index 24beb469d6a7..ff03da53e618 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -32,8 +32,8 @@ $requestedApis = [System.Collections.Generic.HashSet[string]]::new( ) 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." + if (-not (Test-LeakTitlePrefix -Title $title -Kind Scan)) { + throw "Every create-issue title must start with '[leak-scan]' followed by a space or tab." } $api = Get-CanonicalLeakApi -Title $title if ([string]::IsNullOrWhiteSpace($api)) { @@ -52,33 +52,14 @@ foreach ($item in $createItems) { } $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' - ) + Get-CompleteLeakIssues -Repository $Repository ) -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' - ) + Get-CompleteLeakPullRequests ` + -Repository $Repository ` + -State MERGED ) -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 ` @@ -87,12 +68,13 @@ $authoritativeMerged = @( ) $eligibleMerged = @($authoritativeMerged | Where-Object { $null -ne $_.mergedAt -and - ([string]$_.title).StartsWith('[leak-fix] ', [StringComparison]::Ordinal) + (Test-LeakTitlePrefix -Title ([string]$_.title) -Kind Fix) }) $mergedReverts = @( Get-RelevantMergedLeakReverts ` -Repository $Repository ` - -TargetPullRequests $eligibleMerged + -TargetPullRequests $eligibleMerged ` + -MergedPullRequests $merged ) $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` @@ -114,7 +96,7 @@ foreach ($requested in $requestedItems) { $api = [string]$requested.Api $openApiMatches = @($openIssues | Where-Object { $issueTitle = [string]$_.title - $issueTitle.StartsWith('[leak-scan] ', [StringComparison]::Ordinal) -and + (Test-LeakTitlePrefix -Title $issueTitle -Kind Scan) -and (Get-CanonicalExistingLeakApi -Title $issueTitle) -ceq $api }) if ($openApiMatches.Count -gt 0) { diff --git a/.github/scripts/Get-CompleteLeakIssues.ps1 b/.github/scripts/Get-CompleteLeakIssues.ps1 new file mode 100644 index 000000000000..55b3e42f12fc --- /dev/null +++ b/.github/scripts/Get-CompleteLeakIssues.ps1 @@ -0,0 +1,14 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$issues = @(Get-CompleteLeakIssues -Repository $Repository) +ConvertTo-Json -InputObject $issues -Depth 5 | + Set-Content -LiteralPath $OutputPath diff --git a/.github/scripts/Get-CompleteLeakPullRequests.ps1 b/.github/scripts/Get-CompleteLeakPullRequests.ps1 new file mode 100644 index 000000000000..16ecfbafdf89 --- /dev/null +++ b/.github/scripts/Get-CompleteLeakPullRequests.ps1 @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)] + [ValidateSet('OPEN', 'CLOSED', 'MERGED')] + [string]$State, + [Parameter(Mandatory = $true)][string]$OutputPath +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1') -Force + +$pullRequests = @( + Get-CompleteLeakPullRequests ` + -Repository $Repository ` + -State $State +) +ConvertTo-Json -InputObject $pullRequests -Depth 5 | + Set-Content -LiteralPath $OutputPath diff --git a/.github/scripts/Get-RelevantMergedLeakReverts.ps1 b/.github/scripts/Get-RelevantMergedLeakReverts.ps1 index 70d4dc3b7a20..7245d7034650 100644 --- a/.github/scripts/Get-RelevantMergedLeakReverts.ps1 +++ b/.github/scripts/Get-RelevantMergedLeakReverts.ps1 @@ -4,6 +4,7 @@ param( [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][string]$MergedFixTsvPath, + [Parameter(Mandatory = $true)][string]$MergedPullRequestsJsonPath, [Parameter(Mandatory = $true)][string]$OutputPath ) @@ -27,10 +28,16 @@ $fixPullRequests = @( } ) +$mergedPullRequests = @( + Read-RegularJsonFile ` + -Path $MergedPullRequestsJsonPath ` + -MaximumBytes 128MB +) $reverts = @( Get-RelevantMergedLeakReverts ` -Repository $Repository ` - -TargetPullRequests $fixPullRequests + -TargetPullRequests $fixPullRequests ` + -MergedPullRequests $mergedPullRequests ) ConvertTo-Json -InputObject @($reverts) -Depth 5 | Set-Content -LiteralPath $OutputPath diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index 49d5e2b36107..f0922d9a5fbd 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -18,10 +18,60 @@ BeforeAll { title = $Title body = $Body baseRefName = $Base + state = if ($Merged) { 'MERGED' } else { 'CLOSED' } mergedAt = if ($Merged) { '2026-08-10T00:00:00Z' } else { $null } url = "https://github.com/dotnet/maui/pull/$Number" } } + + function New-LeakGraphQlPageJson { + param( + [Parameter(Mandatory = $true)][string]$ConnectionName, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Nodes, + [Parameter(Mandatory = $true)][long]$TotalCount, + [Parameter(Mandatory = $true)][bool]$HasNextPage, + [AllowNull()][string]$EndCursor + ) + + $repository = @{} + $repository[$ConnectionName] = @{ + totalCount = $TotalCount + nodes = @($Nodes) + pageInfo = @{ + hasNextPage = $HasNextPage + endCursor = $EndCursor + } + } + @{ + data = @{ + repository = $repository + } + } | ConvertTo-Json -Depth 8 -Compress + } + + function New-LeakGraphQlPullRequestNode { + param( + [Parameter(Mandatory = $true)][int]$Number, + [string]$Base = 'main', + [ValidateSet('OPEN', 'CLOSED', 'MERGED')][string]$State = 'MERGED', + [string]$Title = "[leak-fix] Fix Type$Number.Member leak", + [string]$Body = '' + ) + + [pscustomobject]@{ + number = $Number + title = $Title + body = $Body + baseRefName = $Base + state = $State + mergedAt = if ($State -ceq 'MERGED') { + '2026-08-10T00:00:00Z' + } else { + $null + } + url = "https://github.com/dotnet/maui/pull/$Number" + } + } } Describe 'native gh invocation' { @@ -415,6 +465,57 @@ Describe 'canonical leak API title parsing' { Should -Be 'Picker.ItemsSource' } + It 'accepts tabs wherever the title grammar permits prefix whitespace' { + Get-CanonicalLeakApi ` + -Title "[leak-scan]`tMicrosoft.Maui.Controls.Picker.ItemsSource — retention" | + Should -Be 'Picker.ItemsSource' + Get-CanonicalLeakApi ` + -Title "[leak-fix]`tFix`tPicker.ItemsSource memory leak" | + Should -Be 'Picker.ItemsSource' + Get-CanonicalExistingLeakApi ` + -Title "[leak-fix]`tFix`tShell`tBackButtonBehavior.Command leak" | + Should -Be 'BackButtonBehavior.Command' + } + + It 'rejects missing-whitespace and near-prefix variants' { + @( + '[leak-scan]Picker.ItemsSource retention' + '[leak-scan]x Picker.ItemsSource retention' + '[leak-scanx] Picker.ItemsSource retention' + '[leak-fix]Fix Picker.ItemsSource retention' + '[leak-fix]x Fix Picker.ItemsSource retention' + '[leak-fixx] Fix Picker.ItemsSource retention' + ) | ForEach-Object { + (Get-CanonicalLeakApi -Title $_) | Should -BeNullOrEmpty + (Get-CanonicalExistingLeakApi -Title $_) | Should -BeNullOrEmpty + } + } + + It 'keeps short canonical-helper inputs safe without changing valid semantics' { + $module = Get-Module LeakWorkflowDedup + + (& $module { + ConvertTo-CanonicalLeakApi -Api 'Picker' + }) | Should -Be 'Picker' + (& $module { + ConvertTo-CanonicalLeakApi -Api '.' + }) | Should -Be '.' + } + + It 'returns no API for empty, whitespace-only, or malformed public title inputs' { + @( + '' + ' ' + '[leak-scan]' + '[leak-fix]' + '[leak-fix] Fix Picker' + '[leak-scan] .ItemsSource' + ) | ForEach-Object { + (Get-CanonicalLeakApi -Title $_) | Should -BeNullOrEmpty + (Get-CanonicalExistingLeakApi -Title $_) | Should -BeNullOrEmpty + } + } + It 'accepts supported punctuation immediately after the anchored API' { @( '[leak-fix] Fix Picker.ItemsSource, clear stale subscriptions' @@ -891,36 +992,241 @@ Describe 'effective recursive revert state' { } } -Describe 'branch-scoped merged revert discovery' { +Describe 'complete GraphQL pagination' { BeforeEach { - $script:discoveryCalls = [System.Collections.Generic.List[object]]::new() - $script:discoveryRowsByBase = @{} + $global:leakPaginationCalls = [System.Collections.Generic.List[object]]::new() + $global:leakPaginationResponses = + [System.Collections.Generic.Queue[string]]::new() function global:gh { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:leakPaginationCalls.Add(@($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 { - @() + if ($global:leakPaginationResponses.Count -eq 0) { + throw 'No mock GraphQL response remains.' } - Write-Output (ConvertTo-Json -InputObject $rows -Depth 5) + Write-Output $global:leakPaginationResponses.Dequeue() } } AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable leakPaginationCalls, leakPaginationResponses ` + -Scope Global -ErrorAction SilentlyContinue } + It 'retrieves more than 1000 historical PRs without a Search ceiling' { + $nodes = @(1..1001 | ForEach-Object { + New-LeakGraphQlPullRequestNode -Number $_ + }) + for ($offset = 0; $offset -lt $nodes.Count; $offset += 100) { + $last = [Math]::Min($offset + 99, $nodes.Count - 1) + $pageNodes = @($nodes[$offset..$last]) + $hasNextPage = $last -lt ($nodes.Count - 1) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes $pageNodes ` + -TotalCount $nodes.Count ` + -HasNextPage $hasNextPage ` + -EndCursor $(if ($hasNextPage) { "cursor-$last" } else { $null })) + ) + } + + $result = @( + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + ) + + $result.Count | Should -Be 1001 + $result[0].number | Should -Be 1 + $result[-1].number | Should -Be 1001 + $global:leakPaginationCalls.Count | Should -Be 11 + ($global:leakPaginationCalls | ForEach-Object { $_ -join ' ' }) | + Should -Not -Match '(?i)\bsearch\b|--limit' + } + + It 'follows multiple issue pages and keeps the label scope in the query' { + $first = @( + [pscustomobject]@{ + number = 10 + title = '[leak-scan] First.Api — leak' + body = '' + url = 'https://github.com/dotnet/maui/issues/10' + } + [pscustomobject]@{ + number = 11 + title = '[leak-scan] Second.Api — leak' + body = '' + url = 'https://github.com/dotnet/maui/issues/11' + } + ) + $second = @( + [pscustomobject]@{ + number = 12 + title = '[leak-scan] Third.Api — leak' + body = '' + url = 'https://github.com/dotnet/maui/issues/12' + } + ) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName issues ` + -Nodes $first ` + -TotalCount 3 ` + -HasNextPage $true ` + -EndCursor issue-cursor) + ) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName issues ` + -Nodes $second ` + -TotalCount 3 ` + -HasNextPage $false) + ) + + $result = @( + Get-CompleteLeakIssues ` + -Repository 'dotnet/maui' ` + -PageSize 2 + ) + + $result.number | Should -Be @(10, 11, 12) + $global:leakPaginationCalls.Count | Should -Be 2 + ($global:leakPaginationCalls[0] -join ' ') | + Should -Match 'labels: \["agentic-workflows"\]' + ($global:leakPaginationCalls[1] -join ' ') | + Should -Match 'after=issue-cursor' + } + + It 'fails closed on malformed or incomplete page metadata' { + $node = New-LeakGraphQlPullRequestNode -Number 1 + $global:leakPaginationResponses.Enqueue( + (@{ + data = @{ + repository = @{ + pullRequests = @{ + totalCount = 1 + nodes = @($node) + } + } + } + } | ConvertTo-Json -Depth 8 -Compress) + ) + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + } | Should -Throw "*connection is missing 'pageInfo'*" + + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @($node) ` + -TotalCount 2 ` + -HasNextPage $false) + ) + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + } | Should -Throw '*ended after 1 unique nodes but totalCount is 2*' + + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @($node) ` + -TotalCount 2 ` + -HasNextPage $true) + ) + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + } | Should -Throw '*claimed another page without a usable endCursor*' + } + + It 'fails closed when pagination changes totalCount or repeats a cursor' { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number 1 + ) ` + -TotalCount 2 ` + -HasNextPage $true ` + -EndCursor cursor-1) + ) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number 2 + ) ` + -TotalCount 3 ` + -HasNextPage $false) + ) + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + } | Should -Throw '*totalCount changed from 2 to 3*' + + 1..2 | ForEach-Object { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number (10 + $_) + ) ` + -TotalCount 3 ` + -HasNextPage $true ` + -EndCursor repeated) + ) + } + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + } | Should -Throw "*repeated endCursor 'repeated'*" + } + + It 'enforces the query budget before issuing an unbounded next-page request' { + 1..2 | ForEach-Object { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number $_ + ) ` + -TotalCount 3 ` + -HasNextPage $true ` + -EndCursor "cursor-$_") + ) + } + + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') ` + -PageSize 1 ` + -MaximumPageQueries 2 + } | Should -Throw '*exceeded the 2-query pagination safety budget*' + + $global:leakPaginationCalls.Count | Should -Be 2 + } +} + +Describe 'merged revert discovery from complete history' { It 'recursively discovers only explicit same-branch reverters of the target set' { - $script:discoveryRowsByBase['main'] = @( + $mergedHistory = @( New-LeakPr ` -Number 200 ` -Title 'Back out cleanup without Revert in the title' ` @@ -945,57 +1251,22 @@ Describe 'branch-scoped merged revert discovery' { ) $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 + -MergedPullRequests $mergedHistory ) - $result.Count | Should -Be 0 - $script:discoveryCalls.Count | Should -Be 1 - $script:discoveryCalls[0].Search.Length | Should -BeLessOrEqual 256 + $result.number | Should -Be @(200, 300) } - It 'keeps more than 100 initial seeds and recursive reverters to one snapshot query' { + It 'keeps more than 100 seeds on one shared history scan' { $targets = @(1000..1100 | ForEach-Object { [pscustomobject]@{ number = $_; baseRefName = 'main' } }) - $script:discoveryRowsByBase['main'] = @( + $mergedHistory = @( New-LeakPr -Number 2000 -Title 'Relevant revert' -Body 'Reverts #1000' New-LeakPr -Number 2001 -Title 'Recursive revert' -Body 'Reverts #2000' ) @@ -1004,47 +1275,14 @@ Describe 'branch-scoped merged revert discovery' { Get-RelevantMergedLeakReverts ` -Repository 'dotnet/maui' ` -TargetPullRequests $targets ` - -MaximumSearchQueries 1 + -MergedPullRequests $mergedHistory ) $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'] = @( + $mergedHistory = @( 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' @@ -1056,44 +1294,47 @@ Describe 'branch-scoped merged revert discovery' { -TargetPullRequests @( [pscustomobject]@{ number = 100; baseRefName = 'main' } ) ` + -MergedPullRequests $mergedHistory ` -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' { + It 'uses complete non-Search pagination for the early merged-fix history' { $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') + $fetch = $step.IndexOf('Get-CompleteLeakPullRequests.ps1') + $state = $step.IndexOf('-State MERGED', $fetch) $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 + ($fetch -ge 0) | Should -BeTrue + ($state -gt $fetch) | Should -BeTrue + ($filteredWrite -gt $state) | Should -BeTrue + $step | Should -Match 'complete non-Search history' + $step | Should -Not -Match 'gh pr list|--search|--limit 1000' } - It 'uses the full Search API window and fails closed for open leak-scan issue de-dup' { + It 'uses complete issue pagination for open leak-scan 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') + $fetch = $step.IndexOf('Get-CompleteLeakIssues.ps1') + $rawWrite = $step.IndexOf( + '-OutputPath /tmp/gh-aw/agent/my-open-leakscan.json' + ) $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 + ($fetch -ge 0) | Should -BeTrue + ($rawWrite -gt $fetch) | Should -BeTrue + ($dedupRead -gt $rawWrite) | Should -BeTrue + $step | Should -Match 'stable totalCount/pageInfo' + $step | Should -Not -Match 'gh issue list|--search|--limit 1000' } It 'keeps source and trusted attempt-cap branch scope in parity' { @@ -1105,8 +1346,8 @@ Describe 'workflow enforcement boundary' { $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 | Should -Match '(?s)Get-CompleteLeakPullRequests\.ps1.*-State CLOSED' + $gate | Should -Match '(?s)Get-CompleteLeakPullRequests.*-State CLOSED' @($step, $gate) | ForEach-Object { $_ | Should -Match 'Select-LeakAuthoritativePullRequests' $_ | Should -Match 'one aggregate budget across both authoritative lanes' @@ -1218,19 +1459,23 @@ Describe 'workflow enforcement boundary' { Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1' ) -Raw - $module | Should -Match '\$MaximumSearchQueries = 2' + $module | Should -Match '\$MaximumPageQueries = 1000' + $module | Should -Match '\$PageSize = 100' $module | Should -Match '\$MaximumTraversalPullRequests = 2000' @($wrapper, $fixGate, $hunterGate) | ForEach-Object { $_ | Should -Match 'Get-RelevantMergedLeakReverts' - $_ | Should -Not -Match 'MaximumSearchQueries' + $_ | Should -Not -Match 'MaximumPageQueries' $_ | Should -Not -Match 'MaximumTraversalPullRequests' } } - It 'uses constant-size branch snapshots with exact local revert verification' { + It 'uses complete cursor history with exact local revert verification' { $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 @@ -1244,26 +1489,30 @@ Describe 'workflow enforcement boundary' { Join-Path $PSScriptRoot '../workflows/leak-fixer.md' ) -Raw - $module | Should -Match "\`$searchQuery = 'Reverts in:body'" - $module | Should -Match "'--base', \`$base" + $module | Should -Match 'pullRequests\(' + $module | Should -Match 'pageInfo' + $module | Should -Match 'totalCount' + $module | Should -Match 'endCursor' $module | Should -Match 'Get-LeakRevertTargets' - $module | Should -Not -Match 'Reverts.*#\$\{targetNumber\}.*in:body' - $fixGate | Should -Match 'Get-RelevantMergedLeakReverts' - $hunterGate | Should -Match 'Get-RelevantMergedLeakReverts' + $module | Should -Not -Match "'--search'|gh pr list" + $wrapper | Should -Match 'MergedPullRequestsJsonPath' + @($fixGate, $hunterGate) | ForEach-Object { + $_ | Should -Match 'Get-CompleteLeakPullRequests' + $_ | Should -Match 'Get-RelevantMergedLeakReverts' + $_ | Should -Match 'MergedPullRequests \$merged' + } $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 'complete non-Search history' + $documentation | Should -Match 'GraphQL' + $documentation | Should -Match 'totalCount/pageInfo' $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' + $documentation | Should -Match '1000-query safety budget' + $documentation | Should -Match '1000-discovery and 2000-PR aggregate' + $documentation | Should -Not -Match '1000-result|Search API' } } @@ -1296,6 +1545,7 @@ Describe 'workflow enforcement boundary' { $global:mockClosed = @() $global:mockGhExitCode = 0 $global:mockGhStderr = '' + $global:mockReturnAllBases = $false function global:gh { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) $global:LASTEXITCODE = $global:mockGhExitCode @@ -1306,27 +1556,54 @@ Describe 'workflow enforcement boundary' { 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) + $queryArgument = @($GhArgs | Where-Object { + $_.StartsWith('query=', [StringComparison]::Ordinal) + })[0] + $baseArgument = @($GhArgs | Where-Object { + $_.StartsWith('base=', [StringComparison]::Ordinal) + })[0] + if ($queryArgument -notmatch 'states: \[(?OPEN|CLOSED|MERGED)\]' -or + [string]::IsNullOrWhiteSpace($baseArgument)) { + throw 'Unexpected mock GraphQL request.' } + $state = $Matches.state + $base = $baseArgument.Substring('base='.Length) + $source = switch ($state) { + 'MERGED' { @($global:mockMerged) + @($global:mockReverts) } + 'CLOSED' { @($global:mockClosed) } + 'OPEN' { @($global:mockOpen) } + } + if (-not $global:mockReturnAllBases) { + $source = @($source | Where-Object { + [string]$_.baseRefName -ceq $base + }) + } + $nodes = @($source | ForEach-Object { + $node = [ordered]@{} + foreach ($name in @( + 'number', 'title', 'body', 'baseRefName', + 'mergedAt', 'url' + )) { + $property = $_.PSObject.Properties[$name] + if ($null -ne $property) { + $node[$name] = $property.Value + } + } + $node.state = $state + [pscustomobject]$node + }) + Write-Output (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes $nodes ` + -TotalCount $nodes.Count ` + -HasNextPage $false) } } AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue - Remove-Variable mockMerged, mockReverts, mockOpen, mockClosed, mockGhExitCode, mockGhStderr ` + Remove-Variable mockMerged, mockReverts, mockOpen, mockClosed, mockGhExitCode, ` + mockGhStderr, mockReturnAllBases ` -Scope Global -ErrorAction SilentlyContinue } @@ -1340,7 +1617,7 @@ Describe 'workflow enforcement boundary' { -AgentOutputPath $script:agentOutput ` -StateDirectory $script:stateDirectory ` -Repository 'dotnet/maui' - } | Should -Throw '*must start with the literal*prefix*' + } | Should -Throw '*must start with*followed by a space or tab*' } It 'accepts a tagged create-pull-request title' { @@ -1352,6 +1629,34 @@ Describe 'workflow enforcement boundary' { } | Should -Not -Throw } + It 'accepts tab-separated prefix grammar at the mutation boundary' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + "[leak-fix]`tFix`tGradientBrush.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 -Not -Throw + } + + It 'rejects near-prefix output at the mutation boundary' { + $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json + $output.items[0].title = + '[leak-fix]x 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 '*followed by a space or tab*' + } + It 'accepts supported punctuation after the canonical API' { $output = Get-Content -LiteralPath $script:agentOutput -Raw | ConvertFrom-Json $output.items[0].title = @@ -1520,6 +1825,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } It 'fails closed when a closed attempt is missing baseRefName' { + $global:mockReturnAllBases = $true $global:mockClosed = @( [pscustomobject]@{ number = 605 @@ -1538,6 +1844,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } It 'fails closed when a closed attempt has malformed baseRefName' { + $global:mockReturnAllBases = $true $global:mockClosed = @( New-LeakPr ` -Number 606 ` @@ -1614,6 +1921,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim $global:mockHunterReverts = @() $global:mockHunterGhExitCode = 0 $global:mockHunterGhStderr = '' + $global:mockHunterReturnAllBases = $false function global:gh { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) $global:LASTEXITCODE = $global:mockHunterGhExitCode @@ -1624,24 +1932,67 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim Write-Output 'mock gh failure' return } - if ($GhArgs[0] -eq 'issue') { - Write-Output (ConvertTo-Json -InputObject @($global:mockHunterOpenIssues) -Depth 5) + $queryArgument = @($GhArgs | Where-Object { + $_.StartsWith('query=', [StringComparison]::Ordinal) + })[0] + if ($queryArgument -match '\bissues\(') { + $nodes = @($global:mockHunterOpenIssues | ForEach-Object { + $node = [ordered]@{} + foreach ($name in @('number', 'title', 'body', 'url')) { + $property = $_.PSObject.Properties[$name] + if ($null -ne $property) { + $node[$name] = $property.Value + } + } + [pscustomobject]$node + }) + Write-Output (New-LeakGraphQlPageJson ` + -ConnectionName issues ` + -Nodes $nodes ` + -TotalCount $nodes.Count ` + -HasNextPage $false) 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) + $baseArgument = @($GhArgs | Where-Object { + $_.StartsWith('base=', [StringComparison]::Ordinal) + })[0] + if ($queryArgument -notmatch 'states: \[MERGED\]' -or + [string]::IsNullOrWhiteSpace($baseArgument)) { + throw 'Unexpected mock GraphQL request.' + } + $base = $baseArgument.Substring('base='.Length) + $source = @($global:mockHunterMerged) + @($global:mockHunterReverts) + if (-not $global:mockHunterReturnAllBases) { + $source = @($source | Where-Object { + [string]$_.baseRefName -ceq $base + }) } + $nodes = @($source | ForEach-Object { + $node = [ordered]@{} + foreach ($name in @( + 'number', 'title', 'body', 'baseRefName', + 'mergedAt', 'url' + )) { + $property = $_.PSObject.Properties[$name] + if ($null -ne $property) { + $node[$name] = $property.Value + } + } + $node.state = 'MERGED' + [pscustomobject]$node + }) + Write-Output (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes $nodes ` + -TotalCount $nodes.Count ` + -HasNextPage $false) } } AfterAll { Remove-Item Function:\global:gh -ErrorAction SilentlyContinue Remove-Variable mockHunterOpenIssues, mockHunterMerged, mockHunterReverts, ` - mockHunterGhExitCode, mockHunterGhStderr ` + mockHunterGhExitCode, mockHunterGhStderr, mockHunterReturnAllBases ` -Scope Global -ErrorAction SilentlyContinue } @@ -1653,6 +2004,36 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } | Should -Not -Throw } + It 'accepts tab-separated issue prefix grammar at the mutation boundary' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | + ConvertFrom-Json + $output.items[0].title = + "[leak-scan]`tGradientBrush.GradientStops — 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 -Not -Throw + } + + It 'rejects near-prefix issue output at the mutation boundary' { + $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | + ConvertFrom-Json + $output.items[0].title = + '[leak-scan]x GradientBrush.GradientStops — 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 '*followed by a space or tab*' + } + 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 = @@ -1730,6 +2111,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } It 'fails closed before mutation when merged metadata is missing baseRefName' { + $global:mockHunterReturnAllBases = $true $global:mockHunterMerged = @( [pscustomobject]@{ number = 704 @@ -1745,13 +2127,14 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim & (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*' + } | Should -Throw "*MERGED pull-request pagination for 'main' 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:mockHunterReturnAllBases = $true $global:mockHunterMerged = @( New-LeakPr ` -Number 705 ` @@ -1764,7 +2147,7 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim & (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*' + } | Should -Throw "*MERGED pull-request pagination for 'main' PR #705 has malformed baseRefName*" Get-Content -LiteralPath $script:hunterAgentOutput -Raw | Should -BeExactly $before diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 70e1bfb01d88..db619b581f64 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -2,6 +2,9 @@ function ConvertTo-CanonicalLeakApi { param([Parameter(Mandatory = $true)][string]$Api) $segments = $Api.Split('.') + if ($segments.Count -lt 2) { + return $Api + } if ($segments.Count -eq 2 -or $Api.StartsWith('Microsoft.Maui.', [StringComparison]::Ordinal)) { return "$($segments[-2]).$($segments[-1])" @@ -9,6 +12,26 @@ function ConvertTo-CanonicalLeakApi { return $Api } +function Test-LeakTitlePrefix { + param( + [AllowEmptyString()][string]$Title, + [Parameter(Mandatory = $true)] + [ValidateSet('Scan', 'Fix')] + [string]$Kind + ) + + if ([string]::IsNullOrWhiteSpace($Title)) { + return $false + } + + $prefix = if ($Kind -eq 'Scan') { + '\[leak-scan\]' + } else { + '\[leak-fix\]' + } + return $Title -cmatch "^$prefix[ `t]+" +} + function Get-CanonicalLeakApi { param([AllowEmptyString()][string]$Title) @@ -19,9 +42,9 @@ function Get-CanonicalLeakApi { $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)) { + $match = if (Test-LeakTitlePrefix -Title $normalized -Kind Scan) { [regex]::Match($normalized, "^\[leak-scan\][ `t]+(?$identifierChain)$apiBoundary") - } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + } elseif (Test-LeakTitlePrefix -Title $normalized -Kind Fix) { [regex]::Match($normalized, "^\[leak-fix\][ `t]+Fix[ `t]+(?$identifierChain)$apiBoundary") } else { return $null @@ -49,12 +72,12 @@ function Get-CanonicalExistingLeakApi { $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)) { + $match = if (Test-LeakTitlePrefix -Title $normalized -Kind Scan) { [regex]::Match( $normalized, "^\[leak-scan\][ `t]+Shell[ `t]+(?$shortApi)$apiBoundary" ) - } elseif ($normalized.StartsWith('[leak-fix] ', [StringComparison]::Ordinal)) { + } elseif (Test-LeakTitlePrefix -Title $normalized -Kind Fix) { [regex]::Match( $normalized, "^\[leak-fix\][ `t]+Fix[ `t]+Shell[ `t]+(?$shortApi)$apiBoundary" @@ -70,7 +93,10 @@ function Get-CanonicalExistingLeakApi { } function Read-RegularJsonFile { - param([Parameter(Mandatory = $true)][string]$Path) + param( + [Parameter(Mandatory = $true)][string]$Path, + [ValidateRange(1, 256MB)][long]$MaximumBytes = 1MB + ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Required JSON file is missing: $Path" @@ -80,7 +106,7 @@ function Read-RegularJsonFile { throw "Refusing symbolic-link JSON file: $Path" } $raw = Get-Content -LiteralPath $Path -Raw - if ([string]::IsNullOrWhiteSpace($raw) -or $raw.Length -gt 1MB) { + if ([string]::IsNullOrWhiteSpace($raw) -or $raw.Length -gt $MaximumBytes) { throw "JSON file is empty or too large: $Path" } try { @@ -293,6 +319,11 @@ function Invoke-LeakGhJson { # Preserve structured exit-code handling if a future host flips the native-command default. $PSNativeCommandUseErrorActionPreference = $false + $commandText = (($Arguments -join ' ') ` + -replace '[\x00-\x20\x7F]', ' ').Trim() + if ($commandText.Length -gt 1000) { + $commandText = "$($commandText.Substring(0, 1000))..." + } for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { $output = & gh @Arguments 2>&1 $exitCode = $LASTEXITCODE @@ -306,12 +337,12 @@ function Invoke-LeakGhJson { if ($exitCode -eq 0) { if ([string]::IsNullOrWhiteSpace($stdout)) { - throw "'gh $($Arguments -join ' ')' returned an empty response." + throw "'gh $commandText' returned an empty response." } try { return $stdout | ConvertFrom-Json } catch { - throw "'gh $($Arguments -join ' ')' returned invalid JSON: $($_.Exception.Message)" + throw "'gh $commandText' returned invalid JSON: $($_.Exception.Message)" } } @@ -322,7 +353,7 @@ function Invoke-LeakGhJson { if ($detail.Length -gt 2000) { $detail = "$($detail.Substring(0, 2000))..." } - $message = "'gh $($Arguments -join ' ')' failed with exit code $exitCode after $attempt attempt(s)." + $message = "'gh $commandText' failed with exit code $exitCode after $attempt attempt(s)." if (-not [string]::IsNullOrWhiteSpace($detail)) { $message = "$message Output: $detail" } @@ -361,16 +392,412 @@ function Invoke-LeakGhJson { throw $message } - throw "'gh $($Arguments -join ' ')' exhausted its retry budget unexpectedly." + throw "'gh $commandText' exhausted its retry budget unexpectedly." +} + +function Get-LeakRequiredPropertyValue { + param( + [Parameter(Mandatory = $true)][AllowNull()][object]$Object, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Context + ) + + if ($null -eq $Object) { + throw "$Context is null." + } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + throw "$Context is missing '$Name'." + } + if ($property.Value -is [System.Array]) { + return ,$property.Value + } + return $property.Value +} + +function Get-LeakRepositoryCoordinates { + param([Parameter(Mandatory = $true)][string]$Repository) + + if ($Repository -notmatch '^(?[A-Za-z0-9_.-]+)/(?[A-Za-z0-9_.-]+)$' -or + $Matches.owner -in @('.', '..') -or + $Matches.name -in @('.', '..')) { + throw "Repository '$Repository' is not a valid owner/name slug." + } + + return [pscustomobject]@{ + Owner = $Matches.owner + Name = $Matches.name + } +} + +function Invoke-LeakGraphQlConnection { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][string]$Query, + [Parameter(Mandatory = $true)][string]$ConnectionName, + [Parameter(Mandatory = $true)][string]$Context, + [hashtable]$Variables = @{}, + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000, + [Parameter(Mandatory = $true)][hashtable]$QueryBudget + ) + + $coordinates = Get-LeakRepositoryCoordinates -Repository $Repository + $items = [System.Collections.Generic.List[object]]::new() + $seenKeys = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + $seenCursors = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + [long]$expectedTotal = -1 + $cursor = $null + + while ($true) { + if ([int]$QueryBudget.Queries -ge $MaximumPageQueries) { + throw "$Context exceeded the $MaximumPageQueries-query pagination safety budget before proving the connection complete." + } + $QueryBudget.Queries = [int]$QueryBudget.Queries + 1 + + $arguments = [System.Collections.Generic.List[string]]::new() + @( + 'api', 'graphql', + '-f', "query=$Query", + '-f', "owner=$($coordinates.Owner)", + '-f', "name=$($coordinates.Name)", + '-F', "first=$PageSize" + ) | ForEach-Object { $arguments.Add($_) } + foreach ($key in @($Variables.Keys | Sort-Object)) { + $arguments.Add('-f') + $arguments.Add("$key=$($Variables[$key])") + } + if (-not [string]::IsNullOrWhiteSpace($cursor)) { + $arguments.Add('-f') + $arguments.Add("after=$cursor") + } + + $response = Invoke-LeakGhJson -Arguments $arguments.ToArray() + $errorsProperty = $response.PSObject.Properties['errors'] + if ($null -ne $errorsProperty -and @($errorsProperty.Value).Count -gt 0) { + throw "$Context returned GraphQL errors." + } + + $data = Get-LeakRequiredPropertyValue ` + -Object $response ` + -Name 'data' ` + -Context "$Context response" + $repositoryNode = Get-LeakRequiredPropertyValue ` + -Object $data ` + -Name 'repository' ` + -Context "$Context response data" + $connection = Get-LeakRequiredPropertyValue ` + -Object $repositoryNode ` + -Name $ConnectionName ` + -Context "$Context repository" + $totalValue = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'totalCount' ` + -Context "$Context connection" + if ($totalValue -isnot [int] -and $totalValue -isnot [long]) { + throw "$Context returned a malformed totalCount." + } + [long]$totalCount = $totalValue + if ($totalCount -lt 0) { + throw "$Context returned a negative totalCount." + } + if ($expectedTotal -lt 0) { + $expectedTotal = $totalCount + } elseif ($totalCount -ne $expectedTotal) { + throw "$Context totalCount changed from $expectedTotal to $totalCount during pagination." + } + + $nodesValue = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'nodes' ` + -Context "$Context connection" + if ($nodesValue -isnot [System.Array]) { + throw "$Context returned malformed nodes instead of an array." + } + $nodes = @($nodesValue) + if ($nodes.Count -gt $PageSize) { + throw "$Context returned $($nodes.Count) nodes for a $PageSize-node page." + } + + foreach ($node in $nodes) { + $numberValue = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'number' ` + -Context "$Context node" + $number = 0 + if (-not [int]::TryParse([string]$numberValue, [ref]$number) -or + $number -le 0) { + throw "$Context returned an invalid node number '$numberValue'." + } + if (-not $seenKeys.Add([string]$number)) { + throw "$Context returned duplicate node #$number across pages." + } + $items.Add($node) + } + if ($items.Count -gt $expectedTotal) { + throw "$Context returned more unique nodes than totalCount $expectedTotal." + } + + $pageInfo = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'pageInfo' ` + -Context "$Context connection" + $hasNextPage = Get-LeakRequiredPropertyValue ` + -Object $pageInfo ` + -Name 'hasNextPage' ` + -Context "$Context pageInfo" + if ($hasNextPage -isnot [bool]) { + throw "$Context returned malformed hasNextPage metadata." + } + $endCursor = Get-LeakRequiredPropertyValue ` + -Object $pageInfo ` + -Name 'endCursor' ` + -Context "$Context pageInfo" + + if (-not $hasNextPage) { + if ($items.Count -ne $expectedTotal) { + throw "$Context ended after $($items.Count) unique nodes but totalCount is $expectedTotal." + } + break + } + if ($nodes.Count -eq 0) { + throw "$Context claimed another page after returning an empty page." + } + if ($items.Count -ge $expectedTotal) { + throw "$Context claimed another page after already returning totalCount $expectedTotal." + } + if ($endCursor -isnot [string] -or + [string]::IsNullOrWhiteSpace($endCursor)) { + throw "$Context claimed another page without a usable endCursor." + } + if (-not $seenCursors.Add($endCursor)) { + throw "$Context repeated endCursor '$endCursor'." + } + $cursor = $endCursor + } + + return [pscustomobject]@{ + Items = @($items) + QueryCount = [int]$QueryBudget.Queries + } +} + +function Get-CompleteLeakPullRequests { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)] + [ValidateSet('OPEN', 'CLOSED', 'MERGED')] + [string]$State, + [string[]]$BaseRefNames = @('main', 'inflight/current'), + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000 + ) + + if ($BaseRefNames.Count -eq 0) { + throw 'At least one baseRefName is required.' + } + + $query = @' +query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests( + first: $first + after: $after + baseRefName: $base + states: [__STATE__] + orderBy: { field: CREATED_AT, direction: ASC } + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + body + baseRefName + state + mergedAt + url + } + } + } +} +'@.Replace('__STATE__', $State) + + $queryBudget = @{ Queries = 0 } + $all = [System.Collections.Generic.List[object]]::new() + $seenNumbers = [System.Collections.Generic.HashSet[int]]::new() + $seenBases = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + foreach ($baseRefName in $BaseRefNames) { + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest ([pscustomobject]@{ baseRefName = $baseRefName }) ` + -Context 'Leak pull-request pagination' + if (-not $seenBases.Add($base)) { + throw "Leak pull-request pagination received duplicate baseRefName '$base'." + } + + $context = "$State pull-request pagination for '$base'" + $connection = Invoke-LeakGraphQlConnection ` + -Repository $Repository ` + -Query $query ` + -ConnectionName 'pullRequests' ` + -Context $context ` + -Variables @{ base = $base } ` + -PageSize $PageSize ` + -MaximumPageQueries $MaximumPageQueries ` + -QueryBudget $queryBudget + + foreach ($node in @($connection.Items)) { + $number = [int](Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'number' ` + -Context "$context node") + if (-not $seenNumbers.Add($number)) { + throw "Leak pull-request pagination returned PR #$number under multiple base branches." + } + $nodeBase = Get-NormalizedLeakBaseRefName ` + -PullRequest $node ` + -Context $context + if ($nodeBase -cne $base) { + throw "$context returned PR #$number for unexpected baseRefName '$nodeBase'." + } + + $nodeState = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'state' ` + -Context "$context PR #$number" + if ($nodeState -isnot [string] -or $nodeState -cne $State) { + throw "$context returned PR #$number with unexpected state '$nodeState'." + } + $title = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'title' ` + -Context "$context PR #$number" + $body = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'body' ` + -Context "$context PR #$number" + $mergedAt = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'mergedAt' ` + -Context "$context PR #$number" + $url = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'url' ` + -Context "$context PR #$number" + if ($title -isnot [string] -or $url -isnot [string] -or + [string]::IsNullOrWhiteSpace($url) -or + ($null -ne $body -and $body -isnot [string])) { + throw "$context returned malformed text metadata for PR #$number." + } + if (($State -ceq 'MERGED' -and $null -eq $mergedAt) -or + ($State -cne 'MERGED' -and $null -ne $mergedAt)) { + throw "$context returned inconsistent mergedAt metadata for PR #$number." + } + + $all.Add([pscustomobject]@{ + number = $number + title = $title + body = $body + baseRefName = $nodeBase + mergedAt = $mergedAt + url = $url + }) + } + } + + return @($all | Sort-Object number) +} + +function Get-CompleteLeakIssues { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000 + ) + + $query = @' +query($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + issues( + first: $first + after: $after + states: [OPEN] + labels: ["agentic-workflows"] + orderBy: { field: CREATED_AT, direction: ASC } + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + body + url + } + } + } +} +'@ + + $connection = Invoke-LeakGraphQlConnection ` + -Repository $Repository ` + -Query $query ` + -ConnectionName 'issues' ` + -Context 'Open agentic-workflows issue pagination' ` + -PageSize $PageSize ` + -MaximumPageQueries $MaximumPageQueries ` + -QueryBudget @{ Queries = 0 } + $issues = [System.Collections.Generic.List[object]]::new() + foreach ($node in @($connection.Items)) { + $number = [int](Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'number' ` + -Context 'Open agentic-workflows issue node') + $title = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'title' ` + -Context "Open agentic-workflows issue #$number" + $body = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'body' ` + -Context "Open agentic-workflows issue #$number" + $url = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'url' ` + -Context "Open agentic-workflows issue #$number" + if ($title -isnot [string] -or $url -isnot [string] -or + [string]::IsNullOrWhiteSpace($url) -or + ($null -ne $body -and $body -isnot [string])) { + throw "Open agentic-workflows issue pagination returned malformed text metadata for issue #$number." + } + $issues.Add([pscustomobject]@{ + number = $number + title = $title + body = $body + url = $url + }) + } + + return @($issues | Sort-Object number) } function Get-RelevantMergedLeakReverts { param( [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$TargetPullRequests, - [ValidateRange(1, 1000)][int]$SearchLimit = 1000, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedPullRequests, [ValidateRange(1, 1000)][int]$MaximumDiscoveredPullRequests = 1000, - [ValidateRange(1, 2)][int]$MaximumSearchQueries = 2, [ValidateRange(1, 2000)][int]$MaximumTraversalPullRequests = 2000 ) @@ -405,59 +832,32 @@ function Get-RelevantMergedLeakReverts { }) } - 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 $MergedPullRequests) { + if ($null -eq $row.mergedAt) { + continue } - - 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) + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest $row ` + -Context 'Complete merged pull-request history' + if (-not $branches.Contains($base)) { + continue + } + $reverter = 0 + if (-not [int]::TryParse([string]$row.number, [ref]$reverter) -or + $reverter -le 0) { + throw "Invalid merged-revert history 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) } } @@ -545,7 +945,7 @@ function Get-LeakFixFinalDedupResult { ) $eligibleMerged = @($authoritativeMerged | Where-Object { $null -ne $_.mergedAt -and - ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) + (Test-LeakTitlePrefix -Title ([string]$_.title) -Kind Fix) }) $effectivelyReverted = @( Get-EffectiveRevertedPullRequestNumbers ` @@ -566,7 +966,7 @@ function Get-LeakFixFinalDedupResult { -Context 'Open leak-fix de-dup search' ) $eligibleOpen = @($authoritativeOpen | Where-Object { - ([string]$_.title).StartsWith('[leak-fix] ', [System.StringComparison]::Ordinal) + Test-LeakTitlePrefix -Title ([string]$_.title) -Kind Fix }) $eligible = @($eligibleMerged + $eligibleOpen) @@ -725,6 +1125,7 @@ function Get-EffectiveRevertedPullRequestNumbers { } Export-ModuleMember -Function ` + Test-LeakTitlePrefix, ` Get-CanonicalLeakApi, ` Get-CanonicalExistingLeakApi, ` Read-RegularJsonFile, ` @@ -732,6 +1133,8 @@ Export-ModuleMember -Function ` Test-LeakPrReferencesIssue, ` Get-LeakRevertTargets, ` Invoke-LeakGhJson, ` + Get-CompleteLeakPullRequests, ` + Get-CompleteLeakIssues, ` Get-RelevantMergedLeakReverts, ` Assert-LeakDedupState, ` Get-LeakFixFinalDedupResult, ` diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 137287acec4b..ffadfc2b7c36 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":"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-metadata: {"schema_version":"v4","frontmatter_hash":"7599797f0a1b460cf63b295ece6bae886e02c5120db421f62938ebec059855f5","body_hash":"649561eb6ec622d5f5802a09f00f31905eac8387d10466de291e1714abbc2465","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 # @@ -505,7 +505,7 @@ jobs: 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" + 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).\n# The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo,\n# and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination.\npwsh .github/scripts/Get-CompleteLeakIssues.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json\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.\n# Fetch complete non-Search history once per authoritative base. Pagination is bounded by\n# an explicit query budget rather than a result cutoff; reaching the budget is a visible\n# fail-closed error, never a silently truncated history.\npwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -State MERGED \\\n -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | test(\"^\\\\[leak-fix\\\\][ \\\\t]+\")) |\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# Reuse the same complete merged-PR history, index only exact repository-local direct\n# references, and traverse recursive reverter chains locally. The history fetch uses\n# 100-node GraphQL cursor pages, stable total-count validation, bounded transient retries,\n# capped server-directed rate-limit delays, and a 1000-query safety budget. Local\n# traversal keeps the existing 1000-discovery and 2000-PR aggregate bounds, so seed count\n# never multiplies GitHub queries.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 3d15cf24fa4b..231212bd6576 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -93,14 +93,11 @@ pre-agent-steps: 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 + # The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo, + # and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination. + pwsh .github/scripts/Get-CompleteLeakIssues.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json 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 @@ -111,24 +108,16 @@ pre-agent-steps: 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 + # Fetch complete non-Search history once per authoritative base. Pagination is bounded by + # an explicit query budget rather than a result cutoff; reaching the budget is a visible + # fail-closed error, never a silently truncated history. + pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State MERGED \ + -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json jq '[.[] | select(.mergedAt != null) | - select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\][ \\t]+")) | 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 @@ -156,18 +145,16 @@ pre-agent-steps: # 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. + # Reuse the same complete merged-PR history, index only exact repository-local direct + # references, and traverse recursive reverter chains locally. The history fetch uses + # 100-node GraphQL cursor pages, stable total-count validation, bounded transient retries, + # capped server-directed rate-limit delays, and a 1000-query safety budget. Local + # traversal keeps the existing 1000-discovery and 2000-PR aggregate bounds, so seed count + # never multiplies GitHub queries. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ + -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \ -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. @@ -310,10 +297,11 @@ active source flow, so filing it again would only create another redundant fix P **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: +`gh` failure or malformed, incomplete, repeated-cursor, or over-budget GraphQL pagination 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index ee007c122053..fad92fbbe32e 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":"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-metadata: {"schema_version":"v4","frontmatter_hash":"904f5e7198714e63042518c4e15989be57d04d917590f91e1f831e0795018cb9","body_hash":"96269347ba609ea7f2911f1111c4969f018593f3eb6ef07e37e61a4258e9bcbc","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index bfba6cfa95a6..8a69a6086a11 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -442,24 +442,17 @@ jq -n \ 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 +# Fetch complete non-Search history once per authoritative base. The shared helper follows +# 100-node GraphQL cursor pages, validates stable totalCount/pageInfo, uses bounded transient +# retries with capped server-directed rate-limit delays, and fails closed on malformed, +# incomplete, repeated-cursor, or over-budget pagination under a 1000-query safety budget. +pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State MERGED \ + -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json jq '[.[] | select(.mergedAt != null) | - select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\][ \\t]+")) | 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 @@ -480,17 +473,14 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ | 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. +# A merged fix is not authoritative if it was later effectively reverted. Reuse the complete +# merged-PR history above, index only exact repository-local direct references, and traverse +# recursive same-branch reverter chains locally under 1000-discovery and 2000-PR aggregate +# bounds. Seed count never multiplies GitHub queries. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/merged-leak-fix-prs.tsv \ + -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \ -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ -Repository "$GITHUB_REPOSITORY" \ @@ -534,16 +524,13 @@ awk -F '\t' '{ print "equivalent API fix already merged: #" $2 " -> " $3 " " $4 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 +pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State OPEN \ + -OutputPath /tmp/gh-aw/agent/open-fix-prs-raw.json jq --arg n "$N" --arg repo "$REPO_RE" '[.[] | select(.baseRefName == "main" or .baseRefName == "inflight/current") | - select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\][ \\t]+")) | select((.body // "") | test("(^|\n)[ \t]*Fixes #"+$n+"\\b") or test("(^|\n)[ \t]*Refs: *"+$repo+"#"+$n+"\\b"))]' \ @@ -557,18 +544,11 @@ jq 'length' /tmp/gh-aw/agent/open-fix-prs.json # 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] ")) | + select(.title | test("^\\[leak-fix\\][ \\t]+")) | [.number, .title] | @tsv' \ - /tmp/gh-aw/agent/same-api-prs-raw.json \ + /tmp/gh-aw/agent/open-fix-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 @@ -579,14 +559,10 @@ jq -r '.[] | > /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 # (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 +pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State CLOSED \ + -OutputPath /tmp/gh-aw/agent/closed-fix-prs-raw.json # 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 @@ -601,7 +577,7 @@ pwsh -NoLogo -NoProfile -Command ' -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)]' \ +jq '[.[] | select(.title | test("^\\[leak-fix\\][ \\t]+")) | 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 @@ -815,21 +791,13 @@ 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 +pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State MERGED \ + -OutputPath /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json jq '[.[] | select(.mergedAt != null) | - select(.title | startswith("[leak-fix] ")) | + select(.title | test("^\\[leak-fix\\][ \\t]+")) | 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 @@ -841,6 +809,7 @@ jq -r '.[] | ["_", .number, .baseRefName, .title] | @tsv' \ pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/final-merged-leak-fix-prs.tsv \ + -MergedPullRequestsJsonPath /tmp/gh-aw/agent/final-merged-leak-fix-prs-raw.json \ -OutputPath /tmp/gh-aw/agent/final-merged-revert-prs.json pwsh .github/scripts/Get-EffectiveRevertedLeakFixes.ps1 \ -Repository "$GITHUB_REPOSITORY" \ @@ -870,21 +839,13 @@ jq -r '.[] | [.number, .title] | @tsv' \ 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 +pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ + -Repository "$GITHUB_REPOSITORY" \ + -State OPEN \ + -OutputPath /tmp/gh-aw/agent/final-open-fix-prs-raw.json jq '[.[] | select(.baseRefName == "main" or .baseRefName == "inflight/current") | - select(.title | startswith("[leak-fix] "))]' \ + select(.title | test("^\\[leak-fix\\][ \\t]+"))]' \ /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" '[.[] | From f897d851ebd8e5501f2d2626b65acd2a104b2bd7 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 22:43:19 +0200 Subject: [PATCH 62/68] Harden leak revert proof verification Require immutable exact merge-commit evidence before treating a merged leak fix as reverted, with bounded batched commit-history pagination and fail-closed recursive semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 707 ++++++++++++++++-- .github/scripts/LeakWorkflowDedup.psm1 | 729 ++++++++++++++++++- .github/workflows/daily-leak-hunter.lock.yml | 4 +- .github/workflows/daily-leak-hunter.md | 46 +- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 15 +- 6 files changed, 1379 insertions(+), 124 deletions(-) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index f0922d9a5fbd..f8b82c6edce3 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -10,18 +10,33 @@ BeforeAll { [string]$Title, [string]$Body = '', [string]$Base = 'main', - [bool]$Merged = $true + [bool]$Merged = $true, + [string]$MergeCommitOid = '', + [int[]]$VerifiedRevertTargets, + [string[]]$CommitMessages ) - [pscustomobject]@{ + if ($Merged -and [string]::IsNullOrWhiteSpace($MergeCommitOid)) { + $MergeCommitOid = '{0:x40}' -f $Number + } + $result = [ordered]@{ number = $Number title = $Title body = $Body baseRefName = $Base state = if ($Merged) { 'MERGED' } else { 'CLOSED' } + merged = $Merged mergedAt = if ($Merged) { '2026-08-10T00:00:00Z' } else { $null } + mergeCommitOid = if ($Merged) { $MergeCommitOid } else { $null } url = "https://github.com/dotnet/maui/pull/$Number" } + if ($PSBoundParameters.ContainsKey('VerifiedRevertTargets')) { + $result.verifiedRevertTargets = @($VerifiedRevertTargets) + } + if ($PSBoundParameters.ContainsKey('CommitMessages')) { + $result.commitMessages = @($CommitMessages) + } + [pscustomobject]$result } function New-LeakGraphQlPageJson { @@ -64,14 +79,89 @@ BeforeAll { body = $Body baseRefName = $Base state = $State + merged = $State -ceq 'MERGED' mergedAt = if ($State -ceq 'MERGED') { '2026-08-10T00:00:00Z' } else { $null } + mergeCommit = if ($State -ceq 'MERGED') { + @{ + oid = '{0:x40}' -f $Number + } + } else { + $null + } url = "https://github.com/dotnet/maui/pull/$Number" } } + + function New-LeakCommitHistory { + param( + [Parameter(Mandatory = $true)][object]$PullRequest, + [AllowEmptyCollection()][string[]]$Messages = @(), + [AllowNull()][object[]]$Commits = $null + ) + + if ($null -eq $Commits) { + $commitNumber = 0 + $Commits = @($Messages | ForEach-Object { + $commitNumber++ + [pscustomobject]@{ + oid = '{0:x40}' -f ( + ([int]$PullRequest.number * 1000) + $commitNumber + ) + message = $_ + } + }) + } + [pscustomobject]@{ + number = [int]$PullRequest.number + state = 'MERGED' + merged = $true + baseRefName = [string]$PullRequest.baseRefName + mergeCommitOid = [string]$PullRequest.mergeCommitOid + commits = @($Commits) + } + } + + function New-LeakCommitHistoryGraphQlJson { + param( + [Parameter(Mandatory = $true)][object]$PullRequest, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Commits, + [Parameter(Mandatory = $true)][long]$TotalCount, + [Parameter(Mandatory = $true)][bool]$HasNextPage, + [AllowNull()][string]$EndCursor, + [string]$Alias = 'pr0' + ) + + $repository = @{} + $repository[$Alias] = @{ + number = [int]$PullRequest.number + state = 'MERGED' + merged = $true + mergedAt = [string]$PullRequest.mergedAt + baseRefName = [string]$PullRequest.baseRefName + mergeCommit = @{ + oid = [string]$PullRequest.mergeCommitOid + } + commits = @{ + totalCount = $TotalCount + nodes = @($Commits | ForEach-Object { + @{ commit = $_ } + }) + pageInfo = @{ + hasNextPage = $HasNextPage + endCursor = $EndCursor + } + } + } + @{ + data = @{ + repository = $repository + } + } | ConvertTo-Json -Depth 10 -Compress + } } Describe 'native gh invocation' { @@ -718,7 +808,8 @@ Describe 'trusted final duplicate gate' { $revert = New-LeakPr ` -Number 200 ` -Title 'Revert leak fix' ` - -Body 'Reverts dotnet/maui#100' + -Body 'Reverts dotnet/maui#100' ` + -VerifiedRevertTargets @(100) $result = Get-LeakFixFinalDedupResult ` -IssueNumber 20 ` @@ -745,15 +836,18 @@ Describe 'trusted final duplicate gate' { New-LeakPr ` -Number 200 ` -Title 'Revert Picker fix and cyclic peer' ` - -Body "Reverts #100`nReverts #300" + -Body "Reverts #100`nReverts #300" ` + -VerifiedRevertTargets @(100, 300) New-LeakPr ` -Number 300 ` -Title 'Revert cyclic peer' ` - -Body 'Reverts #200' + -Body 'Reverts #200' ` + -VerifiedRevertTargets @(200) New-LeakPr ` -Number 210 ` -Title 'Revert unrelated fix' ` - -Body 'Reverts #110' + -Body 'Reverts #110' ` + -VerifiedRevertTargets @(110) ) $result = Get-LeakFixFinalDedupResult ` @@ -778,15 +872,18 @@ Describe 'trusted final duplicate gate' { New-LeakPr ` -Number 200 ` -Title 'Cycle-entangled sibling' ` - -Body "Reverts #100`nReverts #300" + -Body "Reverts #100`nReverts #300" ` + -VerifiedRevertTargets @(100, 300) New-LeakPr ` -Number 300 ` -Title 'Cycle peer' ` - -Body 'Reverts #200' + -Body 'Reverts #200' ` + -VerifiedRevertTargets @(200) New-LeakPr ` -Number 201 ` -Title 'Definite terminal sibling' ` - -Body 'Reverts #100' + -Body 'Reverts #100' ` + -VerifiedRevertTargets @(100) ) $result = Get-LeakFixFinalDedupResult ` @@ -828,7 +925,8 @@ Describe 'effective recursive revert state' { 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' + New-LeakPr -Number 200 -Title 'Revert leak fix' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) ) Get-EffectiveRevertedPullRequestNumbers ` @@ -838,30 +936,32 @@ Describe 'effective recursive revert state' { Should -Be @(100) } - It 'accepts a repository-local revert reference' { + It 'does not trust a repository-local body reference without immutable proof' { $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) + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ).Count | Should -Be 0 } - It 'accepts markdown formatting around a repository-local revert reference' { + It 'does not trust formatted editable body prose without immutable proof' { $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) + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ).Count | Should -Be 0 } It 'rejects a revert reference qualified to another repository' { @@ -881,8 +981,10 @@ Describe 'effective recursive revert state' { 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' + New-LeakPr -Number 200 -Title 'Revert leak fix' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) + New-LeakPr -Number 300 -Title 'Revert the revert' ` + -Body 'Reverts dotnet/maui#200' -VerifiedRevertTargets @(200) ) @( @@ -896,9 +998,12 @@ Describe 'effective recursive revert state' { 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' + New-LeakPr -Number 200 -Title 'Revert A' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) + New-LeakPr -Number 300 -Title 'Revert A again' ` + -Body 'Reverts dotnet/maui#200' -VerifiedRevertTargets @(200) + New-LeakPr -Number 400 -Title 'Revert A re-revert' ` + -Body 'Reverts dotnet/maui#300' -VerifiedRevertTargets @(300) ) Get-EffectiveRevertedPullRequestNumbers ` @@ -911,8 +1016,10 @@ Describe 'effective recursive revert state' { 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' + New-LeakPr -Number 200 -Title 'Revert A' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) + New-LeakPr -Number 201 -Title 'Revert B' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) ) @( @@ -926,9 +1033,12 @@ Describe 'effective recursive revert state' { 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' + New-LeakPr -Number 200 -Title 'Revert A' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) + New-LeakPr -Number 201 -Title 'Revert B' ` + -Body 'Reverts dotnet/maui#100' -VerifiedRevertTargets @(100) + New-LeakPr -Number 300 -Title 'Restore only A' ` + -Body 'Reverts dotnet/maui#200' -VerifiedRevertTargets @(200) ) Get-EffectiveRevertedPullRequestNumbers ` @@ -947,7 +1057,8 @@ Describe 'effective recursive revert state' { -Number 200 ` -Title 'Revert leak fix for servicing' ` -Body 'Reverts dotnet/maui#100' ` - -Base 'release/10.0.1xx-sr9' + -Base 'release/10.0.1xx-sr9' ` + -VerifiedRevertTargets @(100) @( Get-EffectiveRevertedPullRequestNumbers ` @@ -971,17 +1082,20 @@ Describe 'effective recursive revert state' { -Number 200 ` -Title 'Revert main fix' ` -Body 'Reverts dotnet/maui#100' ` - -Base main + -Base main ` + -VerifiedRevertTargets @(100) New-LeakPr ` -Number 210 ` -Title 'Unrelated main revert of inflight PR number' ` -Body 'Reverts dotnet/maui#110' ` - -Base main + -Base main ` + -VerifiedRevertTargets @(110) New-LeakPr ` -Number 220 ` -Title 'Revert inflight fix' ` -Body 'Reverts dotnet/maui#110' ` - -Base 'inflight/current' + -Base 'inflight/current' ` + -VerifiedRevertTargets @(110) ) Get-EffectiveRevertedPullRequestNumbers ` @@ -1224,13 +1338,146 @@ Describe 'complete GraphQL pagination' { } } +Describe 'merged reverter commit-history pagination' { + BeforeEach { + $global:leakCommitHistoryCalls = + [System.Collections.Generic.List[object]]::new() + $global:leakCommitHistoryResponses = + [System.Collections.Generic.Queue[string]]::new() + function global:gh { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GhArgs) + $global:leakCommitHistoryCalls.Add(@($GhArgs)) + $global:LASTEXITCODE = 0 + if ($global:leakCommitHistoryResponses.Count -eq 0) { + throw 'No mock commit-history GraphQL response remains.' + } + Write-Output $global:leakCommitHistoryResponses.Dequeue() + } + } + + AfterAll { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable leakCommitHistoryCalls, leakCommitHistoryResponses ` + -Scope Global -ErrorAction SilentlyContinue + } + + It 'batches candidates while preserving complete commit metadata' { + $first = New-LeakPr -Number 200 -Title 'First candidate' + $second = New-LeakPr -Number 300 -Title 'Second candidate' + $repository = @{} + foreach ($entry in @( + @{ Alias = 'pr0'; PullRequest = $first; Commit = @{ + oid = '1111111111111111111111111111111111111111' + message = 'First immutable message' + } } + @{ Alias = 'pr1'; PullRequest = $second; Commit = @{ + oid = '2222222222222222222222222222222222222222' + message = 'Second immutable message' + } } + )) { + $repository[$entry.Alias] = @{ + number = $entry.PullRequest.number + state = 'MERGED' + merged = $true + mergedAt = $entry.PullRequest.mergedAt + baseRefName = $entry.PullRequest.baseRefName + mergeCommit = @{ oid = $entry.PullRequest.mergeCommitOid } + commits = @{ + totalCount = 1 + nodes = @(@{ commit = $entry.Commit }) + pageInfo = @{ + hasNextPage = $false + endCursor = $null + } + } + } + } + $global:leakCommitHistoryResponses.Enqueue( + (@{ data = @{ repository = $repository } } | + ConvertTo-Json -Depth 10 -Compress) + ) + + $result = @( + Get-CompleteLeakPullRequestCommitHistories ` + -Repository 'dotnet/maui' ` + -PullRequests @($first, $second) ` + -BatchSize 2 + ) + + $result.number | Should -Be @(200, 300) + $global:leakCommitHistoryCalls.Count | Should -Be 1 + ($global:leakCommitHistoryCalls[0] -join ' ') | + Should -Match 'pr0: pullRequest' + ($global:leakCommitHistoryCalls[0] -join ' ') | + Should -Match 'pr1: pullRequest' + } + + It 'fails closed when commit pagination is truncated' { + $candidate = New-LeakPr -Number 200 -Title 'Candidate' + $global:leakCommitHistoryResponses.Enqueue( + (New-LeakCommitHistoryGraphQlJson ` + -PullRequest $candidate ` + -Commits @( + @{ + oid = '1111111111111111111111111111111111111111' + message = 'Only returned commit' + } + ) ` + -TotalCount 2 ` + -HasNextPage $false) + ) + + { + Get-CompleteLeakPullRequestCommitHistories ` + -Repository 'dotnet/maui' ` + -PullRequests @($candidate) + } | Should -Throw '*ended after 1 unique commits but totalCount is 2*' + } + + It 'fails closed before exceeding the aggregate commit query budget' { + $candidate = New-LeakPr -Number 200 -Title 'Candidate' + $global:leakCommitHistoryResponses.Enqueue( + (New-LeakCommitHistoryGraphQlJson ` + -PullRequest $candidate ` + -Commits @( + @{ + oid = '1111111111111111111111111111111111111111' + message = 'First commit' + } + ) ` + -TotalCount 2 ` + -HasNextPage $true ` + -EndCursor next-commit-page) + ) + + { + Get-CompleteLeakPullRequestCommitHistories ` + -Repository 'dotnet/maui' ` + -PullRequests @($candidate) ` + -PageSize 1 ` + -MaximumPageQueries 1 + } | Should -Throw '*exceeded the 1-query safety budget*' + + $global:leakCommitHistoryCalls.Count | Should -Be 1 + } +} + Describe 'merged revert discovery from complete history' { - It 'recursively discovers only explicit same-branch reverters of the target set' { + It 'recursively discovers only same-branch edges with exact immutable commit proof' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' + $revert = New-LeakPr ` + -Number 200 ` + -Title 'Back out cleanup without Revert in the title' ` + -Body 'Reverts #100' + $restore = New-LeakPr ` + -Number 300 ` + -Title 'Restore prior behavior' ` + -Body 'Reverts dotnet/maui#200' $mergedHistory = @( - New-LeakPr ` - -Number 200 ` - -Title 'Back out cleanup without Revert in the title' ` - -Body 'Reverts #100' + $fix + $revert New-LeakPr ` -Number 201 ` -Title 'Unrelated mention' ` @@ -1244,45 +1491,206 @@ Describe 'merged revert discovery from complete history' { -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' + $restore ) $result = @( Get-RelevantMergedLeakReverts ` -Repository 'dotnet/maui' ` - -TargetPullRequests @( - [pscustomobject]@{ number = 100; baseRefName = 'main' } - ) ` - -MergedPullRequests $mergedHistory + -TargetPullRequests @($fix) ` + -MergedPullRequests $mergedHistory ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $revert ` + -Messages @( + "Revert cleanup`n`nThis reverts commit $($fix.mergeCommitOid)." + ) + New-LeakCommitHistory ` + -PullRequest $restore ` + -Messages @( + "Restore cleanup`n`nThis reverts commit $($revert.mergeCommitOid)." + ) + ) ) $result.number | Should -Be @(200, 300) + $result[0].verifiedRevertTargets | Should -Be @(100) + $result[1].verifiedRevertTargets | Should -Be @(200) } It 'keeps more than 100 seeds on one shared history scan' { $targets = @(1000..1100 | ForEach-Object { - [pscustomobject]@{ number = $_; baseRefName = 'main' } + New-LeakPr ` + -Number $_ ` + -Title "[leak-fix] Fix Type$_.Member leak" }) - $mergedHistory = @( - New-LeakPr -Number 2000 -Title 'Relevant revert' -Body 'Reverts #1000' - New-LeakPr -Number 2001 -Title 'Recursive revert' -Body 'Reverts #2000' - ) + $revert = New-LeakPr ` + -Number 2000 ` + -Title 'Relevant revert' ` + -Body 'Reverts #1000' + $restore = New-LeakPr ` + -Number 2001 ` + -Title 'Recursive revert' ` + -Body 'Reverts #2000' + $mergedHistory = @($targets) + @($revert, $restore) $result = @( Get-RelevantMergedLeakReverts ` -Repository 'dotnet/maui' ` -TargetPullRequests $targets ` - -MergedPullRequests $mergedHistory + -MergedPullRequests $mergedHistory ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $revert ` + -Messages @( + "Revert`n`nThis reverts commit $($targets[0].mergeCommitOid)." + ) + New-LeakCommitHistory ` + -PullRequest $restore ` + -Messages @( + "Restore`n`nThis reverts commit $($revert.mergeCommitOid)." + ) + ) ) $result.number | Should -Be @(2000, 2001) } + It 'retains the original fix when editable body prose has no matching commit proof' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' + $forged = New-LeakPr ` + -Number 200 ` + -Title 'Unrelated merged change' ` + -Body 'Reverts #100' + + $reverts = @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @($fix) ` + -MergedPullRequests @($fix, $forged) ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $forged ` + -Messages @('Unrelated immutable commit message') + ) + ) + + $reverts.Count | Should -Be 0 + @( + Get-EffectiveRevertedPullRequestNumbers ` + -Repository 'dotnet/maui' ` + -FixPullRequests @($fix) ` + -MergedRevertPullRequests $reverts + ).Count | Should -Be 0 + } + + It 'rejects wrong and abbreviated immutable commit references' -ForEach @( + @{ Proof = '1111111111111111111111111111111111111111' } + @{ Proof = '000000000000000000000000000000000000006' } + @{ Proof = '0000000' } + ) { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' + $candidate = New-LeakPr ` + -Number 200 ` + -Title 'Claimed revert' ` + -Body 'Reverts #100' + + @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @($fix) ` + -MergedPullRequests @($fix, $candidate) ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $candidate ` + -Messages @( + "Claimed revert`n`nThis reverts commit $Proof." + ) + ) + ).Count | Should -Be 0 + } + + It 'ignores a branch-mismatched candidate even with an exact commit reference' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' + $releaseCandidate = New-LeakPr ` + -Number 200 ` + -Title 'Servicing revert' ` + -Body 'Reverts #100' ` + -Base 'release/10.0.1xx-sr9' + + @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @($fix) ` + -MergedPullRequests @($fix, $releaseCandidate) + ).Count | Should -Be 0 + } + + It 'rejects duplicate immutable proof for one candidate edge as ambiguous' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' + $candidate = New-LeakPr ` + -Number 200 ` + -Title 'Ambiguous revert' ` + -Body 'Reverts #100' + $proof = "This reverts commit $($fix.mergeCommitOid)." + + @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @($fix) ` + -MergedPullRequests @($fix, $candidate) ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $candidate ` + -Messages @("$proof`n$proof") + ) + ).Count | Should -Be 0 + } + + It 'rejects a merge commit shared by multiple PR identities as ambiguous' { + $sharedOid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' ` + -MergeCommitOid $sharedOid + $other = New-LeakPr ` + -Number 101 ` + -Title 'Another PR with ambiguous commit identity' ` + -MergeCommitOid $sharedOid + $candidate = New-LeakPr ` + -Number 200 ` + -Title 'Claimed revert' ` + -Body 'Reverts #100' + + @( + Get-RelevantMergedLeakReverts ` + -Repository 'dotnet/maui' ` + -TargetPullRequests @($fix) ` + -MergedPullRequests @($fix, $other, $candidate) ` + -PullRequestCommitHistories @( + New-LeakCommitHistory ` + -PullRequest $candidate ` + -Messages @( + "Revert`n`nThis reverts commit $sharedOid." + ) + ) + ).Count | Should -Be 0 + } + It 'fails closed when recursive discovery exhausts the aggregate traversal budget' { + $fix = New-LeakPr ` + -Number 100 ` + -Title '[leak-fix] Fix Picker.ItemsSource leak' $mergedHistory = @( + $fix 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' @@ -1291,9 +1699,7 @@ Describe 'merged revert discovery from complete history' { { Get-RelevantMergedLeakReverts ` -Repository 'dotnet/maui' ` - -TargetPullRequests @( - [pscustomobject]@{ number = 100; baseRefName = 'main' } - ) ` + -TargetPullRequests @($fix) ` -MergedPullRequests $mergedHistory ` -MaximumTraversalPullRequests 3 } | Should -Throw '*exhausted the 3-PR aggregate traversal safety budget*' @@ -1409,7 +1815,9 @@ Describe 'workflow enforcement boundary' { $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 -Match '(?s)independent sibling reverts.*never cancel each other' + $workflow | Should -Match 'every edge in a revert-of-revert chain must carry its own exact proof' + $workflow | Should -Match '(?s)body references.*never establish revert state' $workflow | Should -Not -Match 'combined by parity|combine by parity' } @@ -1461,6 +1869,9 @@ Describe 'workflow enforcement boundary' { $module | Should -Match '\$MaximumPageQueries = 1000' $module | Should -Match '\$PageSize = 100' + $module | Should -Match '\$CommitBatchSize = 10' + $module | Should -Match '\$MaximumCommitPageQueries = 1000' + $module | Should -Match '\$MaximumCommitRecords = 20000' $module | Should -Match '\$MaximumTraversalPullRequests = 2000' @($wrapper, $fixGate, $hunterGate) | ForEach-Object { $_ | Should -Match 'Get-RelevantMergedLeakReverts' @@ -1469,7 +1880,7 @@ Describe 'workflow enforcement boundary' { } } - It 'uses complete cursor history with exact local revert verification' { + It 'uses complete cursor history with exact immutable revert verification' { $module = Get-Content -LiteralPath ( Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' ) -Raw @@ -1493,8 +1904,18 @@ Describe 'workflow enforcement boundary' { $module | Should -Match 'pageInfo' $module | Should -Match 'totalCount' $module | Should -Match 'endCursor' + $module | Should -Match 'mergeCommitOid' + $module | Should -Match 'commits\(first:' + $module | Should -Match 'This reverts commit' + $module | Should -Match 'verifiedRevertTargets' $module | Should -Match 'Get-LeakRevertTargets' $module | Should -Not -Match "'--search'|gh pr list" + $effectiveResolver = [regex]::Match( + $module, + '(?s)function Get-EffectiveRevertedPullRequestNumbers \{.*?Export-ModuleMember' + ).Value + $effectiveResolver | Should -Not -Match 'Get-LeakRevertTargets' + $effectiveResolver | Should -Not -Match '\.body' $wrapper | Should -Match 'MergedPullRequestsJsonPath' @($fixGate, $hunterGate) | ForEach-Object { $_ | Should -Match 'Get-CompleteLeakPullRequests' @@ -1512,6 +1933,9 @@ Describe 'workflow enforcement boundary' { $documentation | Should -Match 'capped server-directed rate-limit delays' $documentation | Should -Match '1000-query safety budget' $documentation | Should -Match '1000-discovery and 2000-PR aggregate' + $documentation | Should -Match 'merge/squash commit OID' + $documentation | Should -Match 'This reverts commit' + $documentation | Should -Match '20000' $documentation | Should -Not -Match '1000-result|Search API' } } @@ -1559,6 +1983,63 @@ Describe 'workflow enforcement boundary' { $queryArgument = @($GhArgs | Where-Object { $_.StartsWith('query=', [StringComparison]::Ordinal) })[0] + if ($queryArgument -match 'commits\(first:') { + $repository = @{} + $source = @($global:mockMerged) + @($global:mockReverts) + foreach ($argument in @($GhArgs | Where-Object { + $_ -match '^number(?[0-9]+)=(?[1-9][0-9]*)$' + })) { + $argument -match '^number(?[0-9]+)=(?[1-9][0-9]*)$' | + Out-Null + $index = [int]$Matches.index + $number = [int]$Matches.number + $pullRequest = @($source | Where-Object { + [int]$_.number -eq $number + }) + if ($pullRequest.Count -ne 1) { + throw "Unexpected mock commit-history PR #$number." + } + $messagesProperty = + $pullRequest[0].PSObject.Properties['commitMessages'] + $messages = if ($null -eq $messagesProperty) { + @() + } else { + @($messagesProperty.Value) + } + $commitIndex = 0 + $repository["pr$index"] = @{ + number = $number + state = 'MERGED' + merged = $true + mergedAt = $pullRequest[0].mergedAt + baseRefName = $pullRequest[0].baseRefName + mergeCommit = @{ + oid = $pullRequest[0].mergeCommitOid + } + commits = @{ + totalCount = $messages.Count + nodes = @($messages | ForEach-Object { + $commitIndex++ + @{ + commit = @{ + oid = '{0:x40}' -f ( + ($number * 1000) + $commitIndex + ) + message = $_ + } + } + }) + pageInfo = @{ + hasNextPage = $false + endCursor = $null + } + } + } + } + Write-Output (@{ data = @{ repository = $repository } } | + ConvertTo-Json -Depth 10 -Compress) + return + } $baseArgument = @($GhArgs | Where-Object { $_.StartsWith('base=', [StringComparison]::Ordinal) })[0] @@ -1590,6 +2071,12 @@ Describe 'workflow enforcement boundary' { } } $node.state = $state + $node.merged = $state -ceq 'MERGED' + $node.mergeCommit = if ($state -ceq 'MERGED') { + @{ oid = [string]$_.mergeCommitOid } + } else { + $null + } [pscustomobject]$node }) Write-Output (New-LeakGraphQlPageJson ` @@ -1881,17 +2368,19 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } 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" - ) + $fix = New-LeakPr ` + -Number 503 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + $global:mockMerged = @($fix) $global:mockReverts = @( New-LeakPr ` -Number 504 ` -Title 'Back out the collection cleanup' ` - -Body 'Reverts dotnet/maui#503' + -Body 'Reverts dotnet/maui#503' ` + -CommitMessages @( + "Revert cleanup`n`nThis reverts commit $($fix.mergeCommitOid)." + ) ) { @@ -1901,6 +2390,28 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim -Repository 'dotnet/maui' } | Should -Not -Throw } + + It 'blocks when editable merged-PR prose claims a revert without commit proof' { + $fix = New-LeakPr ` + -Number 503 ` + -Title '[leak-fix] Fix GradientBrush.GradientStops reset leak' ` + -Body "Fixes #20`nRefs: dotnet/maui#20" + $global:mockMerged = @($fix) + $global:mockReverts = @( + New-LeakPr ` + -Number 504 ` + -Title 'Unrelated merged change' ` + -Body 'Reverts dotnet/maui#503' ` + -CommitMessages @('No immutable revert association') + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1') ` + -AgentOutputPath $script:agentOutput ` + -StateDirectory $script:stateDirectory ` + -Repository 'dotnet/maui' + } | Should -Throw '*blocked PR creation*direct issue-reference match: 503*' + } } Context 'hunter safe-output gate script' { @@ -1953,6 +2464,64 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim -HasNextPage $false) return } + if ($queryArgument -match 'commits\(first:') { + $repository = @{} + $source = @($global:mockHunterMerged) + + @($global:mockHunterReverts) + foreach ($argument in @($GhArgs | Where-Object { + $_ -match '^number(?[0-9]+)=(?[1-9][0-9]*)$' + })) { + $argument -match '^number(?[0-9]+)=(?[1-9][0-9]*)$' | + Out-Null + $index = [int]$Matches.index + $number = [int]$Matches.number + $pullRequest = @($source | Where-Object { + [int]$_.number -eq $number + }) + if ($pullRequest.Count -ne 1) { + throw "Unexpected hunter mock commit-history PR #$number." + } + $messagesProperty = + $pullRequest[0].PSObject.Properties['commitMessages'] + $messages = if ($null -eq $messagesProperty) { + @() + } else { + @($messagesProperty.Value) + } + $commitIndex = 0 + $repository["pr$index"] = @{ + number = $number + state = 'MERGED' + merged = $true + mergedAt = $pullRequest[0].mergedAt + baseRefName = $pullRequest[0].baseRefName + mergeCommit = @{ + oid = $pullRequest[0].mergeCommitOid + } + commits = @{ + totalCount = $messages.Count + nodes = @($messages | ForEach-Object { + $commitIndex++ + @{ + commit = @{ + oid = '{0:x40}' -f ( + ($number * 1000) + $commitIndex + ) + message = $_ + } + } + }) + pageInfo = @{ + hasNextPage = $false + endCursor = $null + } + } + } + } + Write-Output (@{ data = @{ repository = $repository } } | + ConvertTo-Json -Depth 10 -Compress) + return + } $baseArgument = @($GhArgs | Where-Object { $_.StartsWith('base=', [StringComparison]::Ordinal) })[0] @@ -1979,6 +2548,10 @@ Same-API comparison: dotnet/maui#501 | Different mechanism: Agent-authored claim } } $node.state = 'MERGED' + $node.merged = $true + $node.mergeCommit = @{ + oid = [string]$_.mergeCommitOid + } [pscustomobject]$node }) Write-Output (New-LeakGraphQlPageJson ` diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index db619b581f64..439d20cdb34d 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -212,6 +212,40 @@ function Get-LeakRevertTargets { Sort-Object -Unique) } +function Get-NormalizedLeakMergeCommitOid { + param( + [Parameter(Mandatory = $true)][AllowNull()][object]$PullRequest, + [Parameter(Mandatory = $true)][string]$Context + ) + + if ($null -eq $PullRequest) { + throw "$Context is null." + } + $number = [string]$PullRequest.number + $record = if ([string]::IsNullOrWhiteSpace($number)) { + 'PR record' + } else { + "PR #$number" + } + $property = $PullRequest.PSObject.Properties['mergeCommitOid'] + if ($null -eq $property -or + $property.Value -isnot [string] -or + $property.Value -cnotmatch '^[0-9A-Fa-f]{40}$') { + throw "$Context $record has a missing or malformed mergeCommitOid." + } + + return ([string]$property.Value).ToLowerInvariant() +} + +function Get-LeakImmutableRevertCommitOids { + param([AllowEmptyString()][string]$Message) + + $pattern = + '(?m)^[ \t]*This reverts commit (?[0-9A-Fa-f]{40})\.[ \t]*$' + return @([regex]::Matches(($Message ?? ''), $pattern) | + ForEach-Object { $_.Groups['oid'].Value.ToLowerInvariant() }) +} + function Test-IsTransientLeakGhFailure { param([AllowEmptyString()][string]$Detail) @@ -622,7 +656,11 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str body baseRefName state + merged mergedAt + mergeCommit { + oid + } url } } @@ -689,6 +727,14 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str -Object $node ` -Name 'mergedAt' ` -Context "$context PR #$number" + $merged = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'merged' ` + -Context "$context PR #$number" + $mergeCommit = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'mergeCommit' ` + -Context "$context PR #$number" $url = Get-LeakRequiredPropertyValue ` -Object $node ` -Name 'url' ` @@ -702,13 +748,39 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str ($State -cne 'MERGED' -and $null -ne $mergedAt)) { throw "$context returned inconsistent mergedAt metadata for PR #$number." } + if ($merged -isnot [bool] -or + $merged -ne ($State -ceq 'MERGED')) { + throw "$context returned inconsistent merged metadata for PR #$number." + } + + $mergeCommitOid = $null + if ($State -ceq 'MERGED') { + if ($null -eq $mergeCommit) { + throw "$context returned no mergeCommit for merged PR #$number." + } + $mergeCommitOidValue = Get-LeakRequiredPropertyValue ` + -Object $mergeCommit ` + -Name 'oid' ` + -Context "$context PR #$number mergeCommit" + $mergeCommitOid = Get-NormalizedLeakMergeCommitOid ` + -PullRequest ([pscustomobject]@{ + number = $number + mergeCommitOid = $mergeCommitOidValue + }) ` + -Context $context + } elseif ($null -ne $mergeCommit) { + throw "$context returned a mergeCommit for unmerged PR #$number." + } $all.Add([pscustomobject]@{ number = $number title = $title body = $body baseRefName = $nodeBase + state = $State + merged = [bool]$merged mergedAt = $mergedAt + mergeCommitOid = $mergeCommitOid url = $url }) } @@ -717,6 +789,321 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str return @($all | Sort-Object number) } +function Get-CompleteLeakPullRequestCommitHistories { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$PullRequests, + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 50)][int]$BatchSize = 10, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000, + [ValidateRange(1, 100000)][int]$MaximumCommitRecords = 20000 + ) + + if ($PullRequests.Count -eq 0) { + return @() + } + + $coordinates = Get-LeakRepositoryCoordinates -Repository $Repository + $states = [System.Collections.Generic.List[object]]::new() + $seenNumbers = [System.Collections.Generic.HashSet[int]]::new() + foreach ($pullRequest in $PullRequests) { + $number = 0 + if (-not [int]::TryParse([string]$pullRequest.number, [ref]$number) -or + $number -le 0) { + throw "Invalid merged reverter PR number '$($pullRequest.number)'." + } + if (-not $seenNumbers.Add($number)) { + throw "Merged reverter commit-history input contains duplicate PR #$number." + } + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest $pullRequest ` + -Context 'Merged reverter commit-history input' + $mergeCommitOid = Get-NormalizedLeakMergeCommitOid ` + -PullRequest $pullRequest ` + -Context 'Merged reverter commit-history input' + + $states.Add([pscustomobject]@{ + Number = $number + BaseRefName = $base + MergeCommitOid = $mergeCommitOid + Cursor = $null + ExpectedTotal = [long]-1 + Complete = $false + Commits = [System.Collections.Generic.List[object]]::new() + SeenCommitOids = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + SeenCursors = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + }) + } + + $queryCount = 0 + $commitRecordCount = 0 + while (@($states | Where-Object { -not $_.Complete }).Count -gt 0) { + if ($queryCount -ge $MaximumPageQueries) { + throw "Merged reverter commit-history pagination exceeded the $MaximumPageQueries-query safety budget before proving every candidate complete." + } + $queryCount++ + + $batch = @($states | + Where-Object { -not $_.Complete } | + Select-Object -First $BatchSize) + $declarations = [System.Collections.Generic.List[string]]::new() + @('$owner: String!', '$name: String!', '$first: Int!') | + ForEach-Object { $declarations.Add($_) } + $selections = [System.Collections.Generic.List[string]]::new() + for ($index = 0; $index -lt $batch.Count; $index++) { + $declarations.Add(('$number{0}: Int!' -f $index)) + $declarations.Add(('$after{0}: String' -f $index)) + $selections.Add(@" + pr$index`: pullRequest(number: `$number$index) { + number + state + merged + mergedAt + baseRefName + mergeCommit { + oid + } + commits(first: `$first, after: `$after$index) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + commit { + oid + message + } + } + } + } +"@) + } + $query = @" +query($($declarations -join ', ')) { + repository(owner: `$owner, name: `$name) { +$($selections -join "`n") + } +} +"@ + + $arguments = [System.Collections.Generic.List[string]]::new() + @( + 'api', 'graphql', + '-f', "query=$query", + '-f', "owner=$($coordinates.Owner)", + '-f', "name=$($coordinates.Name)", + '-F', "first=$PageSize" + ) | ForEach-Object { $arguments.Add($_) } + for ($index = 0; $index -lt $batch.Count; $index++) { + $arguments.Add('-F') + $arguments.Add("number$index=$($batch[$index].Number)") + if (-not [string]::IsNullOrWhiteSpace([string]$batch[$index].Cursor)) { + $arguments.Add('-f') + $arguments.Add("after$index=$($batch[$index].Cursor)") + } + } + + $response = Invoke-LeakGhJson -Arguments $arguments.ToArray() + $errorsProperty = $response.PSObject.Properties['errors'] + if ($null -ne $errorsProperty -and @($errorsProperty.Value).Count -gt 0) { + throw 'Merged reverter commit-history pagination returned GraphQL errors.' + } + $data = Get-LeakRequiredPropertyValue ` + -Object $response ` + -Name 'data' ` + -Context 'Merged reverter commit-history response' + $repositoryNode = Get-LeakRequiredPropertyValue ` + -Object $data ` + -Name 'repository' ` + -Context 'Merged reverter commit-history response data' + + for ($index = 0; $index -lt $batch.Count; $index++) { + $state = $batch[$index] + $context = "Merged reverter commit-history PR #$($state.Number)" + $node = Get-LeakRequiredPropertyValue ` + -Object $repositoryNode ` + -Name "pr$index" ` + -Context 'Merged reverter commit-history repository' + $numberValue = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'number' ` + -Context $context + $number = 0 + if (-not [int]::TryParse([string]$numberValue, [ref]$number) -or + $number -ne $state.Number) { + throw "$context returned unexpected PR number '$numberValue'." + } + $nodeState = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'state' ` + -Context $context + $merged = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'merged' ` + -Context $context + $mergedAt = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'mergedAt' ` + -Context $context + if ($nodeState -isnot [string] -or $nodeState -cne 'MERGED' -or + $merged -isnot [bool] -or -not $merged -or + $null -eq $mergedAt) { + throw "$context is no longer an authoritatively merged PR." + } + $nodeBase = Get-NormalizedLeakBaseRefName ` + -PullRequest $node ` + -Context $context + if ($nodeBase -cne $state.BaseRefName) { + throw "$context changed baseRefName from '$($state.BaseRefName)' to '$nodeBase'." + } + $mergeCommit = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'mergeCommit' ` + -Context $context + if ($null -eq $mergeCommit) { + throw "$context returned no mergeCommit." + } + $currentMergeCommitOid = Get-NormalizedLeakMergeCommitOid ` + -PullRequest ([pscustomobject]@{ + number = $state.Number + mergeCommitOid = Get-LeakRequiredPropertyValue ` + -Object $mergeCommit ` + -Name 'oid' ` + -Context "$context mergeCommit" + }) ` + -Context $context + if ($currentMergeCommitOid -cne $state.MergeCommitOid) { + throw "$context mergeCommitOid changed from '$($state.MergeCommitOid)' to '$currentMergeCommitOid'." + } + + $connection = Get-LeakRequiredPropertyValue ` + -Object $node ` + -Name 'commits' ` + -Context $context + $totalValue = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'totalCount' ` + -Context "$context commits" + if ($totalValue -isnot [int] -and $totalValue -isnot [long]) { + throw "$context returned a malformed commit totalCount." + } + [long]$totalCount = $totalValue + if ($totalCount -lt 0) { + throw "$context returned a negative commit totalCount." + } + if ($state.ExpectedTotal -lt 0) { + $state.ExpectedTotal = $totalCount + } elseif ($totalCount -ne $state.ExpectedTotal) { + throw "$context commit totalCount changed from $($state.ExpectedTotal) to $totalCount during pagination." + } + + $nodesValue = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'nodes' ` + -Context "$context commits" + if ($nodesValue -isnot [System.Array]) { + throw "$context returned malformed commit nodes instead of an array." + } + $nodes = @($nodesValue) + if ($nodes.Count -gt $PageSize) { + throw "$context returned $($nodes.Count) commits for a $PageSize-commit page." + } + foreach ($commitNode in $nodes) { + if ($commitRecordCount -ge $MaximumCommitRecords) { + throw "Merged reverter commit-history pagination exceeded the $MaximumCommitRecords-commit aggregate safety budget." + } + $commit = Get-LeakRequiredPropertyValue ` + -Object $commitNode ` + -Name 'commit' ` + -Context "$context commit node" + $oidValue = Get-LeakRequiredPropertyValue ` + -Object $commit ` + -Name 'oid' ` + -Context "$context commit" + if ($oidValue -isnot [string] -or + $oidValue -cnotmatch '^[0-9A-Fa-f]{40}$') { + throw "$context returned a malformed commit oid." + } + $oid = $oidValue.ToLowerInvariant() + if (-not $state.SeenCommitOids.Add($oid)) { + throw "$context returned duplicate commit '$oid' across pages." + } + $message = Get-LeakRequiredPropertyValue ` + -Object $commit ` + -Name 'message' ` + -Context "$context commit $oid" + if ($message -isnot [string]) { + throw "$context commit '$oid' returned a malformed message." + } + $state.Commits.Add([pscustomobject]@{ + oid = $oid + message = $message + }) + $commitRecordCount++ + } + if ($state.Commits.Count -gt $state.ExpectedTotal) { + throw "$context returned more unique commits than totalCount $($state.ExpectedTotal)." + } + + $pageInfo = Get-LeakRequiredPropertyValue ` + -Object $connection ` + -Name 'pageInfo' ` + -Context "$context commits" + $hasNextPage = Get-LeakRequiredPropertyValue ` + -Object $pageInfo ` + -Name 'hasNextPage' ` + -Context "$context commit pageInfo" + if ($hasNextPage -isnot [bool]) { + throw "$context returned malformed commit hasNextPage metadata." + } + $endCursor = Get-LeakRequiredPropertyValue ` + -Object $pageInfo ` + -Name 'endCursor' ` + -Context "$context commit pageInfo" + + if (-not $hasNextPage) { + if ($state.Commits.Count -ne $state.ExpectedTotal) { + throw "$context ended after $($state.Commits.Count) unique commits but totalCount is $($state.ExpectedTotal)." + } + $state.Complete = $true + continue + } + if ($nodes.Count -eq 0) { + throw "$context claimed another commit page after returning an empty page." + } + if ($state.Commits.Count -ge $state.ExpectedTotal) { + throw "$context claimed another commit page after already returning totalCount $($state.ExpectedTotal)." + } + if ($endCursor -isnot [string] -or + [string]::IsNullOrWhiteSpace($endCursor)) { + throw "$context claimed another commit page without a usable endCursor." + } + if (-not $state.SeenCursors.Add($endCursor)) { + throw "$context repeated commit endCursor '$endCursor'." + } + $state.Cursor = $endCursor + } + } + + return @($states | ForEach-Object { + [pscustomobject]@{ + number = $_.Number + state = 'MERGED' + merged = $true + baseRefName = $_.BaseRefName + mergeCommitOid = $_.MergeCommitOid + commits = @($_.Commits) + } + } | Sort-Object number) +} + function Get-CompleteLeakIssues { param( [Parameter(Mandatory = $true)][string]$Repository, @@ -797,10 +1184,71 @@ function Get-RelevantMergedLeakReverts { [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$TargetPullRequests, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedPullRequests, + [AllowNull()][AllowEmptyCollection()][object[]]$PullRequestCommitHistories = $null, [ValidateRange(1, 1000)][int]$MaximumDiscoveredPullRequests = 1000, - [ValidateRange(1, 2000)][int]$MaximumTraversalPullRequests = 2000 + [ValidateRange(1, 2000)][int]$MaximumTraversalPullRequests = 2000, + [ValidateRange(1, 100)][int]$CommitPageSize = 100, + [ValidateRange(1, 50)][int]$CommitBatchSize = 10, + [ValidateRange(1, 5000)][int]$MaximumCommitPageQueries = 1000, + [ValidateRange(1, 100000)][int]$MaximumCommitRecords = 20000 ) + $historyByNumber = @{} + $numbersByMergeCommit = @{} + foreach ($row in $MergedPullRequests) { + $number = 0 + if (-not [int]::TryParse([string]$row.number, [ref]$number) -or + $number -le 0) { + throw "Invalid merged-revert history PR number '$($row.number)'." + } + if ($historyByNumber.ContainsKey($number)) { + throw "Complete merged pull-request history contains duplicate PR #$number." + } + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest $row ` + -Context 'Complete merged pull-request history' + $state = Get-LeakRequiredPropertyValue ` + -Object $row ` + -Name 'state' ` + -Context "Complete merged pull-request history PR #$number" + $merged = Get-LeakRequiredPropertyValue ` + -Object $row ` + -Name 'merged' ` + -Context "Complete merged pull-request history PR #$number" + $mergedAt = Get-LeakRequiredPropertyValue ` + -Object $row ` + -Name 'mergedAt' ` + -Context "Complete merged pull-request history PR #$number" + if ($state -isnot [string] -or $state -cne 'MERGED' -or + $merged -isnot [bool] -or -not $merged -or + $null -eq $mergedAt) { + throw "Complete merged pull-request history PR #$number is not authoritatively merged." + } + $mergeCommitOid = Get-NormalizedLeakMergeCommitOid ` + -PullRequest $row ` + -Context 'Complete merged pull-request history' + + $normalized = [pscustomobject]@{ + number = $number + title = [string]$row.title + body = [string]$row.body + baseRefName = $base + state = 'MERGED' + merged = $true + mergedAt = $mergedAt + mergeCommitOid = $mergeCommitOid + url = [string]$row.url + } + $historyByNumber[$number] = $normalized + + $commitIdentity = "$base`0$mergeCommitOid" + if (-not $numbersByMergeCommit.ContainsKey($commitIdentity)) { + $numbersByMergeCommit[$commitIdentity] = + [System.Collections.Generic.List[int]]::new() + } + $numbersByMergeCommit[$commitIdentity].Add($number) + } + $queue = [System.Collections.Generic.Queue[object]]::new() $branches = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::Ordinal @@ -815,6 +1263,18 @@ function Get-RelevantMergedLeakReverts { if ([string]::IsNullOrWhiteSpace($base)) { throw "Revert-discovery target PR #$number is missing baseRefName." } + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest ([pscustomobject]@{ + number = $number + baseRefName = $base + }) ` + -Context 'Revert-discovery target' + if (-not $historyByNumber.ContainsKey($number)) { + throw "Revert-discovery target PR #$number is missing from complete merged history." + } + if ([string]$historyByNumber[$number].baseRefName -cne $base) { + throw "Revert-discovery target PR #$number has a base branch that conflicts with complete merged history." + } if ($branchByNumber.ContainsKey($number)) { if ([string]$branchByNumber[$number] -cne $base) { throw "Revert-discovery target PR #$number has conflicting base branches." @@ -832,52 +1292,42 @@ function Get-RelevantMergedLeakReverts { }) } - $revertersByTarget = @{} - foreach ($row in $MergedPullRequests) { - if ($null -eq $row.mergedAt) { - continue - } - $base = Get-NormalizedLeakBaseRefName ` - -PullRequest $row ` - -Context 'Complete merged pull-request history' + $bodyRevertersByTarget = @{} + foreach ($row in $historyByNumber.Values) { + $base = [string]$row.baseRefName if (-not $branches.Contains($base)) { continue } - $reverter = 0 - if (-not [int]::TryParse([string]$row.number, [ref]$reverter) -or - $reverter -le 0) { - throw "Invalid merged-revert history PR number '$($row.number)'." - } foreach ($targetNumber in @( Get-LeakRevertTargets ` -Body ([string]$row.body) ` -Repository $Repository )) { - if (-not $revertersByTarget.ContainsKey($targetNumber)) { - $revertersByTarget[$targetNumber] = + if (-not $bodyRevertersByTarget.ContainsKey($targetNumber)) { + $bodyRevertersByTarget[$targetNumber] = [System.Collections.Generic.List[object]]::new() } - $revertersByTarget[$targetNumber].Add($row) + $bodyRevertersByTarget[$targetNumber].Add($row) } } $traversed = [System.Collections.Generic.HashSet[int]]::new() - $discovered = @{} + $candidateReverters = @{} while ($queue.Count -gt 0) { $target = $queue.Dequeue() $targetNumber = [int]$target.number if (-not $traversed.Add($targetNumber) -or - -not $revertersByTarget.ContainsKey($targetNumber)) { + -not $bodyRevertersByTarget.ContainsKey($targetNumber)) { continue } - foreach ($row in $revertersByTarget[$targetNumber]) { + foreach ($row in $bodyRevertersByTarget[$targetNumber]) { if ([string]$row.baseRefName -cne [string]$target.baseRefName) { continue } $reverter = [int]$row.number - if (-not $discovered.ContainsKey($reverter)) { - if ($discovered.Count -ge $MaximumDiscoveredPullRequests) { + if (-not $candidateReverters.ContainsKey($reverter)) { + if ($candidateReverters.Count -ge $MaximumDiscoveredPullRequests) { throw "Relevant merged-revert discovery exceeded the $MaximumDiscoveredPullRequests-PR safety bound." } if (-not $branchByNumber.ContainsKey($reverter)) { @@ -889,7 +1339,7 @@ function Get-RelevantMergedLeakReverts { [string]$row.baseRefName) { throw "Discovered merged-revert PR #$reverter has conflicting base branches." } - $discovered[$reverter] = $row + $candidateReverters[$reverter] = $row $queue.Enqueue([pscustomobject]@{ number = $reverter baseRefName = [string]$row.baseRefName @@ -898,7 +1348,195 @@ function Get-RelevantMergedLeakReverts { } } - return @($discovered.Values | Sort-Object number) + if ($candidateReverters.Count -eq 0) { + return @() + } + + $commitHistories = if ($null -eq $PullRequestCommitHistories) { + @( + Get-CompleteLeakPullRequestCommitHistories ` + -Repository $Repository ` + -PullRequests @($candidateReverters.Values) ` + -PageSize $CommitPageSize ` + -BatchSize $CommitBatchSize ` + -MaximumPageQueries $MaximumCommitPageQueries ` + -MaximumCommitRecords $MaximumCommitRecords + ) + } else { + @($PullRequestCommitHistories) + } + + $commitHistoryByNumber = @{} + foreach ($commitHistory in $commitHistories) { + $number = 0 + if (-not [int]::TryParse([string]$commitHistory.number, [ref]$number) -or + $number -le 0) { + throw "Invalid merged reverter commit-history PR number '$($commitHistory.number)'." + } + if (-not $candidateReverters.ContainsKey($number)) { + throw "Merged reverter commit history unexpectedly contains non-candidate PR #$number." + } + if ($commitHistoryByNumber.ContainsKey($number)) { + throw "Merged reverter commit history contains duplicate PR #$number." + } + $state = Get-LeakRequiredPropertyValue ` + -Object $commitHistory ` + -Name 'state' ` + -Context "Merged reverter commit history PR #$number" + $merged = Get-LeakRequiredPropertyValue ` + -Object $commitHistory ` + -Name 'merged' ` + -Context "Merged reverter commit history PR #$number" + if ($state -isnot [string] -or $state -cne 'MERGED' -or + $merged -isnot [bool] -or -not $merged) { + throw "Merged reverter commit history PR #$number is not authoritatively merged." + } + $base = Get-NormalizedLeakBaseRefName ` + -PullRequest $commitHistory ` + -Context 'Merged reverter commit history' + if ($base -cne [string]$candidateReverters[$number].baseRefName) { + throw "Merged reverter commit history PR #$number has an unexpected base branch." + } + $mergeCommitOid = Get-NormalizedLeakMergeCommitOid ` + -PullRequest $commitHistory ` + -Context 'Merged reverter commit history' + if ($mergeCommitOid -cne + [string]$candidateReverters[$number].mergeCommitOid) { + throw "Merged reverter commit history PR #$number has an unexpected mergeCommitOid." + } + $commits = Get-LeakRequiredPropertyValue ` + -Object $commitHistory ` + -Name 'commits' ` + -Context "Merged reverter commit history PR #$number" + if ($commits -isnot [System.Array]) { + throw "Merged reverter commit history PR #$number has malformed commits." + } + + $proofOids = [System.Collections.Generic.List[string]]::new() + $seenCommitOids = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + foreach ($commit in @($commits)) { + $oid = Get-LeakRequiredPropertyValue ` + -Object $commit ` + -Name 'oid' ` + -Context "Merged reverter commit history PR #$number commit" + if ($oid -isnot [string] -or + $oid -cnotmatch '^[0-9A-Fa-f]{40}$' -or + -not $seenCommitOids.Add($oid)) { + throw "Merged reverter commit history PR #$number has a malformed or duplicate commit oid." + } + $message = Get-LeakRequiredPropertyValue ` + -Object $commit ` + -Name 'message' ` + -Context "Merged reverter commit history PR #$number commit" + if ($message -isnot [string]) { + throw "Merged reverter commit history PR #$number has a malformed commit message." + } + foreach ($proofOid in @(Get-LeakImmutableRevertCommitOids -Message $message)) { + $proofOids.Add($proofOid) + } + } + $commitHistoryByNumber[$number] = [pscustomobject]@{ + ProofOids = @($proofOids) + } + } + if ($commitHistoryByNumber.Count -ne $candidateReverters.Count) { + $missing = @($candidateReverters.Keys | + Where-Object { -not $commitHistoryByNumber.ContainsKey([int]$_) } | + Sort-Object) + throw "Merged reverter commit history is incomplete; missing candidate PRs: $($missing -join ', ')." + } + + $verifiedRevertersByTarget = @{} + foreach ($row in $candidateReverters.Values) { + $number = [int]$row.number + $verifiedTargets = [System.Collections.Generic.List[int]]::new() + foreach ($targetNumber in @( + Get-LeakRevertTargets ` + -Body ([string]$row.body) ` + -Repository $Repository + )) { + if (-not $historyByNumber.ContainsKey($targetNumber)) { + continue + } + $target = $historyByNumber[$targetNumber] + if ([string]$target.baseRefName -cne [string]$row.baseRefName) { + continue + } + + $targetMergeCommitOid = [string]$target.mergeCommitOid + $commitIdentity = + "$($target.baseRefName)`0$targetMergeCommitOid" + if ($numbersByMergeCommit[$commitIdentity].Count -ne 1) { + continue + } + $proofCount = @( + $commitHistoryByNumber[$number].ProofOids | + Where-Object { $_ -ceq $targetMergeCommitOid } + ).Count + if ($proofCount -ne 1) { + continue + } + $verifiedTargets.Add($targetNumber) + } + + if ($verifiedTargets.Count -eq 0) { + continue + } + $verified = [pscustomobject]@{ + number = $number + title = [string]$row.title + body = [string]$row.body + baseRefName = [string]$row.baseRefName + state = 'MERGED' + merged = $true + mergedAt = $row.mergedAt + mergeCommitOid = [string]$row.mergeCommitOid + url = [string]$row.url + verifiedRevertTargets = @($verifiedTargets | Sort-Object -Unique) + } + foreach ($targetNumber in $verified.verifiedRevertTargets) { + if (-not $verifiedRevertersByTarget.ContainsKey($targetNumber)) { + $verifiedRevertersByTarget[$targetNumber] = + [System.Collections.Generic.List[object]]::new() + } + $verifiedRevertersByTarget[$targetNumber].Add($verified) + } + } + + $verifiedQueue = [System.Collections.Generic.Queue[object]]::new() + foreach ($target in $TargetPullRequests) { + $verifiedQueue.Enqueue([pscustomobject]@{ + number = [int]$target.number + baseRefName = [string]$target.baseRefName + }) + } + $verifiedTraversal = [System.Collections.Generic.HashSet[int]]::new() + $verifiedDiscovered = @{} + while ($verifiedQueue.Count -gt 0) { + $target = $verifiedQueue.Dequeue() + $targetNumber = [int]$target.number + if (-not $verifiedTraversal.Add($targetNumber) -or + -not $verifiedRevertersByTarget.ContainsKey($targetNumber)) { + continue + } + foreach ($row in $verifiedRevertersByTarget[$targetNumber]) { + if ([string]$row.baseRefName -cne [string]$target.baseRefName) { + continue + } + $reverter = [int]$row.number + if (-not $verifiedDiscovered.ContainsKey($reverter)) { + $verifiedDiscovered[$reverter] = $row + $verifiedQueue.Enqueue([pscustomobject]@{ + number = $reverter + baseRefName = [string]$row.baseRefName + }) + } + } + } + + return @($verifiedDiscovered.Values | Sort-Object number) } function Assert-LeakDedupState { @@ -1006,6 +1644,7 @@ function Get-EffectiveRevertedPullRequestNumbers { [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$MergedRevertPullRequests ) + [void](Get-LeakRepositoryCoordinates -Repository $Repository) $revertersByTarget = @{} $branchByNumber = @{} $fixNumbers = [System.Collections.Generic.List[int]]::new() @@ -1035,6 +1674,26 @@ function Get-EffectiveRevertedPullRequestNumbers { if ([string]::IsNullOrWhiteSpace($base)) { throw "Merged-revert PR #$reverter is missing baseRefName." } + $state = Get-LeakRequiredPropertyValue ` + -Object $revert ` + -Name 'state' ` + -Context "Merged-revert PR #$reverter" + $merged = Get-LeakRequiredPropertyValue ` + -Object $revert ` + -Name 'merged' ` + -Context "Merged-revert PR #$reverter" + $mergedAt = Get-LeakRequiredPropertyValue ` + -Object $revert ` + -Name 'mergedAt' ` + -Context "Merged-revert PR #$reverter" + if ($state -isnot [string] -or $state -cne 'MERGED' -or + $merged -isnot [bool] -or -not $merged -or + $null -eq $mergedAt) { + throw "Merged-revert PR #$reverter is not authoritatively merged." + } + [void](Get-NormalizedLeakMergeCommitOid ` + -PullRequest $revert ` + -Context 'Merged-revert history') if ($branchByNumber.ContainsKey($reverter) -and $branchByNumber[$reverter] -cne $base) { throw "PR #$reverter has conflicting base branches." @@ -1045,11 +1704,22 @@ function Get-EffectiveRevertedPullRequestNumbers { foreach ($revert in $MergedRevertPullRequests) { $reverter = [int]$revert.number $reverterBase = [string]$revert.baseRefName - foreach ($target in @( - Get-LeakRevertTargets ` - -Body ([string]$revert.body) ` - -Repository $Repository - )) { + $verifiedTargetsProperty = + $revert.PSObject.Properties['verifiedRevertTargets'] + if ($null -eq $verifiedTargetsProperty) { + continue + } + if ($verifiedTargetsProperty.Value -isnot [System.Array]) { + throw "Merged-revert PR #$reverter has malformed verifiedRevertTargets." + } + $seenTargets = [System.Collections.Generic.HashSet[int]]::new() + foreach ($targetValue in @($verifiedTargetsProperty.Value)) { + $target = 0 + if (-not [int]::TryParse([string]$targetValue, [ref]$target) -or + $target -le 0 -or + -not $seenTargets.Add($target)) { + throw "Merged-revert PR #$reverter has a malformed or duplicate verified target." + } if (-not $branchByNumber.ContainsKey($target) -or $branchByNumber[$target] -cne $reverterBase) { continue @@ -1134,6 +1804,7 @@ Export-ModuleMember -Function ` Get-LeakRevertTargets, ` Invoke-LeakGhJson, ` Get-CompleteLeakPullRequests, ` + Get-CompleteLeakPullRequestCommitHistories, ` Get-CompleteLeakIssues, ` Get-RelevantMergedLeakReverts, ` Assert-LeakDedupState, ` diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index ffadfc2b7c36..3c368a031e8c 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":"7599797f0a1b460cf63b295ece6bae886e02c5120db421f62938ebec059855f5","body_hash":"649561eb6ec622d5f5802a09f00f31905eac8387d10466de291e1714abbc2465","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":"c81b1eb87b6868b6194376d9b3d8be686435ea0624c3870077f6555e335c9a49","body_hash":"41cc2853bf68817886c30172372b3b63c38c1ed5d0e3ce63cb0221ea337441ad","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 # @@ -505,7 +505,7 @@ jobs: 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).\n# The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo,\n# and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination.\npwsh .github/scripts/Get-CompleteLeakIssues.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json\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.\n# Fetch complete non-Search history once per authoritative base. Pagination is bounded by\n# an explicit query budget rather than a result cutoff; reaching the budget is a visible\n# fail-closed error, never a silently truncated history.\npwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -State MERGED \\\n -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | test(\"^\\\\[leak-fix\\\\][ \\\\t]+\")) |\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# Reuse the same complete merged-PR history, index only exact repository-local direct\n# references, and traverse recursive reverter chains locally. The history fetch uses\n# 100-node GraphQL cursor pages, stable total-count validation, bounded transient retries,\n# capped server-directed rate-limit delays, and a 1000-query safety budget. Local\n# traversal keeps the existing 1000-discovery and 2000-PR aggregate bounds, so seed count\n# never multiplies GitHub queries.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\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" + 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).\n# The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo,\n# and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination.\npwsh .github/scripts/Get-CompleteLeakIssues.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json\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.\n# Fetch complete non-Search history once per authoritative base. Pagination is bounded by\n# an explicit query budget rather than a result cutoff; reaching the budget is a visible\n# fail-closed error, never a silently truncated history.\npwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -State MERGED \\\n -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | test(\"^\\\\[leak-fix\\\\][ \\\\t]+\")) |\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# Repository-local \"Reverts #\" body references narrow the candidate set but are\n# editable and never establish revert state themselves. Resolve only independently\n# verified links recursively: any active same-branch direct reverter keeps its target\n# reverted. Reverting a reverter can deactivate it, but independent sibling reverts\n# never cancel each other.\n# Reuse the same complete merged-PR history, including each original merge/squash commit\n# OID. Editable `Reverts #N` body lines identify candidates only. The helper batches\n# candidate PR commit-history queries and accepts an edge only when a complete immutable\n# commit message contains exactly one full `This reverts commit <40-hex-target-OID>.`\n# proof. Wrong, abbreviated, duplicate, ambiguous, cross-branch, truncated, or over-budget\n# evidence leaves the original fix active or fails closed. Both merged-history and commit\n# pagination use stable totalCount/pageInfo validation, bounded transient retries, capped\n# server-directed rate-limit delays, and 1000-query safety budgets; commit verification is\n# batched 10 PRs at a time, pages 100 commits, and caps aggregate records at 20000. Local\n# traversal keeps the 1000-discovery and 2000-PR aggregate bounds.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\n -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json\n# Resolve the EFFECTIVE state recursively, not just one hop. An immutably verified merged\n# revert edge toggles its target only while that revert itself remains active on the SAME\n# base branch; every edge in a revert-of-revert chain must carry its own exact proof.\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 diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 231212bd6576..8f11d6db7ff7 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -140,24 +140,29 @@ pre-agent-steps: # 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. - # Reuse the same complete merged-PR history, index only exact repository-local direct - # references, and traverse recursive reverter chains locally. The history fetch uses - # 100-node GraphQL cursor pages, stable total-count validation, bounded transient retries, - # capped server-directed rate-limit delays, and a 1000-query safety budget. Local - # traversal keeps the existing 1000-discovery and 2000-PR aggregate bounds, so seed count - # never multiplies GitHub queries. + # Repository-local "Reverts #" body references narrow the candidate set but are + # editable and never establish revert state themselves. Resolve only independently + # verified links recursively: any active same-branch direct reverter keeps its target + # reverted. Reverting a reverter can deactivate it, but independent sibling reverts + # never cancel each other. + # Reuse the same complete merged-PR history, including each original merge/squash commit + # OID. Editable `Reverts #N` body lines identify candidates only. The helper batches + # candidate PR commit-history queries and accepts an edge only when a complete immutable + # commit message contains exactly one full `This reverts commit <40-hex-target-OID>.` + # proof. Wrong, abbreviated, duplicate, ambiguous, cross-branch, truncated, or over-budget + # evidence leaves the original fix active or fails closed. Both merged-history and commit + # pagination use stable totalCount/pageInfo validation, bounded transient retries, capped + # server-directed rate-limit delays, and 1000-query safety budgets; commit verification is + # batched 10 PRs at a time, pages 100 commits, and caps aggregate records at 20000. Local + # traversal keeps the 1000-discovery and 2000-PR aggregate bounds. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \ -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. + # Resolve the EFFECTIVE state recursively, not just one hop. An immutably verified merged + # revert edge toggles its target only while that revert itself remains active on the SAME + # base branch; every edge in a revert-of-revert chain must carry its own exact proof. # 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. @@ -313,13 +318,14 @@ echo "already-merged fix APIs:"; cat /tmp/gh-aw/agent/already-merged-fix-apis.ts - `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. + recursively and per base branch. Editable repository-local `Reverts #` body references + only identify candidates; every edge also requires exactly one full target merge/squash + commit OID in an immutable `This reverts commit <40-hex-OID>.` commit-message line from a + complete merged candidate history. Any active same-branch verified direct reverter excludes + its target. Reverting a reverter can deactivate that reverter only when that next edge is + independently verified; sibling reverts never cancel each other. A servicing-branch revert + cannot toggle a main/inflight fix. Missing, ambiguous, malformed, truncated, or over-budget + evidence retains the original fix as active or blocks the run. - 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 diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index fad92fbbe32e..7a5b3c97b832 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":"904f5e7198714e63042518c4e15989be57d04d917590f91e1f831e0795018cb9","body_hash":"96269347ba609ea7f2911f1111c4969f018593f3eb6ef07e37e61a4258e9bcbc","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":"8f5b8b7b2ab54f2a791bd8d8a1b05f325a31d2ae5ba853caa91198495a6d810d","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 # diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 8a69a6086a11..77d477414a04 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -473,10 +473,14 @@ jq -r '.[] | [.number, .title, .baseRefName, .url] | @tsv' \ | sort -u \ > /tmp/gh-aw/agent/merged-leak-fix-apis.tsv -# A merged fix is not authoritative if it was later effectively reverted. Reuse the complete -# merged-PR history above, index only exact repository-local direct references, and traverse -# recursive same-branch reverter chains locally under 1000-discovery and 2000-PR aggregate -# bounds. Seed count never multiplies GitHub queries. +# A merged fix is not authoritative if it was later effectively reverted. The complete history +# carries each original merge/squash commit OID. Editable `Reverts #N` body text identifies +# candidates only; the helper batches complete candidate commit histories (10 PRs/query, +# 100 commits/page) and accepts an edge only for exactly one full immutable +# `This reverts commit <40-hex-target-OID>.` proof on the same base. Wrong, abbreviated, +# duplicate, ambiguous, cross-branch, truncated, or over-budget evidence leaves the fix active +# or fails closed. Commit verification has a 1000-query and 20000-record budget; recursive +# traversal retains the 1000-discovery and 2000-PR aggregate bounds. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/merged-leak-fix-prs.tsv \ @@ -805,7 +809,8 @@ jq '[.[] | 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. +# The shared helper repeats the same complete, batched immutable-proof verification and bounds +# 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 \ From cf4ab2229e99b8d5fbc09641935af5842405b500 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Wed, 19 Aug 2026 23:00:05 +0200 Subject: [PATCH 63/68] Harden leak gate input validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Assert-LeakHunterSafeOutputGate.ps1 | 28 +++- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 116 ++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 134 +++++++++++++++++- 3 files changed, 266 insertions(+), 12 deletions(-) diff --git a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 index ff03da53e618..e817ac1e4108 100644 --- a/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakHunterSafeOutputGate.ps1 @@ -54,6 +54,26 @@ foreach ($item in $createItems) { $openIssues = @( Get-CompleteLeakIssues -Repository $Repository ) +$openIssueApis = @( + foreach ($issue in $openIssues) { + $issueNumber = [string]$issue.number + $context = if ([string]::IsNullOrWhiteSpace($issueNumber)) { + 'Open issue' + } else { + "Open issue #$issueNumber" + } + $issueApi = Get-ValidatedExistingLeakApi ` + -Title ([string]$issue.title) ` + -Kind Scan ` + -Context $context + if (-not [string]::IsNullOrWhiteSpace($issueApi)) { + [pscustomobject]@{ + Api = $issueApi + Number = $issue.number + } + } + } +) $merged = @( Get-CompleteLeakPullRequests ` @@ -94,13 +114,11 @@ $eligibleMerged = @($eligibleMerged | Where-Object { # 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 - (Test-LeakTitlePrefix -Title $issueTitle -Kind Scan) -and - (Get-CanonicalExistingLeakApi -Title $issueTitle) -ceq $api + $openApiMatches = @($openIssueApis | Where-Object { + [string]$_.Api -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." + 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 { diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index f8b82c6edce3..13f753a66113 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -417,17 +417,63 @@ Describe 'shared regular JSON file validation' { (Read-RegularJsonFile -Path $path).value | Should -Be 42 } - It 'rejects missing, empty, oversized, and invalid JSON files' { + It 'accepts valid JSON at the exact byte boundary' { + $path = Join-Path $TestDrive 'exact-boundary.json' + $content = '{"value":42}' + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($content) + [System.IO.File]::WriteAllBytes($path, $bytes) + + (Read-RegularJsonFile ` + -Path $path ` + -MaximumBytes $bytes.Length).value | Should -Be 42 + } + + It 'rejects an over-bound file before parsing its content' { + $path = Join-Path $TestDrive 'over-bound.json' + $content = '{"value":42}' + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($content) + [System.IO.File]::WriteAllBytes($path, $bytes) + + { + Read-RegularJsonFile ` + -Path $path ` + -MaximumBytes ($bytes.Length - 1) + } | Should -Throw '*empty or too large*' + } + + It 'enforces MaximumBytes as bytes for multibyte JSON content' { + $path = Join-Path $TestDrive 'multibyte.json' + $content = '{"value":"é"}' + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($content) + [System.IO.File]::WriteAllBytes($path, $bytes) + + { + Read-RegularJsonFile ` + -Path $path ` + -MaximumBytes $content.Length + } | Should -Throw '*empty or too large*' + (Read-RegularJsonFile ` + -Path $path ` + -MaximumBytes $bytes.Length).value | Should -Be 'é' + } + + It 'rejects missing, zero-byte, whitespace-only, 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 '' + [System.IO.File]::WriteAllBytes($emptyPath, [byte[]]::new(0)) { Read-RegularJsonFile -Path $emptyPath } | Should -Throw '*empty or too large*' + $whitespacePath = Join-Path $TestDrive 'whitespace.json' + Set-Content -LiteralPath $whitespacePath -Value '' + { + Read-RegularJsonFile -Path $whitespacePath + } | Should -Throw '*empty or too large*' + $oversizedPath = Join-Path $TestDrive 'oversized.json' Set-Content -LiteralPath $oversizedPath -Value ('x' * (1MB + 1)) -NoNewline { @@ -2781,6 +2827,72 @@ Same-API comparison: dotnet/maui#701 | Different mechanism: Agent-authored claim } | Should -Throw "*blocked issue creation for 'GradientBrush.GradientStops'*702*" } + It 'accepts space and tab grammar when validating existing open scan titles' { + foreach ($separator in @(' ', "`t")) { + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 702 + title = "[leak-scan]${separator}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 'fails closed on malformed or ambiguous expected-prefix open titles' -ForEach @( + @{ ExistingTitle = '[leak-scan]' } + @{ ExistingTitle = '[leak-scan] Investigate GradientBrush.GradientStops' } + @{ ExistingTitle = '[leak-scan]x GradientBrush.GradientStops' } + @{ ExistingTitle = '[leak-scanx] GradientBrush.GradientStops' } + @{ ExistingTitle = '[LEAK-SCAN] GradientBrush.GradientStops' } + @{ ExistingTitle = '[leak-scan] [leak-scan] GradientBrush.GradientStops' } + ) { + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 704 + title = $ExistingTitle + body = 'Malformed scanner issue' + url = 'https://github.com/dotnet/maui/issues/704' + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Throw '*malformed*leak-scan*' + } + + It 'ignores unrelated open issue titles' { + $global:mockHunterOpenIssues = @( + [pscustomobject]@{ + number = 705 + title = 'Document the [leak-scan] issue format' + body = 'Unrelated issue' + url = 'https://github.com/dotnet/maui/issues/705' + } + [pscustomobject]@{ + number = 706 + title = '[leak-fix] Fix GradientBrush.GradientStops reset leak' + body = 'Unrelated issue kind' + url = 'https://github.com/dotnet/maui/issues/706' + } + ) + + { + & (Join-Path $PSScriptRoot 'Assert-LeakHunterSafeOutputGate.ps1') ` + -AgentOutputPath $script:hunterAgentOutput ` + -Repository 'dotnet/maui' + } | Should -Not -Throw + } + It 'blocks a legacy Shell-prefixed same-API open issue' { $output = Get-Content -LiteralPath $script:hunterAgentOutput -Raw | ConvertFrom-Json $output.items[0].title = diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 439d20cdb34d..8e09d862130e 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -92,21 +92,144 @@ function Get-CanonicalExistingLeakApi { return ConvertTo-CanonicalLeakApi -Api $match.Groups['api'].Value } -function Read-RegularJsonFile { +function Get-ValidatedExistingLeakApi { param( - [Parameter(Mandatory = $true)][string]$Path, - [ValidateRange(1, 256MB)][long]$MaximumBytes = 1MB + [AllowEmptyString()][string]$Title, + [Parameter(Mandatory = $true)] + [ValidateSet('Scan', 'Fix')] + [string]$Kind, + [Parameter(Mandatory = $true)][string]$Context ) + if ([string]::IsNullOrWhiteSpace($Title)) { + return $null + } + + $normalized = ($Title -replace "[`r`n]+", ' ').Trim() + $tag = if ($Kind -eq 'Scan') { + '[leak-scan]' + } else { + '[leak-fix]' + } + $tagStem = $tag.Substring(0, $tag.Length - 1) + if (-not $normalized.StartsWith( + $tagStem, + [System.StringComparison]::OrdinalIgnoreCase + )) { + return $null + } + + if (-not $normalized.StartsWith( + $tag, + [System.StringComparison]::Ordinal + ) -or + -not (Test-LeakTitlePrefix -Title $normalized -Kind $Kind)) { + throw "$Context has a malformed or ambiguous $tag title prefix: '$Title'." + } + + $api = Get-CanonicalExistingLeakApi -Title $normalized + if ([string]::IsNullOrWhiteSpace($api)) { + throw "$Context has a malformed $tag title without a canonical API: '$Title'." + } + + return $api +} + +function Get-RegularJsonFileInfo { + 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 $MaximumBytes) { + $hasReparsePoint = + ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if ($hasReparsePoint) { + throw "Refusing reparse-point JSON file: $Path" + } + if ($item.PSIsContainer -or $item -isnot [System.IO.FileInfo]) { + throw "Refusing non-regular JSON file: $Path" + } + + return $item +} + +function Read-RegularJsonFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [ValidateRange(1, 256MB)][long]$MaximumBytes = 1MB + ) + + $item = Get-RegularJsonFileInfo -Path $Path + $expectedLength = [long]$item.Length + if ($expectedLength -eq 0 -or $expectedLength -gt $MaximumBytes) { + throw "JSON file is empty or too large: $Path" + } + + $expectedLastWriteTimeUtc = $item.LastWriteTimeUtc + $stream = $null + try { + $stream = [System.IO.FileStream]::new( + $item.FullName, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + $openedLength = [long]$stream.Length + if ($openedLength -ne $expectedLength -or + $openedLength -gt $MaximumBytes) { + throw "JSON file changed while being read: $Path" + } + + $bytes = [byte[]]::new([int]$openedLength) + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { + break + } + $offset += $read + } + if ($offset -ne $bytes.Length -or $stream.ReadByte() -ne -1) { + throw "JSON file changed while being read: $Path" + } + + $postReadItem = Get-RegularJsonFileInfo -Path $Path + if ([long]$postReadItem.Length -ne $expectedLength -or + $postReadItem.LastWriteTimeUtc -ne $expectedLastWriteTimeUtc) { + throw "JSON file changed while being read: $Path" + } + } finally { + if ($null -ne $stream) { + $stream.Dispose() + } + } + + $memoryStream = $null + $reader = $null + try { + $memoryStream = [System.IO.MemoryStream]::new($bytes, $false) + $reader = [System.IO.StreamReader]::new( + $memoryStream, + [System.Text.UTF8Encoding]::new($false, $true), + $true + ) + $raw = $reader.ReadToEnd() + } catch { + throw "Invalid JSON in '$Path': $($_.Exception.Message)" + } finally { + if ($null -ne $reader) { + $reader.Dispose() + } elseif ($null -ne $memoryStream) { + $memoryStream.Dispose() + } + } + + if ([string]::IsNullOrWhiteSpace($raw)) { throw "JSON file is empty or too large: $Path" } try { @@ -1798,6 +1921,7 @@ Export-ModuleMember -Function ` Test-LeakTitlePrefix, ` Get-CanonicalLeakApi, ` Get-CanonicalExistingLeakApi, ` + Get-ValidatedExistingLeakApi, ` Read-RegularJsonFile, ` Select-LeakAuthoritativePullRequests, ` Test-LeakPrReferencesIssue, ` From f51f067c5ee805b2726602c5e2dd733fd4b549be Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Thu, 20 Aug 2026 00:30:56 +0200 Subject: [PATCH 64/68] Harden leak workflow snapshot consistency Retry complete GraphQL snapshots once on detected dataset churn, surface bounded GraphQL error diagnostics, verify trusted downloads, and bound merged-revert JSON ingress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Get-EffectiveRevertedLeakFixes.ps1 | 6 +- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 339 +++++++++++++++++- .github/scripts/LeakWorkflowDedup.psm1 | 280 +++++++++++++-- .github/workflows/daily-leak-hunter.lock.yml | 8 +- .github/workflows/daily-leak-hunter.md | 19 +- .github/workflows/leak-fixer.lock.yml | 4 +- .github/workflows/leak-fixer.md | 13 +- 7 files changed, 614 insertions(+), 55 deletions(-) diff --git a/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 index 32cd0cf49f3e..832b5e93b235 100644 --- a/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 +++ b/.github/scripts/Get-EffectiveRevertedLeakFixes.ps1 @@ -28,9 +28,11 @@ $fixPullRequests = @( } ) +$mergedRevertsJson = Read-RegularJsonFile ` + -Path $MergedRevertsJsonPath ` + -MaximumBytes 128MB $reverts = @( - Get-Content -LiteralPath $MergedRevertsJsonPath -Raw | - ConvertFrom-Json | + $mergedRevertsJson | Where-Object { $null -ne $_.mergedAt } ) diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index e044690de78c..ff757e4513a1 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -512,6 +512,68 @@ Describe 'shared regular JSON file validation' { } } +Describe 'merged-reverts JSON driver validation' { + BeforeAll { + $script:effectiveRevertsDriver = Join-Path ( + $PSScriptRoot + ) 'Get-EffectiveRevertedLeakFixes.ps1' + } + + It 'uses the shared reader with the 128MB ingress bound' { + $driver = Get-Content -LiteralPath $script:effectiveRevertsDriver -Raw + + $driver | Should -Match '(?s)Read-RegularJsonFile\s+`?\s*-Path \$MergedRevertsJsonPath\s+`?\s*-MaximumBytes 128MB' + $driver | Should -Not -Match '(?s)Get-Content[^\r\n]*\$MergedRevertsJsonPath' + $driver | Should -Not -Match 'ConvertFrom-Json' + } + + It 'accepts a regular bounded merged-reverts file' { + $fixesPath = Join-Path $TestDrive 'merged-fixes.tsv' + $revertsPath = Join-Path $TestDrive 'merged-reverts.json' + $outputPath = Join-Path $TestDrive 'effective-reverts.txt' + "Type.Member`t100`tmain" | Set-Content -LiteralPath $fixesPath + '[]' | Set-Content -LiteralPath $revertsPath + + & $script:effectiveRevertsDriver ` + -Repository 'dotnet/maui' ` + -MergedFixTsvPath $fixesPath ` + -MergedRevertsJsonPath $revertsPath ` + -OutputPath $outputPath + + Test-Path -LiteralPath $outputPath -PathType Leaf | + Should -BeTrue + @(Get-Content -LiteralPath $outputPath).Count | Should -Be 0 + } + + It 'rejects a merged-reverts file above 128MB before parsing' { + $fixesPath = Join-Path $TestDrive 'oversized-merged-fixes.tsv' + $revertsPath = Join-Path $TestDrive 'oversized-merged-reverts.json' + $outputPath = Join-Path $TestDrive 'oversized-effective-reverts.txt' + "Type.Member`t100`tmain" | Set-Content -LiteralPath $fixesPath + + $stream = [System.IO.FileStream]::new( + $revertsPath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + try { + $stream.SetLength(128MB + 1) + } finally { + $stream.Dispose() + } + + { + & $script:effectiveRevertsDriver ` + -Repository 'dotnet/maui' ` + -MergedFixTsvPath $fixesPath ` + -MergedRevertsJsonPath $revertsPath ` + -OutputPath $outputPath + } | Should -Throw '*JSON file is empty or too large*' + Test-Path -LiteralPath $outputPath | Should -BeFalse + } +} + Describe 'authoritative leak-fix branch scope' { It 'selects main and inflight/current as one scope while excluding release branches' { $selected = @( @@ -1433,7 +1495,75 @@ Describe 'complete GraphQL pagination' { } | Should -Throw '*claimed another page without a usable endCursor*' } - It 'fails closed when pagination changes totalCount or repeats a cursor' { + It 'rejects HTTP-200 pull-request responses with top-level errors and partial data' { + $node = New-LeakGraphQlPullRequestNode -Number 1 + $global:leakPaginationResponses.Enqueue( + (@{ + data = @{ + repository = @{ + pullRequests = @{ + totalCount = 1 + nodes = @($node) + pageInfo = @{ + hasNextPage = $false + endCursor = $null + } + } + } + } + errors = @( + @{ + message = "Permission denied`n$('x' * 1000)`0" + type = "FORBIDDEN`tTYPE" + path = @('repository', 'pullRequests', 0, 'nodes') + } + ) + } | ConvertTo-Json -Depth 10 -Compress) + ) + + $result = @() + $failure = $null + try { + $result = @( + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') + ) + } catch { + $failure = $_.Exception.Message + } + + $result.Count | Should -Be 0 + $failure | Should -Match 'returned GraphQL errors' + $failure | Should -Match 'Permission denied x+' + $failure | Should -Match '"type":"FORBIDDEN TYPE"' + $failure | Should -Match '"path":"repository.pullRequests.0.nodes"' + $failure | Should -Not -Match '[\x00-\x1F\x7F]' + $failure.Length | Should -BeLessThan 1500 + $global:leakPaginationCalls.Count | Should -Be 1 + } + + It 'restarts the whole PR snapshot after count churn and returns only the stable attempt' { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number 91 + ) ` + -TotalCount 2 ` + -HasNextPage $true ` + -EndCursor stale-cursor) + ) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number 92 + ) ` + -TotalCount 3 ` + -HasNextPage $false) + ) $global:leakPaginationResponses.Enqueue( (New-LeakGraphQlPageJson ` -ConnectionName pullRequests ` @@ -1442,7 +1572,7 @@ Describe 'complete GraphQL pagination' { ) ` -TotalCount 2 ` -HasNextPage $true ` - -EndCursor cursor-1) + -EndCursor stable-cursor) ) $global:leakPaginationResponses.Enqueue( (New-LeakGraphQlPageJson ` @@ -1450,34 +1580,151 @@ Describe 'complete GraphQL pagination' { -Nodes @( New-LeakGraphQlPullRequestNode -Number 2 ) ` - -TotalCount 3 ` + -TotalCount 2 ` -HasNextPage $false) ) - { + + $result = @( Get-CompleteLeakPullRequests ` -Repository 'dotnet/maui' ` -State MERGED ` - -BaseRefNames @('main') - } | Should -Throw '*totalCount changed from 2 to 3*' + -BaseRefNames @('main') ` + -PageSize 1 + ) + + $result.number | Should -Be @(1, 2) + $global:leakPaginationCalls.Count | Should -Be 4 + ($global:leakPaginationCalls[2] -join ' ') | + Should -Not -Match '\bafter=' + ($global:leakPaginationCalls[3] -join ' ') | + Should -Match 'after=stable-cursor' + } + It 'fails closed when count churn persists across the whole-snapshot restart' { 1..2 | ForEach-Object { $global:leakPaginationResponses.Enqueue( (New-LeakGraphQlPageJson ` -ConnectionName pullRequests ` -Nodes @( - New-LeakGraphQlPullRequestNode -Number (10 + $_) + New-LeakGraphQlPullRequestNode -Number (10 * $_) ) ` - -TotalCount 3 ` + -TotalCount 2 ` -HasNextPage $true ` - -EndCursor repeated) + -EndCursor "count-$($_)-first") + ) + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode -Number ((10 * $_) + 1) + ) ` + -TotalCount 3 ` + -HasNextPage $false) ) } + { Get-CompleteLeakPullRequests ` -Repository 'dotnet/maui' ` -State MERGED ` - -BaseRefNames @('main') - } | Should -Throw "*repeated endCursor 'repeated'*" + -BaseRefNames @('main') ` + -PageSize 1 + } | Should -Throw '*remained inconsistent after 2 whole-snapshot attempts*totalCount changed from 2 to 3*' + + $global:leakPaginationCalls.Count | Should -Be 4 + } + + It 'restarts the whole PR snapshot after cross-base retargeting' { + foreach ($entry in @( + @{ Number = 10; Base = 'main' } + @{ Number = 10; Base = 'inflight/current' } + @{ Number = 20; Base = 'main' } + @{ Number = 30; Base = 'inflight/current' } + )) { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode ` + -Number $entry.Number ` + -Base $entry.Base + ) ` + -TotalCount 1 ` + -HasNextPage $false) + ) + } + + $result = @( + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED + ) + + $result.number | Should -Be @(20, 30) + $global:leakPaginationCalls.Count | Should -Be 4 + @($global:leakPaginationCalls | ForEach-Object { + @($_ | Where-Object { $_ -like 'base=*' })[0] + }) | + Should -Be @( + 'base=main' + 'base=inflight/current' + 'base=main' + 'base=inflight/current' + ) -Because 'the second attempt must rebuild every authoritative base' + } + + It 'fails closed when cross-base retargeting persists across the restart' { + 1..2 | ForEach-Object { + foreach ($base in @('main', 'inflight/current')) { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode ` + -Number 10 ` + -Base $base + ) ` + -TotalCount 1 ` + -HasNextPage $false) + ) + } + } + + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED + } | Should -Throw '*remained inconsistent after 2 whole-snapshot attempts*PR #10 under multiple base branches*' + + $global:leakPaginationCalls.Count | Should -Be 4 + } + + It 'fails closed when cursor inconsistency persists across the restart' { + 1..2 | ForEach-Object { + 1..2 | ForEach-Object { + $global:leakPaginationResponses.Enqueue( + (New-LeakGraphQlPageJson ` + -ConnectionName pullRequests ` + -Nodes @( + New-LeakGraphQlPullRequestNode ` + -Number (($global:leakPaginationResponses.Count + 1) * 10) + ) ` + -TotalCount 3 ` + -HasNextPage $true ` + -EndCursor repeated) + ) + } + } + + { + Get-CompleteLeakPullRequests ` + -Repository 'dotnet/maui' ` + -State MERGED ` + -BaseRefNames @('main') ` + -PageSize 1 + } | Should -Throw "*remained inconsistent after 2 whole-snapshot attempts*repeated endCursor 'repeated'*" + + $global:leakPaginationCalls.Count | Should -Be 4 } It 'enforces the query budget before issuing an unbounded next-page request' { @@ -1581,6 +1828,52 @@ Describe 'merged reverter commit-history pagination' { Should -Match 'pr1: pullRequest' } + It 'rejects HTTP-200 commit-history responses with top-level errors and partial data' { + $candidate = New-LeakPr -Number 200 -Title 'Candidate' + $partial = New-LeakCommitHistoryGraphQlJson ` + -PullRequest $candidate ` + -Commits @( + @{ + oid = '1111111111111111111111111111111111111111' + message = 'Partial commit that must not be accepted' + } + ) ` + -TotalCount 1 ` + -HasNextPage $false | + ConvertFrom-Json + $partial | Add-Member -NotePropertyName errors -NotePropertyValue @( + [pscustomobject]@{ + message = "Commit history unavailable`r`n$('y' * 1000)`0" + type = "SERVICE_UNAVAILABLE`tTYPE" + path = @('repository', 'pr0', 'commits', 'nodes') + } + ) + $global:leakCommitHistoryResponses.Enqueue( + ($partial | ConvertTo-Json -Depth 10 -Compress) + ) + + $result = @() + $failure = $null + try { + $result = @( + Get-CompleteLeakPullRequestCommitHistories ` + -Repository 'dotnet/maui' ` + -PullRequests @($candidate) + ) + } catch { + $failure = $_.Exception.Message + } + + $result.Count | Should -Be 0 + $failure | Should -Match 'returned GraphQL errors' + $failure | Should -Match 'Commit history unavailable y+' + $failure | Should -Match '"type":"SERVICE_UNAVAILABLE TYPE"' + $failure | Should -Match '"path":"repository.pr0.commits.nodes"' + $failure | Should -Not -Match '[\x00-\x1F\x7F]' + $failure.Length | Should -BeLessThan 1500 + $global:leakCommitHistoryCalls.Count | Should -Be 1 + } + It 'fails closed when commit pagination is truncated' { $candidate = New-LeakPr -Number 200 -Title 'Candidate' $global:leakCommitHistoryResponses.Enqueue( @@ -1980,6 +2273,30 @@ Describe 'workflow enforcement boundary' { $lock | Should -Match '(?ms)^ safe_outputs:.*?^ permissions:.*?^ pull-requests: read$' } + It 'requires both trusted gate downloads to be non-empty before execution' { + $fixer = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/leak-fixer.md' + ) -Raw + $fixerLock = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/leak-fixer.lock.yml' + ) -Raw + $hunter = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md' + ) -Raw + $hunterLock = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.lock.yml' + ) -Raw + + foreach ($content in @($fixer, $fixerLock)) { + $content | Should -Match '(?s)Assert-LeakFixSafeOutputGate\.ps1.*LeakWorkflowDedup\.psm1.*test -s "\$TRUSTED_DIR/Assert-LeakFixSafeOutputGate\.ps1".*test -s "\$TRUSTED_DIR/LeakWorkflowDedup\.psm1".*chmod -R a-w' + $content | Should -Not -Match 'test -f "\$TRUSTED_DIR/(?:Assert-LeakFixSafeOutputGate\.ps1|LeakWorkflowDedup\.psm1)"' + } + foreach ($content in @($hunter, $hunterLock)) { + $content | Should -Match '(?s)test -s "\$TRUSTED_DIR/Assert-LeakHunterSafeOutputGate\.ps1".*test -s "\$TRUSTED_DIR/LeakWorkflowDedup\.psm1".*chmod -R a-w' + $content | Should -Not -Match 'test -f "\$TRUSTED_DIR/(?:Assert-LeakHunterSafeOutputGate\.ps1|LeakWorkflowDedup\.psm1)"' + } + } + It 'documents recursive any-active-reverter semantics' { $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../workflows/daily-leak-hunter.md') -Raw diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index 1f76ea35a6b7..c7915b5fd1e2 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -612,6 +612,176 @@ function Get-LeakRequiredPropertyValue { return $property.Value } +function ConvertTo-LeakGraphQlDiagnosticText { + param( + [AllowNull()][object]$Value, + [ValidateRange(1, 1024)][int]$MaximumLength + ) + + if ($null -eq $Value) { + return '' + } + + $text = [regex]::Replace( + [string]$Value, + '[\p{Cc}\p{Cf}]', + ' ' + ) + $text = [regex]::Replace($text, '\s+', ' ').Trim() + if ($text.Length -gt $MaximumLength) { + return "$($text.Substring(0, $MaximumLength))..." + } + return $text +} + +function Format-LeakGraphQlErrorPath { + param([AllowNull()][object]$Path) + + if ($null -eq $Path) { + return '' + } + + $segments = @($Path) + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($segment in @($segments | Select-Object -First 8)) { + $text = ConvertTo-LeakGraphQlDiagnosticText ` + -Value $segment ` + -MaximumLength 48 + if ([string]::IsNullOrWhiteSpace($text)) { + $text = '' + } + $parts.Add($text) + } + if ($segments.Count -gt 8) { + $parts.Add('...') + } + return $parts -join '.' +} + +function Format-LeakGraphQlErrors { + param( + [Parameter(Mandatory = $true)][string]$Context, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Errors + ) + + $details = [System.Collections.Generic.List[string]]::new() + foreach ($errorItem in @($Errors | Select-Object -First 3)) { + $messageProperty = if ($null -eq $errorItem) { + $null + } else { + $errorItem.PSObject.Properties['message'] + } + $message = if ($null -eq $messageProperty) { + '' + } else { + ConvertTo-LeakGraphQlDiagnosticText ` + -Value $messageProperty.Value ` + -MaximumLength 240 + } + if ([string]::IsNullOrWhiteSpace($message)) { + $message = '' + } + + $summary = [ordered]@{ message = $message } + if ($null -ne $errorItem) { + $typeProperty = $errorItem.PSObject.Properties['type'] + if ($null -ne $typeProperty) { + $type = ConvertTo-LeakGraphQlDiagnosticText ` + -Value $typeProperty.Value ` + -MaximumLength 80 + if (-not [string]::IsNullOrWhiteSpace($type)) { + $summary.type = $type + } + } + + $pathProperty = $errorItem.PSObject.Properties['path'] + if ($null -ne $pathProperty) { + $path = Format-LeakGraphQlErrorPath -Path $pathProperty.Value + if (-not [string]::IsNullOrWhiteSpace($path)) { + $summary.path = $path + } + } + } + $details.Add(($summary | ConvertTo-Json -Compress)) + } + if ($Errors.Count -gt 3) { + $details.Add( + (@{ omitted = $Errors.Count - 3 } | ConvertTo-Json -Compress) + ) + } + + $diagnostic = "$Context returned GraphQL errors: $($details -join '; ')" + if ($diagnostic.Length -gt 1400) { + $diagnostic = "$($diagnostic.Substring(0, 1400))..." + } + return $diagnostic +} + +function Assert-LeakGraphQlResponse { + param( + [Parameter(Mandatory = $true)][AllowNull()][object]$Response, + [Parameter(Mandatory = $true)][string]$Context + ) + + if ($null -eq $Response) { + return + } + $errorsProperty = $Response.PSObject.Properties['errors'] + $errors = if ($null -eq $errorsProperty) { + @() + } else { + @($errorsProperty.Value) + } + if ($errors.Count -gt 0) { + throw (Format-LeakGraphQlErrors -Context $Context -Errors $errors) + } +} + +function New-LeakSnapshotChurnException { + param([Parameter(Mandatory = $true)][string]$Message) + + $exception = [System.InvalidOperationException]::new($Message) + $exception.Data['LeakSnapshotChurn'] = $true + return $exception +} + +function Test-IsLeakSnapshotChurnException { + param([Parameter(Mandatory = $true)][System.Exception]$Exception) + + $current = $Exception + while ($null -ne $current) { + if ($current.Data['LeakSnapshotChurn'] -eq $true) { + return $true + } + $current = $current.InnerException + } + return $false +} + +function Invoke-LeakWholeSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Context, + [Parameter(Mandatory = $true)][scriptblock]$Operation, + [ValidateRange(1, 3)][int]$MaximumAttempts = 2 + ) + + for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { + try { + return & $Operation + } catch { + if (-not (Test-IsLeakSnapshotChurnException -Exception $_.Exception)) { + throw + } + if ($attempt -eq $MaximumAttempts) { + throw "$Context remained inconsistent after $MaximumAttempts whole-snapshot attempts. Last inconsistency: $($_.Exception.Message)" + } + Write-Warning "$Context detected dataset churn on whole-snapshot attempt $attempt of $MaximumAttempts; rebuilding from scratch. $($_.Exception.Message)" + } + } + + throw "$Context exhausted its whole-snapshot retry budget unexpectedly." +} + function Get-LeakRepositoryCoordinates { param([Parameter(Mandatory = $true)][string]$Repository) @@ -674,10 +844,7 @@ function Invoke-LeakGraphQlConnection { } $response = Invoke-LeakGhJson -Arguments $arguments.ToArray() - $errorsProperty = $response.PSObject.Properties['errors'] - if ($null -ne $errorsProperty -and @($errorsProperty.Value).Count -gt 0) { - throw "$Context returned GraphQL errors." - } + Assert-LeakGraphQlResponse -Response $response -Context $Context $data = Get-LeakRequiredPropertyValue ` -Object $response ` @@ -705,7 +872,8 @@ function Invoke-LeakGraphQlConnection { if ($expectedTotal -lt 0) { $expectedTotal = $totalCount } elseif ($totalCount -ne $expectedTotal) { - throw "$Context totalCount changed from $expectedTotal to $totalCount during pagination." + throw (New-LeakSnapshotChurnException ` + -Message "$Context totalCount changed from $expectedTotal to $totalCount during pagination.") } $nodesValue = Get-LeakRequiredPropertyValue ` @@ -731,7 +899,8 @@ function Invoke-LeakGraphQlConnection { throw "$Context returned an invalid node number '$numberValue'." } if (-not $seenKeys.Add([string]$number)) { - throw "$Context returned duplicate node #$number across pages." + throw (New-LeakSnapshotChurnException ` + -Message "$Context returned duplicate node #$number across pages.") } $items.Add($node) } @@ -772,7 +941,8 @@ function Invoke-LeakGraphQlConnection { throw "$Context claimed another page without a usable endCursor." } if (-not $seenCursors.Add($endCursor)) { - throw "$Context repeated endCursor '$endCursor'." + throw (New-LeakSnapshotChurnException ` + -Message "$Context repeated endCursor '$endCursor'.") } $cursor = $endCursor } @@ -783,7 +953,7 @@ function Invoke-LeakGraphQlConnection { } } -function Get-CompleteLeakPullRequests { +function Invoke-LeakPullRequestSnapshot { param( [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)] @@ -862,13 +1032,15 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str -Name 'number' ` -Context "$context node") if (-not $seenNumbers.Add($number)) { - throw "Leak pull-request pagination returned PR #$number under multiple base branches." + throw (New-LeakSnapshotChurnException ` + -Message "Leak pull-request pagination returned PR #$number under multiple base branches.") } $nodeBase = Get-NormalizedLeakBaseRefName ` -PullRequest $node ` -Context $context if ($nodeBase -cne $base) { - throw "$context returned PR #$number for unexpected baseRefName '$nodeBase'." + throw (New-LeakSnapshotChurnException ` + -Message "$context returned PR #$number for unexpected baseRefName '$nodeBase'.") } $nodeState = Get-LeakRequiredPropertyValue ` @@ -952,7 +1124,32 @@ query($owner: String!, $name: String!, $base: String!, $first: Int!, $after: Str return @($all | Sort-Object number) } -function Get-CompleteLeakPullRequestCommitHistories { +function Get-CompleteLeakPullRequests { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)] + [ValidateSet('OPEN', 'CLOSED', 'MERGED')] + [string]$State, + [string[]]$BaseRefNames = @('main', 'inflight/current'), + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000 + ) + + return @( + Invoke-LeakWholeSnapshot ` + -Context "$State pull-request pagination" ` + -Operation { + Invoke-LeakPullRequestSnapshot ` + -Repository $Repository ` + -State $State ` + -BaseRefNames $BaseRefNames ` + -PageSize $PageSize ` + -MaximumPageQueries $MaximumPageQueries + } + ) +} + +function Invoke-LeakPullRequestCommitHistorySnapshot { param( [Parameter(Mandatory = $true)][string]$Repository, [Parameter(Mandatory = $true)] @@ -1074,10 +1271,9 @@ $($selections -join "`n") } $response = Invoke-LeakGhJson -Arguments $arguments.ToArray() - $errorsProperty = $response.PSObject.Properties['errors'] - if ($null -ne $errorsProperty -and @($errorsProperty.Value).Count -gt 0) { - throw 'Merged reverter commit-history pagination returned GraphQL errors.' - } + Assert-LeakGraphQlResponse ` + -Response $response ` + -Context 'Merged reverter commit-history pagination' $data = Get-LeakRequiredPropertyValue ` -Object $response ` -Name 'data' ` @@ -1164,7 +1360,8 @@ $($selections -join "`n") if ($state.ExpectedTotal -lt 0) { $state.ExpectedTotal = $totalCount } elseif ($totalCount -ne $state.ExpectedTotal) { - throw "$context commit totalCount changed from $($state.ExpectedTotal) to $totalCount during pagination." + throw (New-LeakSnapshotChurnException ` + -Message "$context commit totalCount changed from $($state.ExpectedTotal) to $totalCount during pagination.") } $nodesValue = Get-LeakRequiredPropertyValue ` @@ -1196,7 +1393,8 @@ $($selections -join "`n") } $oid = $oidValue.ToLowerInvariant() if (-not $state.SeenCommitOids.Add($oid)) { - throw "$context returned duplicate commit '$oid' across pages." + throw (New-LeakSnapshotChurnException ` + -Message "$context returned duplicate commit '$oid' across pages.") } $message = Get-LeakRequiredPropertyValue ` -Object $commit ` @@ -1249,7 +1447,8 @@ $($selections -join "`n") throw "$context claimed another commit page without a usable endCursor." } if (-not $state.SeenCursors.Add($endCursor)) { - throw "$context repeated commit endCursor '$endCursor'." + throw (New-LeakSnapshotChurnException ` + -Message "$context repeated commit endCursor '$endCursor'.") } $state.Cursor = $endCursor } @@ -1267,6 +1466,33 @@ $($selections -join "`n") } | Sort-Object number) } +function Get-CompleteLeakPullRequestCommitHistories { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$PullRequests, + [ValidateRange(1, 100)][int]$PageSize = 100, + [ValidateRange(1, 50)][int]$BatchSize = 10, + [ValidateRange(1, 5000)][int]$MaximumPageQueries = 1000, + [ValidateRange(1, 100000)][int]$MaximumCommitRecords = 20000 + ) + + return @( + Invoke-LeakWholeSnapshot ` + -Context 'Merged reverter commit-history pagination' ` + -Operation { + Invoke-LeakPullRequestCommitHistorySnapshot ` + -Repository $Repository ` + -PullRequests $PullRequests ` + -PageSize $PageSize ` + -BatchSize $BatchSize ` + -MaximumPageQueries $MaximumPageQueries ` + -MaximumCommitRecords $MaximumCommitRecords + } + ) +} + function Get-CompleteLeakIssues { param( [Parameter(Mandatory = $true)][string]$Repository, @@ -1300,14 +1526,18 @@ query($owner: String!, $name: String!, $first: Int!, $after: String) { } '@ - $connection = Invoke-LeakGraphQlConnection ` - -Repository $Repository ` - -Query $query ` - -ConnectionName 'issues' ` + $connection = Invoke-LeakWholeSnapshot ` -Context 'Open agentic-workflows issue pagination' ` - -PageSize $PageSize ` - -MaximumPageQueries $MaximumPageQueries ` - -QueryBudget @{ Queries = 0 } + -Operation { + Invoke-LeakGraphQlConnection ` + -Repository $Repository ` + -Query $query ` + -ConnectionName 'issues' ` + -Context 'Open agentic-workflows issue pagination' ` + -PageSize $PageSize ` + -MaximumPageQueries $MaximumPageQueries ` + -QueryBudget @{ Queries = 0 } + } $issues = [System.Collections.Generic.List[object]]::new() foreach ($node in @($connection.Items)) { $number = [int](Get-LeakRequiredPropertyValue ` diff --git a/.github/workflows/daily-leak-hunter.lock.yml b/.github/workflows/daily-leak-hunter.lock.yml index 3c368a031e8c..11543c420a75 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":"c81b1eb87b6868b6194376d9b3d8be686435ea0624c3870077f6555e335c9a49","body_hash":"41cc2853bf68817886c30172372b3b63c38c1ed5d0e3ce63cb0221ea337441ad","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":"1880fb34512d9811b0e0767daebb801428ba2ae31449697c85d2147f370a562d","body_hash":"41cc2853bf68817886c30172372b3b63c38c1ed5d0e3ce63cb0221ea337441ad","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 # @@ -505,7 +505,7 @@ jobs: 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).\n# The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo,\n# and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination.\npwsh .github/scripts/Get-CompleteLeakIssues.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json\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.\n# Fetch complete non-Search history once per authoritative base. Pagination is bounded by\n# an explicit query budget rather than a result cutoff; reaching the budget is a visible\n# fail-closed error, never a silently truncated history.\npwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -State MERGED \\\n -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | test(\"^\\\\[leak-fix\\\\][ \\\\t]+\")) |\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# Repository-local \"Reverts #\" body references narrow the candidate set but are\n# editable and never establish revert state themselves. Resolve only independently\n# verified links recursively: any active same-branch direct reverter keeps its target\n# reverted. Reverting a reverter can deactivate it, but independent sibling reverts\n# never cancel each other.\n# Reuse the same complete merged-PR history, including each original merge/squash commit\n# OID. Editable `Reverts #N` body lines identify candidates only. The helper batches\n# candidate PR commit-history queries and accepts an edge only when a complete immutable\n# commit message contains exactly one full `This reverts commit <40-hex-target-OID>.`\n# proof. Wrong, abbreviated, duplicate, ambiguous, cross-branch, truncated, or over-budget\n# evidence leaves the original fix active or fails closed. Both merged-history and commit\n# pagination use stable totalCount/pageInfo validation, bounded transient retries, capped\n# server-directed rate-limit delays, and 1000-query safety budgets; commit verification is\n# batched 10 PRs at a time, pages 100 commits, and caps aggregate records at 20000. Local\n# traversal keeps the 1000-discovery and 2000-PR aggregate bounds.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\n -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json\n# Resolve the EFFECTIVE state recursively, not just one hop. An immutably verified merged\n# revert edge toggles its target only while that revert itself remains active on the SAME\n# base branch; every edge in a revert-of-revert chain must carry its own exact proof.\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" + 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).\n# The shared GraphQL helper follows every cursor and verifies stable totalCount/pageInfo.\n# Count, duplicate, or cursor churn rebuilds the whole issue snapshot once from scratch;\n# persistent churn and malformed, incomplete, or over-budget pagination fail closed.\npwsh .github/scripts/Get-CompleteLeakIssues.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json\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.\n# Fetch complete non-Search history once per authoritative base. Pagination is bounded by\n# an explicit query budget rather than a result cutoff; reaching the budget is a visible\n# fail-closed error, never a silently truncated history.\npwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -State MERGED \\\n -OutputPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json\njq '[.[] |\n select(.mergedAt != null) |\n select(.title | test(\"^\\\\[leak-fix\\\\][ \\\\t]+\")) |\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# Repository-local \"Reverts #\" body references narrow the candidate set but are\n# editable and never establish revert state themselves. Resolve only independently\n# verified links recursively: any active same-branch direct reverter keeps its target\n# reverted. Reverting a reverter can deactivate it, but independent sibling reverts\n# never cancel each other.\n# Reuse the same complete merged-PR history, including each original merge/squash commit\n# OID. Editable `Reverts #N` body lines identify candidates only. The helper batches\n# candidate PR commit-history queries and accepts an edge only when a complete immutable\n# commit message contains exactly one full `This reverts commit <40-hex-target-OID>.`\n# proof. Wrong, abbreviated, duplicate, ambiguous, cross-branch, truncated, or over-budget\n# evidence leaves the original fix active or fails closed. Both merged-history and commit\n# pagination use stable totalCount/pageInfo validation and rebuild the whole snapshot once\n# on count, duplicate, cursor, or cross-base retarget churn. Persistent churn fails closed.\n# Native requests use bounded transient retries, capped server-directed rate-limit delays,\n# and 1000-query safety budgets per snapshot attempt; commit verification is batched 10 PRs\n# at a time, pages 100 commits, and caps aggregate records at 20000. Local traversal keeps\n# the 1000-discovery and 2000-PR aggregate bounds.\npwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \\\n -Repository \"$GITHUB_REPOSITORY\" \\\n -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \\\n -MergedPullRequestsJsonPath /tmp/gh-aw/agent/merged-leak-fix-prs-raw.json \\\n -OutputPath /tmp/gh-aw/agent/merged-revert-prs.json\n# Resolve the EFFECTIVE state recursively, not just one hop. An immutably verified merged\n# revert edge toggles its target only while that revert itself remains active on the SAME\n# base branch; every edge in a revert-of-revert chain must carry its own exact proof.\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 @@ -1698,8 +1698,8 @@ jobs: 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" + test -s "$TRUSTED_DIR/Assert-LeakHunterSafeOutputGate.ps1" + test -s "$TRUSTED_DIR/LeakWorkflowDedup.psm1" chmod -R a-w "$TRUSTED_DIR" shell: bash - name: Enforce final leak-hunter de-dup gate diff --git a/.github/workflows/daily-leak-hunter.md b/.github/workflows/daily-leak-hunter.md index 8f11d6db7ff7..802b52022eb9 100644 --- a/.github/workflows/daily-leak-hunter.md +++ b/.github/workflows/daily-leak-hunter.md @@ -93,8 +93,9 @@ pre-agent-steps: mkdir -p /tmp/gh-aw/agent # This workflow's own open [leak-scan] issues (filed with the agentic-workflows label). - # The shared GraphQL helper follows every cursor, verifies stable totalCount/pageInfo, - # and fails closed on malformed, incomplete, repeated-cursor, or over-budget pagination. + # The shared GraphQL helper follows every cursor and verifies stable totalCount/pageInfo. + # Count, duplicate, or cursor churn rebuilds the whole issue snapshot once from scratch; + # persistent churn and malformed, incomplete, or over-budget pagination fail closed. pwsh .github/scripts/Get-CompleteLeakIssues.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -OutputPath /tmp/gh-aw/agent/my-open-leakscan.json @@ -151,10 +152,12 @@ pre-agent-steps: # commit message contains exactly one full `This reverts commit <40-hex-target-OID>.` # proof. Wrong, abbreviated, duplicate, ambiguous, cross-branch, truncated, or over-budget # evidence leaves the original fix active or fails closed. Both merged-history and commit - # pagination use stable totalCount/pageInfo validation, bounded transient retries, capped - # server-directed rate-limit delays, and 1000-query safety budgets; commit verification is - # batched 10 PRs at a time, pages 100 commits, and caps aggregate records at 20000. Local - # traversal keeps the 1000-discovery and 2000-PR aggregate bounds. + # pagination use stable totalCount/pageInfo validation and rebuild the whole snapshot once + # on count, duplicate, cursor, or cross-base retarget churn. Persistent churn fails closed. + # Native requests use bounded transient retries, capped server-directed rate-limit delays, + # and 1000-query safety budgets per snapshot attempt; commit verification is batched 10 PRs + # at a time, pages 100 commits, and caps aggregate records at 20000. Local traversal keeps + # the 1000-discovery and 2000-PR aggregate bounds. pwsh .github/scripts/Get-RelevantMergedLeakReverts.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -MergedFixTsvPath /tmp/gh-aw/agent/already-merged-fix-apis.tsv \ @@ -212,8 +215,8 @@ safe-outputs: 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" + test -s "$TRUSTED_DIR/Assert-LeakHunterSafeOutputGate.ps1" + test -s "$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') }} diff --git a/.github/workflows/leak-fixer.lock.yml b/.github/workflows/leak-fixer.lock.yml index ca7dd653815b..743961b5e64d 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":"bf76c6ef1942690cf1f86cc74042bc21cc93d18c7a90cd0fe5cbbd12d8198e0e","body_hash":"54261d25aa889b24fa70f112253876e9cb4bb56de415dd56e8c978034017270e","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":"1cc4667fc873a62721db6d3db7539f7e286d9e1f81da2f8b90981b357abf2e84","body_hash":"314b7a7bb448e98129036b54b2746747080579ab661d62db195ecbe2a32e9549","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 # @@ -1817,6 +1817,8 @@ jobs: -f ref="$TRUSTED_REF" \ -H "Accept: application/vnd.github.raw+json" \ > "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + test -s "$TRUSTED_DIR/Assert-LeakFixSafeOutputGate.ps1" + test -s "$TRUSTED_DIR/LeakWorkflowDedup.psm1" chmod -R a-w "$TRUSTED_DIR" env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/leak-fixer.md b/.github/workflows/leak-fixer.md index 7fb6dee4676a..b15e99d0d350 100644 --- a/.github/workflows/leak-fixer.md +++ b/.github/workflows/leak-fixer.md @@ -131,6 +131,8 @@ safe-outputs: -f ref="$TRUSTED_REF" \ -H "Accept: application/vnd.github.raw+json" \ > "$TRUSTED_DIR/LeakWorkflowDedup.psm1" + test -s "$TRUSTED_DIR/Assert-LeakFixSafeOutputGate.ps1" + test -s "$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') }} @@ -450,9 +452,11 @@ jq -n \ }' > /tmp/gh-aw/agent/dedup-state.json # (a) Exact [leak-fix] PRs already MERGED to main/inflight/current. # Fetch complete non-Search history once per authoritative base. The shared helper follows -# 100-node GraphQL cursor pages, validates stable totalCount/pageInfo, uses bounded transient -# retries with capped server-directed rate-limit delays, and fails closed on malformed, -# incomplete, repeated-cursor, or over-budget pagination under a 1000-query safety budget. +# 100-node GraphQL cursor pages and validates stable totalCount/pageInfo. On count, duplicate, +# cursor, or cross-base retarget churn it rebuilds the whole snapshot once from scratch, then +# fails closed if churn persists. Native requests use bounded transient retries with capped +# server-directed rate-limit delays; malformed, incomplete, and over-budget pagination fails +# closed under a 1000-query safety budget per whole-snapshot attempt. pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ -Repository "$GITHUB_REPOSITORY" \ -State MERGED \ @@ -575,7 +579,8 @@ pwsh .github/scripts/Get-CompleteLeakPullRequests.ps1 \ -State CLOSED \ -OutputPath /tmp/gh-aw/agent/closed-fix-prs-raw.json # Validate every returned baseRefName before filtering. -# The cap is one aggregate budget across both authoritative lanes: main and inflight/current. +# Each whole-snapshot attempt has 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 ' From efc7266b18059a0bb956506f64ef37a3d4805ad3 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Thu, 20 Aug 2026 00:49:15 +0200 Subject: [PATCH 65/68] Harden leak fix provenance gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../scripts/Assert-LeakFixSafeOutputGate.ps1 | 18 +- .github/scripts/LeakWorkflowDedup.Tests.ps1 | 210 ++++++++++++++++++ .github/scripts/LeakWorkflowDedup.psm1 | 105 ++++++++- .github/workflows/leak-fixer.lock.yml | 2 +- .github/workflows/leak-fixer.md | 12 +- 5 files changed, 324 insertions(+), 23 deletions(-) diff --git a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 index b39567e1f100..30b9db57eb34 100644 --- a/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 +++ b/.github/scripts/Assert-LeakFixSafeOutputGate.ps1 @@ -38,21 +38,9 @@ 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." -} +$issueNumber = Get-LeakFixProvenanceIssueNumber ` + -Body ([string]$item.body) ` + -Repository $Repository $statePath = Join-Path $StateDirectory 'dedup-state.json' $state = Read-RegularJsonFile -Path $statePath diff --git a/.github/scripts/LeakWorkflowDedup.Tests.ps1 b/.github/scripts/LeakWorkflowDedup.Tests.ps1 index ff757e4513a1..a5aa87da5a22 100644 --- a/.github/scripts/LeakWorkflowDedup.Tests.ps1 +++ b/.github/scripts/LeakWorkflowDedup.Tests.ps1 @@ -683,6 +683,195 @@ Describe 'leak PR issue reference parsing' { } } +Describe 'leak-fix safe-output provenance parsing' { + It 'accepts one visible canonical pair with supported indentation and newlines' -ForEach @( + @{ + Body = "Fixes #123`nRefs: dotnet/maui#123" + } + @{ + Body = " Fixes #123 `n Refs: dotnet/maui#123 " + } + @{ + Body = "Fixes #123`r`nRefs: dotnet/maui#123`r`n" + } + ) { + Get-LeakFixProvenanceIssueNumber ` + -Body $Body ` + -Repository 'dotnet/maui' | + Should -Be 123 + } + + It 'ignores hidden duplicates when exactly one canonical pair is visible' { + $body = @' +```markdown +Fixes #123 +Refs: dotnet/maui#123 +``` + +Fixes #123 +Refs: dotnet/maui#123 +'@ + + Get-LeakFixProvenanceIssueNumber ` + -Body $body ` + -Repository 'dotnet/maui' | + Should -Be 123 + } + + It 'does not accept provenance that exists only in inert Markdown' -ForEach @( + @{ + Body = @' +```powershell +Fixes #123 +Refs: dotnet/maui#123 +``` +'@ + } + @{ + Body = @' + +'@ + } + @{ + Body = @' +~~~markdown +Fixes #123 +Refs: dotnet/maui#123 +~~~ +'@ + } + ) { + { + Get-LeakFixProvenanceIssueNumber ` + -Body $Body ` + -Repository 'dotnet/maui' + } | Should -Throw '*exactly one visible canonical Fixes line*' + } + + It 'rejects tab and four-space indentation at either contract line' -ForEach @( + @{ + Body = "`tFixes #123`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = " Fixes #123`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = "Fixes #123`n`tRefs: dotnet/maui#123" + Message = '*canonical Refs line*' + } + @{ + Body = "Fixes #123`n Refs: dotnet/maui#123" + Message = '*canonical Refs line*' + } + ) { + { + Get-LeakFixProvenanceIssueNumber ` + -Body $Body ` + -Repository 'dotnet/maui' + } | Should -Throw $Message + } + + It 'fails closed when fence or HTML comment state is malformed or unclosed' -ForEach @( + @{ + Body = @' +Fixes #123 +Refs: dotnet/maui#123 +```text +unfinished +'@ + Message = '*unclosed fenced code block*' + } + @{ + Body = @' +Fixes #123 +Refs: dotnet/maui#123 + stray closer" + Message = '*malformed HTML comment delimiter*' + } + @{ + Body = "Fixes #123`nRefs: dotnet/maui#123`n" + Message = '*malformed nested HTML comment*' + } + ) { + { + Get-LeakFixProvenanceIssueNumber ` + -Body $Body ` + -Repository 'dotnet/maui' + } | Should -Throw $Message + } + + It 'rejects ambiguous Fixes and mismatched or duplicate target Refs' -ForEach @( + @{ + Body = "Fixes #123`nFixes #124`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = "Fixes #123`nRefs: dotnet/maui#124" + Message = '*canonical Refs line*' + } + @{ + Body = "Fixes #123`nRefs: dotnet/maui#123`nRefs: dotnet/maui#123" + Message = '*canonical Refs line*' + } + @{ + Body = "Fixes #123`nRefs:dotnet/maui#123" + Message = '*canonical Refs line*' + } + @{ + Body = "Fixes #123`nrefs: dotnet/maui#123" + Message = '*canonical Refs line*' + } + @{ + Body = "Fixes #123`nRefs: DotNet/Maui#123" + Message = '*canonical Refs line*' + } + @{ + Body = "fixes #123`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = "Fixes #123`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = "Fixes #123 trailing`nRefs: dotnet/maui#123" + Message = '*canonical Fixes line*' + } + @{ + Body = "Fixes #123`nRefs: dotnet/maui#123 trailing" + Message = '*canonical Refs line*' + } + ) { + { + Get-LeakFixProvenanceIssueNumber ` + -Body $Body ` + -Repository 'dotnet/maui' + } | Should -Throw $Message + } + + It 'allows an unrelated visible Refs citation in addition to the target pair' { + $body = "Fixes #123`nRefs: dotnet/maui#123`nRefs: dotnet/maui#501" + + Get-LeakFixProvenanceIssueNumber ` + -Body $body ` + -Repository 'dotnet/maui' | + Should -Be 123 + } +} + Describe 'canonical leak API title parsing' { It 'extracts the API only from the anchored leak-scan title position' { Get-CanonicalLeakApi ` @@ -2259,6 +2448,27 @@ Describe 'workflow enforcement boundary' { )).Count | Should -BeGreaterOrEqual 3 } + It 'keeps leak-fix provenance instructions aligned with the trusted parser' { + $workflow = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot '../workflows/leak-fixer.md' + ) -Raw + $gate = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'Assert-LeakFixSafeOutputGate.ps1' + ) -Raw + $module = Get-Content -LiteralPath ( + Join-Path $PSScriptRoot 'LeakWorkflowDedup.psm1' + ) -Raw + + $workflow | Should -Match 'dedicated\s+visible line' + $workflow | Should -Match '0–3\s+leading ASCII spaces' + $workflow | Should -Match '(?m)^Fixes #\r?\nRefs: /#$' + $gate | Should -Match 'Get-LeakFixProvenanceIssueNumber' + $gate | Should -Not -Match '\[regex\]::Matches' + $module | Should -Match '(?s)Remove-LeakInertMarkdown.*RejectMalformedState' + $module | Should -Match '\^\[ \]\{0,3\}Fixes #' + $module | Should -Match '\^\[ \]\{0,3\}Refs: ' + } + 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 diff --git a/.github/scripts/LeakWorkflowDedup.psm1 b/.github/scripts/LeakWorkflowDedup.psm1 index c7915b5fd1e2..f6acc0aa57bb 100644 --- a/.github/scripts/LeakWorkflowDedup.psm1 +++ b/.github/scripts/LeakWorkflowDedup.psm1 @@ -308,7 +308,10 @@ function Select-LeakAuthoritativePullRequests { } function Remove-LeakInertMarkdown { - param([AllowEmptyString()][string]$Body) + param( + [AllowEmptyString()][string]$Body, + [switch]$RejectMalformedState + ) $normalized = (($Body ?? '') -replace "`r`n", "`n") -replace "`r", "`n" $visibleLines = [Collections.Generic.List[string]]::new() @@ -340,10 +343,101 @@ function Remove-LeakInertMarkdown { } } - return [regex]::Replace( - ($visibleLines -join "`n"), - '(?s)|\z)', - '') + if ($fenceLength -ne 0 -and $RejectMalformedState) { + throw 'The PR body contains an unclosed fenced code block.' + } + + $withoutFences = $visibleLines -join "`n" + if (-not $RejectMalformedState) { + return [regex]::Replace( + $withoutFences, + '(?s)|\z)', + '') + } + + $withoutComments = [Text.StringBuilder]::new() + $offset = 0 + while ($offset -lt $withoutFences.Length) { + $commentStart = $withoutFences.IndexOf( + '', + $offset, + [StringComparison]::Ordinal + ) + if ($commentEnd -ge 0 -and + ($commentStart -lt 0 -or $commentEnd -lt $commentStart)) { + throw 'The PR body contains a malformed HTML comment delimiter.' + } + if ($commentStart -lt 0) { + [void]$withoutComments.Append($withoutFences.Substring($offset)) + break + } + + [void]$withoutComments.Append( + $withoutFences.Substring($offset, $commentStart - $offset) + ) + $commentEnd = $withoutFences.IndexOf( + '-->', + $commentStart + 4, + [StringComparison]::Ordinal + ) + if ($commentEnd -lt 0) { + throw 'The PR body contains an unclosed HTML comment.' + } + $nestedComment = $withoutFences.IndexOf( + '