From 6717d1cb84a039ca6c3563f28b2df2879ceb0b7e Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:44:35 -0500 Subject: [PATCH 01/11] Add GitHub Actions workflow to run evaluate-pr-tests via Copilot CLI (#34548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Description Adds a [gh-aw (GitHub Agentic Workflows)](https://github.github.com/gh-aw/introduction/overview/) workflow that automatically evaluates test quality on PRs using the `evaluate-pr-tests` skill. ### What it does When a PR adds or modifies test files, this workflow: 1. **Checks out the PR branch** (including fork PRs) in a pre-agent step 2. **Runs the `evaluate-pr-tests` skill** via Copilot CLI in a sandboxed container 3. **Posts the evaluation report** as a PR comment using gh-aw safe-outputs ### Triggers | Trigger | When | Fork PR support | |---------|------|-----------------| | `pull_request` | Automatic on test file changes (`src/**/tests/**`) | ❌ Blocked by `pre_activation` gate | | `workflow_dispatch` | Manual — enter PR number | ✅ Works for all PRs | | `issue_comment` (`/evaluate-tests`) | Comment on PR | ⚠️ Same-repo only (see Known Limitations) | ### Security model | Layer | Implementation | |-------|---------------| | **gh-aw sandbox** | Agent runs in container with scrubbed credentials, network firewall | | **Safe outputs** | Max 1 PR comment per run, content-limited | | **Checkout without execution** | `steps:` checks out PR code but never executes workspace scripts | | **Base branch restoration** | `.github/skills/`, `.github/instructions/`, `.github/copilot-instructions.md` restored from base branch after checkout | | **Fork PR activation gate** | `pull_request` events blocked for forks via `head.repo.id == repository_id` | | **Pinned actions** | SHA-pinned `actions/checkout`, `actions/github-script`, etc. | | **Minimal permissions** | Each job declares only what it needs | | **Concurrency** | One evaluation per PR, cancels in-progress | | **Threat detection** | gh-aw built-in threat detection analyzes agent output | ### Files added/modified - `.github/workflows/copilot-evaluate-tests.md` — gh-aw workflow source - `.github/workflows/copilot-evaluate-tests.lock.yml` — Compiled workflow (auto-generated by `gh aw compile`) - `.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1` — Test context gathering script (binary-safe file download, path traversal protection) - `.github/instructions/gh-aw-workflows.instructions.md` — Copilot instructions for gh-aw development ### Known Limitations **Fork PR evaluation via `/evaluate-tests` comment is not supported in v1.** The gh-aw platform inserts a `checkout_pr_branch.cjs` step after all user steps, which may overwrite base-branch skill files restored for fork PRs. This is a known gh-aw platform limitation — user steps always run before platform-generated steps, with no way to insert steps after. **Workaround:** Use `workflow_dispatch` (Actions UI → "Run workflow" → enter PR number) to evaluate fork PRs. This trigger bypasses the platform checkout step entirely and works correctly. **Related upstream issues:** - [github/gh-aw#18481](https://github.com/github/gh-aw/issues/18481) — "Using gh-aw in forks of repositories" - [github/gh-aw#18518](https://github.com/github/gh-aw/issues/18518) — Fork detection and warning in `gh aw init` - [github/gh-aw#18520](https://github.com/github/gh-aw/issues/18520) — Fork context hint in failure messages - [github/gh-aw#18521](https://github.com/github/gh-aw/issues/18521) — Fork support documentation ### Fixes - Fixes #34602 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jakub Florkowski --- .github/aw/actions-lock.json | 10 + .../gh-aw-workflows.instructions.md | 223 ++++ .github/scripts/Checkout-GhAwPr.ps1 | 100 ++ .../scripts/Gather-TestContext.ps1 | 130 +- .../workflows/copilot-evaluate-tests.lock.yml | 1089 +++++++++++++++++ .github/workflows/copilot-evaluate-tests.md | 139 +++ 6 files changed, 1677 insertions(+), 14 deletions(-) create mode 100644 .github/instructions/gh-aw-workflows.instructions.md create mode 100644 .github/scripts/Checkout-GhAwPr.ps1 create mode 100644 .github/workflows/copilot-evaluate-tests.lock.yml create mode 100644 .github/workflows/copilot-evaluate-tests.md diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 774742dfa79e..4a1a1a9ed381 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -5,6 +5,16 @@ "version": "v8", "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" }, + "github/gh-aw-actions/setup@v0.62.1": { + "repo": "github/gh-aw-actions/setup", + "version": "v0.62.1", + "sha": "95c4e2aa6adbdf63ff0b0fbf09945ad4f4716fea" + }, + "github/gh-aw-actions/setup@v0.62.2": { + "repo": "github/gh-aw-actions/setup", + "version": "v0.62.2", + "sha": "20045bbd5ad2632b9809856c389708eab1bd16ef" + }, "github/gh-aw/actions/setup@v0.43.19": { "repo": "github/gh-aw/actions/setup", "version": "v0.43.19", diff --git a/.github/instructions/gh-aw-workflows.instructions.md b/.github/instructions/gh-aw-workflows.instructions.md new file mode 100644 index 000000000000..30d02675e639 --- /dev/null +++ b/.github/instructions/gh-aw-workflows.instructions.md @@ -0,0 +1,223 @@ +--- +applyTo: + - ".github/workflows/*.md" + - ".github/workflows/*.lock.yml" +--- + +# gh-aw (GitHub Agentic Workflows) Guidelines + +## Architecture + +gh-aw workflows are authored as `.md` files with YAML frontmatter, compiled to `.lock.yml` via `gh aw compile`. The lock file is auto-generated — **never edit it manually**. + +### Execution Model + +``` +activation job (renders prompt from base branch .md via runtime-import) + ↓ +agent job: + user steps: (pre-agent, OUTSIDE firewall, has GITHUB_TOKEN) + ↓ + platform steps: (configure git → checkout_pr_branch.cjs → install CLI) + ↓ + agent: (INSIDE sandboxed container, NO credentials) +``` + +| Context | Has GITHUB_TOKEN | Has gh CLI | Has git creds | Can execute scripts | +|---------|-----------------|-----------|---------------|-------------------| +| `steps:` (user) | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes — **be careful** | +| Platform steps | ✅ Yes | ✅ Yes | ✅ Yes | Platform-controlled | +| Agent container | ❌ Scrubbed | ❌ Scrubbed | ❌ Scrubbed | ✅ But sandboxed | + +### Step Ordering (Critical) + +User `steps:` **always run before** platform-generated steps. You cannot insert user steps after platform steps. + +The platform's `checkout_pr_branch.cjs` runs with `if: (github.event.pull_request) || (github.event.issue.pull_request)` — it is **skipped** for `workflow_dispatch` triggers. + +### Prompt Rendering + +The prompt is built in the **activation job** via `{{#runtime-import .github/workflows/.md}}`. This reads the `.md` file from the **base branch** workspace (before any PR checkout). The rendered prompt is uploaded as an artifact and downloaded by the agent job. + +- The agent prompt is always the base branch version — fork PRs cannot alter it +- The prompt references files on disk (e.g., `SKILL.md`) — those files must exist in the agent's workspace + +### Fork PR Activation Gate + +`gh aw compile` automatically injects a fork guard into the activation job's `if:` condition: `head.repo.id == repository_id`. This blocks fork PRs on `pull_request` events. This is **platform behavior** — do not add it manually. + +## Fork PR Handling + +### The "pwn-request" Threat Model + +The classic attack requires **checkout + execution** of fork code with elevated credentials. Checkout alone is not dangerous — the vulnerability is executing workspace scripts with `GITHUB_TOKEN`. + +Reference: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + +### Fork PR Behavior by Trigger + +| Trigger | `checkout_pr_branch.cjs` runs? | Fork handling | +|---------|-------------------------------|---------------| +| `pull_request` | ✅ Yes | Blocked by auto-generated activation gate | +| `workflow_dispatch` | ❌ Skipped | ✅ Works — user steps handle checkout and restore is final | +| `issue_comment` (same-repo) | ✅ Yes | ✅ Works — files already on PR branch | +| `issue_comment` (fork) | N/A | ❌ Blocked by fail-closed fork guard in `Checkout-GhAwPr.ps1` | + +### The `issue_comment` + Fork Problem + +For `/slash-command` triggers on fork PRs, `checkout_pr_branch.cjs` runs AFTER all user steps and re-checks out the fork branch. This overwrites any files restored by user steps (e.g., `.github/skills/`). There is no way to run user steps after platform steps. A fork could include a crafted `SKILL.md` that alters the agent's evaluation behavior. + +**Current approach (fail-closed fork guard):** `Checkout-GhAwPr.ps1` checks `isCrossRepository` via `gh pr view` for `issue_comment` triggers. If the PR is from a fork or the API call fails, the script exits with code 1. Fork PRs should use `workflow_dispatch` instead, where `checkout_pr_branch.cjs` is skipped and the user step restore is the final workspace state. + +**Upstream issue:** [github/gh-aw#18481](https://github.com/github/gh-aw/issues/18481) — "Using gh-aw in forks of repositories" + +### Safe Pattern: Checkout + Restore + +Use the shared `.github/scripts/Checkout-GhAwPr.ps1` script, which implements checkout + restore in a single reusable step: + +```yaml +steps: + - name: Checkout PR and restore agent infrastructure + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: pwsh .github/scripts/Checkout-GhAwPr.ps1 +``` + +The script: +1. Captures the base branch SHA before checkout +2. **Fork guard** (`issue_comment` only): checks `isCrossRepository` — exits 1 if fork or API failure +3. Checks out the PR branch via `gh pr checkout` +4. Deletes `.github/skills/` and `.github/instructions/` (prevents fork-added files) +5. Restores them from the base branch SHA (best-effort, non-fatal) + +**Behavior by trigger:** +- **`workflow_dispatch`**: Fork guard skipped. Platform checkout is skipped, so the restore IS the final workspace state (trusted files from base branch) +- **`issue_comment`** (same-repo): Fork guard passes. Platform re-checks out PR branch — files already match, effectively a no-op +- **`issue_comment`** (fork): Fork guard rejects — exits 1 with actionable notice to use `workflow_dispatch` +- **`pull_request`** (same-repo): Fork guard skipped. Files already exist, restore is a no-op + +### Anti-Patterns + +**Do NOT skip checkout for fork PRs:** + +```bash +# ❌ ANTI-PATTERN: Makes fork PRs unevaluable +if [ "$HEAD_OWNER" != "$BASE_OWNER" ]; then + echo "Skipping checkout for fork PR" + exit 0 # Agent evaluates workflow branch instead of PR +fi +``` + +Skipping checkout means the agent evaluates the wrong files. The correct approach is: always check out the PR, then restore agent infrastructure from the base branch. + +**Do NOT execute workspace code after fork checkout:** + +```yaml +# ❌ DANGEROUS: runs fork code with GITHUB_TOKEN +- name: Checkout PR + run: gh pr checkout "$PR_NUMBER" ... +- name: Run analysis + run: pwsh .github/skills/some-script.ps1 +``` + +If you need to run scripts, either: +1. Run them **before** the checkout (from the base branch) +2. Run them **inside the agent container** (sandboxed, no tokens) + +## Compilation + +```bash +# Compile after every change to the .md source +gh aw compile .github/workflows/.md + +# This updates: +# - .github/workflows/.lock.yml (auto-generated) +# - .github/aw/actions-lock.json +``` + +**Always commit the compiled lock file alongside the source `.md`.** + +## Common Patterns + +### Pre-Agent Data Prep (the `steps:` pattern) + +Use `steps:` for any operation requiring GitHub API access that the agent needs: + +```yaml +steps: + - name: Fetch PR data + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr view "$PR_NUMBER" --json title,body > pr-metadata.json + gh pr diff "$PR_NUMBER" --name-only > changed-files.txt +``` + +### Safe Outputs (Posting Comments) + +```yaml +safe-outputs: + add-comment: + max: 1 + target: "*" # Required for workflow_dispatch (no triggering PR context) +``` + +### Concurrency + +Include all trigger-specific PR number sources: + +```yaml +concurrency: + group: "my-workflow-${{ github.event.issue.number || github.event.pull_request.number || inputs.pr_number || github.run_id }}" + cancel-in-progress: true +``` + +### Noise Reduction + +Filter `pull_request` triggers to relevant paths and add a gate step: + +```yaml +on: + pull_request: + paths: + - 'src/**/tests/**' + +steps: + - name: Gate — skip if no relevant files + if: github.event_name == 'pull_request' + run: | + FILES=$(gh pr diff "$PR_NUMBER" --name-only | grep -E '\.cs$' || true) + if [ -z "$FILES" ]; then exit 1; fi +``` + +Manual triggers (`workflow_dispatch`, `issue_comment`) should bypass the gate. Note: `exit 1` causes a red ❌ on non-matching PRs — this is intentional (no built-in "skip" mechanism in gh-aw steps). + +## Limitations + +| What | Behavior | Workaround | +|------|----------|------------| +| User steps always before platform steps | Cannot run user code after `checkout_pr_branch.cjs` | Use `workflow_dispatch` for fork PRs; see [gh-aw#18481](https://github.com/github/gh-aw/issues/18481) | +| `--allow-all-tools` in lock.yml | Emitted by `gh aw compile` | Cannot override from `.md` source | +| MCP integrity filtering | Fork PRs blocked as "unapproved" | Use `steps:` checkout instead of MCP | +| `gh` CLI inside agent | Credentials scrubbed | Use `steps:` for API calls, or MCP tools | +| `issue_comment` trigger | Requires workflow on default branch | Must merge to `main` before `/slash-commands` work | +| Duplicate runs | gh-aw sometimes creates 2 runs per dispatch | Harmless, use concurrency groups | + +### Upstream References + +- [github/gh-aw#18481](https://github.com/github/gh-aw/issues/18481) — Fork support tracking issue +- [github/gh-aw#18518](https://github.com/github/gh-aw/issues/18518) — Fork detection in `gh aw init` +- [github/gh-aw#18521](https://github.com/github/gh-aw/issues/18521) — Fork support documentation + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Agent evaluates wrong PR | `workflow_dispatch` checks out workflow branch | Add `gh pr checkout` in `steps:` | +| Agent can't find SKILL.md | Fork PR branch doesn't have `.github/skills/` | Agent posts "rebase or use `workflow_dispatch`" message; or rebase fork on `main` | +| Fork PR rejected on `/evaluate-tests` | Fail-closed fork guard in `Checkout-GhAwPr.ps1` | Use `workflow_dispatch` with `pr_number` input instead | +| `gh` commands fail in agent | Credentials scrubbed inside container | Move to `steps:` section | +| Lock file out of date | Forgot to recompile | Run `gh aw compile` | +| Integrity filtering warning | MCP reading fork PR data | Expected, non-blocking | +| `/slash-command` doesn't trigger | Workflow not on default branch | Merge to `main` first | diff --git a/.github/scripts/Checkout-GhAwPr.ps1 b/.github/scripts/Checkout-GhAwPr.ps1 new file mode 100644 index 000000000000..4a6380a50421 --- /dev/null +++ b/.github/scripts/Checkout-GhAwPr.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS + Shared PR checkout for gh-aw (GitHub Agentic Workflows). + +.DESCRIPTION + Checks out a PR branch and restores trusted agent infrastructure (skills, + instructions) from the base branch. For issue_comment triggers, fork PRs + are rejected (fail-closed) because the platform's checkout_pr_branch.cjs + overwrites restored files after user steps. Fork PRs should use + workflow_dispatch instead. + + SECURITY NOTE: This script checks out PR code onto disk. This is safe + because NO subsequent user steps execute workspace code — the gh-aw + platform copies the workspace into a sandboxed container with scrubbed + credentials before starting the agent. The classic "pwn-request" attack + requires checkout + execution; we only do checkout. + + DO NOT add steps after this that run scripts from the workspace + (e.g., ./build.sh, pwsh ./script.ps1). That would create an actual + fork code execution vulnerability. See: + https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + +.NOTES + Required environment variables (set by the calling workflow step): + GH_TOKEN - GitHub token for API access + PR_NUMBER - PR number to check out + GITHUB_REPOSITORY - owner/repo (set by GitHub Actions) + GITHUB_ENV - path to env file (set by GitHub Actions) + GITHUB_EVENT_NAME - trigger type (set by GitHub Actions) +#> + +$ErrorActionPreference = 'Stop' + +# ── Validate inputs ────────────────────────────────────────────────────────── + +if (-not $env:PR_NUMBER -or $env:PR_NUMBER -eq '0') { + Write-Host "No PR number available, using default checkout" + exit 0 +} + +$PrNumber = $env:PR_NUMBER + +# ── Fork guard (issue_comment only) ───────────────────────────────────────── +# For issue_comment triggers, platform's checkout_pr_branch.cjs runs AFTER user +# steps and re-checks out the fork branch, overwriting any restored skill/instruction +# files. A fork could include a crafted SKILL.md that alters agent behavior. +# Fail closed: if we can't verify origin, exit 1 (not 0). +# Fork PRs can still be evaluated via workflow_dispatch (where platform checkout is skipped). + +if ($env:GITHUB_EVENT_NAME -eq 'issue_comment') { + $isFork = gh pr view $PrNumber --repo $env:GITHUB_REPOSITORY --json isCrossRepository --jq '.isCrossRepository' 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Could not verify PR origin — failing closed" + exit 1 + } + if ($isFork -eq 'true') { + Write-Host "::notice::Fork PR detected — /evaluate-tests via issue_comment is not supported for fork PRs. Use workflow_dispatch with pr_number=$PrNumber instead." + exit 1 + } +} + +# ── Save base branch SHA ───────────────────────────────────────────────────── +# Must be captured BEFORE checkout replaces HEAD. +# Exported for potential use by downstream platform steps (e.g., checkout_pr_branch.cjs) + +$BaseSha = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Failed to get current HEAD SHA" + exit 1 +} +Add-Content -Path $env:GITHUB_ENV -Value "BASE_SHA=$BaseSha" + +# ── Checkout PR branch ────────────────────────────────────────────────────── + +Write-Host "Checking out PR #$PrNumber..." +gh pr checkout $PrNumber --repo $env:GITHUB_REPOSITORY +if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Failed to checkout PR #$PrNumber" + exit 1 +} +Write-Host "✅ Checked out PR #$PrNumber" +git log --oneline -1 + +# ── Restore agent infrastructure from base branch ──────────────────────────── +# Best-effort restore of skill/instruction files from the base branch. +# - workflow_dispatch: platform checkout is skipped, so this IS the final state +# - issue_comment (same-repo): platform's checkout_pr_branch.cjs runs after and +# overwrites, but files already match (same repo). Fork PRs are blocked above. +# - pull_request (same-repo): files already exist, this is a no-op +# rm -rf first to prevent fork-added files from surviving the restore. + +if (Test-Path '.github/skills/') { Remove-Item -Recurse -Force '.github/skills/' } +if (Test-Path '.github/instructions/') { Remove-Item -Recurse -Force '.github/instructions/' } + +git checkout $BaseSha -- .github/skills/ .github/instructions/ .github/copilot-instructions.md 2>&1 +if ($LASTEXITCODE -eq 0) { + Write-Host "✅ Restored agent infrastructure from base branch ($BaseSha)" +} else { + Write-Host "⚠️ Could not restore agent infrastructure from base branch — files may come from the PR branch" +} diff --git a/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 b/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 index 8b8805b5596b..7fe0cdedba50 100644 --- a/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 +++ b/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 @@ -12,6 +12,12 @@ - Find existing similar tests - Assess platform scope +.PARAMETER PrNumber + Explicit PR number to evaluate. When provided, the script uses + `gh pr view ` to detect the base branch and `gh pr diff ` + to get the changed files. This avoids relying on the currently checked-out + branch, which is critical for workflow_dispatch triggers. + .PARAMETER BaseBranch Base branch to diff against. Auto-detected from PR if not specified. @@ -19,13 +25,16 @@ Directory to write the context report to. .EXAMPLE - ./Gather-TestContext.ps1 + ./Gather-TestContext.ps1 -PrNumber 31244 .EXAMPLE ./Gather-TestContext.ps1 -BaseBranch "origin/main" #> param( + [Parameter(Mandatory = $false)] + [int]$PrNumber, + [Parameter(Mandatory = $false)] [string]$BaseBranch, @@ -42,7 +51,22 @@ New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null $reportPath = Join-Path $OutputDir "context.md" # --- 1. Detect base branch --- -if (-not $BaseBranch) { +$usePrDiff = $false +if ($PrNumber -gt 0) { + # Explicit PR number — use gh pr view/diff so we don't depend on local branch + Write-Host "📋 Evaluating PR #$PrNumber (explicit)" + if (-not $BaseBranch) { + try { + $prJson = gh pr view $PrNumber --json baseRefName 2>$null + if ($prJson) { + $prInfo = $prJson | ConvertFrom-Json + $BaseBranch = "origin/$($prInfo.baseRefName)" + } + } catch { } + if (-not $BaseBranch) { $BaseBranch = "origin/main" } + } + $usePrDiff = $true +} elseif (-not $BaseBranch) { try { $prJson = gh pr view --json baseRefName 2>$null if ($prJson) { @@ -61,15 +85,69 @@ git fetch origin --quiet 2>$null # --- 2. Get changed files --- $changedFiles = @() -$diffOutput = git diff --name-only "$BaseBranch...HEAD" 2>$null -if ($diffOutput) { - $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } -} else { - $diffOutput = git diff --name-only "$BaseBranch" 2>$null + +if ($changedFiles.Count -eq 0 -and $usePrDiff) { + # Use gh pr diff to get file list directly from GitHub API — works regardless of local checkout + $diffOutput = gh pr diff $PrNumber --name-only 2>$null if ($diffOutput) { $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } } } +if ($changedFiles.Count -eq 0) { + $diffOutput = git diff --name-only "$BaseBranch...HEAD" 2>$null + if ($diffOutput) { + $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } + } else { + $diffOutput = git diff --name-only "$BaseBranch" 2>$null + if ($diffOutput) { + $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } + } + } +} + +# --- 2b. Download missing files via GitHub API (needed when PR isn't checked out locally) --- +if ($usePrDiff -and $changedFiles.Count -gt 0) { + $headSha = $null + try { + $headSha = gh pr view $PrNumber --json headRefOid --jq '.headRefOid' 2>$null + } catch { } + + if ($headSha) { + $downloadCount = 0 + $repoRootFull = [System.IO.Path]::GetFullPath($RepoRoot) + $owner, $repo = ($env:GITHUB_REPOSITORY ?? "dotnet/maui") -split '/', 2 + foreach ($file in $changedFiles) { + # Path traversal guard: ensure resolved path stays within repo root + $targetPath = [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $file)) + if (-not $targetPath.StartsWith($repoRootFull + [System.IO.Path]::DirectorySeparatorChar)) { + Write-Warning "Skipping out-of-root path: $file" + continue + } + + if (-not (Test-Path $targetPath)) { + try { + $dir = [System.IO.Path]::GetDirectoryName($targetPath) + if ($dir -and -not (Test-Path $dir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + $encodedFile = [Uri]::EscapeDataString($file) -replace '%2F', '/' + $apiPath = "repos/$owner/$repo/contents/$($encodedFile)?ref=$headSha" + $b64 = gh api $apiPath --jq '.content' 2>$null + if ($b64) { + $bytes = [System.Convert]::FromBase64String(($b64 -replace '\s', '')) + [System.IO.File]::WriteAllBytes($targetPath, $bytes) + $downloadCount++ + } + } catch { + Write-Host "⚠️ Could not download $file via API: $_" + } + } + } + if ($downloadCount -gt 0) { + Write-Host "📥 Downloaded $downloadCount file(s) from PR #$PrNumber head ($($headSha.Substring(0,7)))" + } + } +} if ($changedFiles.Count -eq 0) { Write-Host "⚠️ No changed files detected. Check your branch and base branch." @@ -128,7 +206,8 @@ function Test-UITestConventions { # --- Naming (only flag files in Issues/ directory that look like issue tests) --- $fileName = [System.IO.Path]::GetFileNameWithoutExtension($TestFile) if ($TestFile -match "Issues/" -and $fileName -match "^Issue" -and $fileName -notmatch "^Issue\d+$") { - $issues += "Issue test file name ``$fileName`` should follow ``IssueXXXXX`` pattern" + $safeName = Escape-ForCodeSpan $fileName + $issues += "Issue test file name ``$safeName`` should follow ``IssueXXXXX`` pattern" } # --- Inheritance --- @@ -225,7 +304,8 @@ function Test-UITestConventions { # For .xaml files, skip C# attribute checks (they live in code-behind) if ($hostFile -notmatch "\.xaml$") { if ($hostContent -notmatch "\[Issue\(") { - $issues += "HostApp page ``$([System.IO.Path]::GetFileName($hostFile))`` missing ``[Issue()]`` attribute" + $safeHost = Escape-ForCodeSpan ([System.IO.Path]::GetFileName($hostFile)) + $issues += "HostApp page ``$safeHost`` missing ``[Issue()]`` attribute" } } if ($hostContent -match "new\s+Frame\b") { @@ -334,7 +414,8 @@ function Test-XamlTestConventions { # File naming for issues $fileName = [System.IO.Path]::GetFileNameWithoutExtension($TestFile) if ($TestFile -match "Issues/" -and $fileName -notmatch "^Maui\d+$") { - $issues += "Issue test file name ``$fileName`` doesn't follow ``MauiXXXXX`` pattern" + $safeName = Escape-ForCodeSpan $fileName + $issues += "Issue test file name ``$safeName`` doesn't follow ``MauiXXXXX`` pattern" } return @{ Issues = $issues; Info = $info } @@ -353,7 +434,25 @@ $report += "" $report += "| Category | Count | Files |" $report += "|----------|-------|-------|" -function Format-FileList { param([string[]]$files) if ($files.Count -eq 0) { return "_none_" } return ($files | ForEach-Object { "``$_``" }) -join ", " } +function Escape-ForCodeSpan { + param([string]$Text) + # Neutralise characters that break markdown code spans or line structure. + # Backticks are replaced with a visually similar RIGHT SINGLE QUOTATION MARK (U+2019) + # so the surrounding `` delimiters stay balanced. Newlines / carriage-returns are + # stripped because they would break table rows or heading lines. + return ($Text -replace '`', [char]0x2019 -replace '[\r\n]', '') +} + +function Format-FileList { + param([string[]]$files) + if ($files.Count -eq 0) { return "_none_" } + return ($files | ForEach-Object { + # Escape markdown metacharacters to prevent injection via crafted filenames. + # Use double-backtick code spans (`` ... ``) so literal backticks render correctly. + $escaped = (Escape-ForCodeSpan $_) -replace '\|', '\|' -replace '<', '<' -replace '>', '>' + "````$escaped````" + }) -join ", " +} $report += "| **Fix files** | $($fixFiles.Count) | $(Format-FileList $fixFiles) |" $report += "| **UI Tests (NUnit)** | $($uiTestFiles.Count) | $(Format-FileList $uiTestFiles) |" @@ -396,7 +495,8 @@ if ($uiTestFiles.Count -gt 0) { $hostName -eq $baseName } $result = Test-UITestConventions -TestFile $testFile -HostAppFiles $matchingHostFiles - $report += "### ``$baseName``" + $safeBase = Escape-ForCodeSpan $baseName + $report += "### ``$safeBase``" if ($result.Info.Count -gt 0) { foreach ($i in $result.Info) { $report += "- ℹ️ $i" } } @@ -419,7 +519,8 @@ if ($unitTestFiles.Count -gt 0) { foreach ($testFile in $unitTestFiles) { $baseName = [System.IO.Path]::GetFileNameWithoutExtension($testFile) $result = Test-UnitTestConventions -TestFile $testFile - $report += "### ``$baseName``" + $safeBase = Escape-ForCodeSpan $baseName + $report += "### ``$safeBase``" if ($result.Info.Count -gt 0) { foreach ($i in $result.Info) { $report += "- ℹ️ $i" } } @@ -442,7 +543,8 @@ if ($xamlTestFiles.Count -gt 0) { foreach ($testFile in ($xamlTestFiles | Where-Object { $_ -match "\.cs$" })) { $baseName = [System.IO.Path]::GetFileNameWithoutExtension($testFile) $result = Test-XamlTestConventions -TestFile $testFile - $report += "### ``$baseName``" + $safeBase = Escape-ForCodeSpan $baseName + $report += "### ``$safeBase``" if ($result.Info.Count -gt 0) { foreach ($i in $result.Info) { $report += "- ℹ️ $i" } } diff --git a/.github/workflows/copilot-evaluate-tests.lock.yml b/.github/workflows/copilot-evaluate-tests.lock.yml new file mode 100644 index 000000000000..c1e75132b153 --- /dev/null +++ b/.github/workflows/copilot-evaluate-tests.lock.yml @@ -0,0 +1,1089 @@ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.62.2). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Evaluates test quality, coverage, and appropriateness on PRs that add or modify tests +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"9a0e480927d61956be62f85669d2e599525442694d280126849fe87e727c31df","compiler_version":"v0.62.2","strict":true} + +name: "Evaluate PR Tests" +"on": + issue_comment: + types: + - created + pull_request: + paths: + - src/**/tests/** + - src/**/test/** + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + inputs: + pr_number: + description: PR number to evaluate + required: true + type: number + +permissions: {} + +concurrency: + cancel-in-progress: true + group: evaluate-pr-tests-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }} + +run-name: "Evaluate PR Tests" + +jobs: + activation: + needs: pre_activation + if: > + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/evaluate-tests'))) && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id))) + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + body: ${{ steps.sanitized.outputs.body }} + comment_id: "" + comment_repo: "" + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} + steps: + - name: Setup Scripts + uses: github/gh-aw-actions/setup@20045bbd5ad2632b9809856c389708eab1bd16ef # v0.62.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: "claude-sonnet-4.6" + GH_AW_INFO_VERSION: "" + GH_AW_INFO_AGENT_VERSION: "latest" + GH_AW_INFO_CLI_VERSION: "v0.62.2" + GH_AW_INFO_WORKFLOW_NAME: "Evaluate PR Tests" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.24.3" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "copilot-evaluate-tests.lock.yml" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_EXPR_93C755A4: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} + run: | + bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_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_EOF' + + Tools: add_comment, missing_tool, missing_data, noop + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then + cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md" + fi + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/copilot-evaluate-tests.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_93C755A4: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_93C755A4: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_93C755A4: process.env.GH_AW_EXPR_93C755A4, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_IS_PR_COMMENT: process.env.GH_AW_IS_PR_COMMENT, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: copilotevaluatetests + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + steps: + - name: Setup Scripts + uses: github/gh-aw-actions/setup@20045bbd5ad2632b9809856c389708eab1bd16ef # v0.62.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Set runtime paths + run: | + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure gh CLI for GitHub Enterprise + run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + env: + GH_TOKEN: ${{ github.token }} + - env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + if: github.event_name == 'pull_request' + name: Gate — skip if no test source files in diff + run: "TEST_FILES=$(gh pr diff \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --name-only \\\n | grep -E '\\.(cs|xaml)$' \\\n | grep -iE '(tests?/|TestCases|UnitTests|DeviceTests)' \\\n || true)\nif [ -z \"$TEST_FILES\" ]; then\n echo \"⏭️ No test source files (.cs/.xaml) found in PR diff. Skipping evaluation.\"\n exit 1\nfi\necho \"✅ Found test files to evaluate:\"\necho \"$TEST_FILES\" | head -20\n" + - env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + name: Checkout PR and restore agent infrastructure + run: pwsh .github/scripts/Checkout-GhAwPr.ps1 + + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + (github.event.pull_request) || (github.event.issue.pull_request) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.3 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.3 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.3 ghcr.io/github/gh-aw-firewall/squid:0.24.3 ghcr.io/github/gh-aw-mcpg:v0.1.19 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + 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_EOF' + {"add_comment":{"max":1,"target":"*"},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + - name: Write Safe Outputs Tools + run: | + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF' + { + "description_suffixes": { + "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Target: *." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_SAFE_OUTPUTS_TOOLS_META_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.19' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.32.0", + "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Clean git credentials + continue-on-error: true + run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 15 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # shellcheck disable=SC1003 + sudo -E awf --env-all --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" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.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,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.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,packagecloud.io,packages.cloud.google.com,packages.microsoft.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" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: claude-sonnet-4.6 + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.62.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect inference access error + id: detect-inference-error + if: always() + continue-on-error: true + run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + - name: Copy Safe Outputs + if: always() + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.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,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.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,packagecloud.io,packages.cloud.google.com,packages.microsoft.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" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Evaluate PR Tests" + WORKFLOW_DESCRIPTION: "Evaluates test quality, coverage, and appropriateness on PRs that add or modify tests" + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # shellcheck disable=SC1003 + sudo -E awf --env-all --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" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.3 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: claude-sonnet-4.6 + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.62.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-copilot-evaluate-tests" + cancel-in-progress: false + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw-actions/setup@20045bbd5ad2632b9809856c389708eab1bd16ef # v0.62.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Evaluate PR Tests" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Evaluate PR Tests" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Evaluate PR Tests" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "copilot-evaluate-tests" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🧪 *Test evaluation by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Evaluating tests on this PR… [{workflow_name}]({run_url})\",\"runSuccess\":\"✅ Test evaluation complete! [{workflow_name}]({run_url})\",\"runFailure\":\"❌ Test evaluation failed. [{workflow_name}]({run_url}) {status}\"}" + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "15" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Evaluate PR Tests" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + + pre_activation: + if: > + ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/evaluate-tests'))) && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + steps: + - name: Setup Scripts + uses: github/gh-aw-actions/setup@20045bbd5ad2632b9809856c389708eab1bd16ef # v0.62.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/copilot-evaluate-tests" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: "claude-sonnet-4.6" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🧪 *Test evaluation by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Evaluating tests on this PR… [{workflow_name}]({run_url})\",\"runSuccess\":\"✅ Test evaluation complete! [{workflow_name}]({run_url})\",\"runFailure\":\"❌ Test evaluation failed. [{workflow_name}]({run_url}) {status}\"}" + GH_AW_WORKFLOW_ID: "copilot-evaluate-tests" + GH_AW_WORKFLOW_NAME: "Evaluate PR Tests" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw-actions/setup@20045bbd5ad2632b9809856c389708eab1bd16ef # v0.62.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + - name: Configure GH_HOST for enterprise compatibility + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.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,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.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,packagecloud.io,packages.cloud.google.com,packages.microsoft.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" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1,\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload safe output items + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: safe-output-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore + diff --git a/.github/workflows/copilot-evaluate-tests.md b/.github/workflows/copilot-evaluate-tests.md new file mode 100644 index 000000000000..c84f83f8855e --- /dev/null +++ b/.github/workflows/copilot-evaluate-tests.md @@ -0,0 +1,139 @@ +--- +description: Evaluates test quality, coverage, and appropriateness on PRs that add or modify tests +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/**/tests/**' + - 'src/**/test/**' + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to evaluate' + required: true + type: number + +if: >- + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/evaluate-tests')) + +permissions: + contents: read + issues: read + pull-requests: read + +engine: + id: copilot + model: claude-sonnet-4.6 + +safe-outputs: + add-comment: + max: 1 + target: "*" + noop: + messages: + footer: "> 🧪 *Test evaluation by [{workflow_name}]({run_url})*" + run-started: "🔬 Evaluating tests on this PR… [{workflow_name}]({run_url})" + run-success: "✅ Test evaluation complete! [{workflow_name}]({run_url})" + run-failure: "❌ Test evaluation failed. [{workflow_name}]({run_url}) {status}" + +tools: + github: + toolsets: [default] + +network: defaults + +concurrency: + group: "evaluate-pr-tests-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }}" + cancel-in-progress: true + +timeout-minutes: 15 + +steps: + - name: Gate — skip if no test source files in diff + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + TEST_FILES=$(gh pr diff "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --name-only \ + | grep -E '\.(cs|xaml)$' \ + | grep -iE '(tests?/|TestCases|UnitTests|DeviceTests)' \ + || true) + if [ -z "$TEST_FILES" ]; then + echo "⏭️ No test source files (.cs/.xaml) found in PR diff. Skipping evaluation." + exit 1 + fi + echo "✅ Found test files to evaluate:" + echo "$TEST_FILES" | head -20 + + - name: Checkout PR and restore agent infrastructure + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + run: pwsh .github/scripts/Checkout-GhAwPr.ps1 +--- + +# Evaluate PR Tests + +Invoke the **evaluate-pr-tests** skill: read and follow `.github/skills/evaluate-pr-tests/SKILL.md`. + +## Context + +- **Repository**: ${{ github.repository }} +- **PR Number**: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + +The PR branch has been checked out for you. All files from the PR are available locally. + +## Pre-flight check + +Before starting, verify the skill file exists: + +```bash +test -f .github/skills/evaluate-pr-tests/SKILL.md +``` + +If the file is **missing**, the fork PR branch is likely not rebased on the latest `main`. Post a comment using `add_comment`: + +```markdown +## 🧪 PR Test Evaluation + +❌ **Cannot evaluate**: this PR's branch does not include the evaluate-pr-tests skill (`.github/skills/evaluate-pr-tests/SKILL.md` is missing). + +**Fix**: rebase your fork on the latest `main` branch, or use the **workflow_dispatch** trigger (Actions tab → "Evaluate PR Tests" → "Run workflow" → enter PR number) which handles this automatically. +``` + +Then stop — do not proceed with the evaluation. + +## Running the skill + +1. Use `gh pr view ` to fetch PR metadata (title, body, labels, base branch). If `gh` CLI is unavailable, use the GitHub MCP tools instead. +2. Run `pwsh .github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1` to gather automated context +3. Read the context report and the actual changed files, then evaluate per SKILL.md criteria +4. Post results using `add_comment` with `item_number` set to the PR number + +## Posting Results + +Call `add_comment` with `item_number` set to the PR number. Wrap the report in a collapsible `
` block: + +```markdown +## 🧪 PR Test Evaluation + +**Overall Verdict:** [✅ Tests are adequate | ⚠️ Tests need improvement | ❌ Tests are insufficient] + +[1-2 sentence summary] + +> 👍 / 👎 — Was this evaluation helpful? React to let us know! + +
+📊 Expand Full Evaluation + +[Full report from SKILL.md] + +
+``` From 720a9d4a935413c89e0748198549cf92694ee051 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:05:33 -0500 Subject: [PATCH 02/11] Allow fork PRs to auto-trigger evaluate-pr-tests workflow (#34655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Enables the copilot-evaluate-tests gh-aw workflow to run on fork PRs by adding `forks: ["*"]` to the `pull_request` trigger and removing the fork guard from `Checkout-GhAwPr.ps1`. ## Changes 1. **copilot-evaluate-tests.md**: Added `forks: ["*"]` to opt out of gh-aw auto-injected fork activation guard. Scoped `Checkout-GhAwPr.ps1` step to `workflow_dispatch` only (redundant for other triggers since platform handles checkout). 2. **copilot-evaluate-tests.lock.yml**: Recompiled via `gh aw compile` — fork guard removed from activation `if:` conditions. 3. **Checkout-GhAwPr.ps1**: Removed the `isCrossRepository` fork guard. Updated header docs and restore comments to accurately describe behavior for all trigger×fork combinations (including corrected step ordering). 4. **gh-aw-workflows.instructions.md**: Updated all stale references to the removed fork guard. Documented `forks: ["*"]` opt-in, clarified residual risk model for fork PRs, and updated troubleshooting table. ## Security Model Fork PRs are safe because: - Agent runs in **sandboxed container** with all credentials scrubbed - Output limited to **1 comment** via `safe-outputs: add-comment: max: 1` - Agent **prompt comes from base branch** (`runtime-import`) — forks cannot alter instructions - Pre-flight check catches missing `SKILL.md` if fork isn't rebased on `main` - No workspace code is executed with `GITHUB_TOKEN` (checkout without execution) ## Testing - ✅ `workflow_dispatch` tested against fork PR #34621 - ✅ Lock.yml statically verified — fork guard removed from `if:` conditions - ⏳ `pull_request` trigger on fork PRs can only be verified post-merge (GitHub Actions reads lock.yml from default branch) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gh-aw-workflows.instructions.md | 35 +++++++++-------- .github/scripts/Checkout-GhAwPr.ps1 | 39 +++++-------------- .../workflows/copilot-evaluate-tests.lock.yml | 15 ++++--- .github/workflows/copilot-evaluate-tests.md | 7 +++- 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/.github/instructions/gh-aw-workflows.instructions.md b/.github/instructions/gh-aw-workflows.instructions.md index 30d02675e639..c8cd70ecc3be 100644 --- a/.github/instructions/gh-aw-workflows.instructions.md +++ b/.github/instructions/gh-aw-workflows.instructions.md @@ -44,7 +44,9 @@ The prompt is built in the **activation job** via `{{#runtime-import .github/wor ### Fork PR Activation Gate -`gh aw compile` automatically injects a fork guard into the activation job's `if:` condition: `head.repo.id == repository_id`. This blocks fork PRs on `pull_request` events. This is **platform behavior** — do not add it manually. +By default, `gh aw compile` automatically injects a fork guard into the activation job's `if:` condition: `head.repo.id == repository_id`. This blocks fork PRs on `pull_request` events. + +To **allow fork PRs**, add `forks: ["*"]` to the `pull_request` trigger in the `.md` frontmatter. The compiler removes the auto-injected guard from the compiled `if:` conditions. This is safe when the workflow uses the `Checkout-GhAwPr.ps1` pattern (checkout + trusted-infra restore) and the agent is sandboxed. ## Fork PR Handling @@ -58,16 +60,17 @@ Reference: https://securitylab.github.com/resources/github-actions-preventing-pw | Trigger | `checkout_pr_branch.cjs` runs? | Fork handling | |---------|-------------------------------|---------------| -| `pull_request` | ✅ Yes | Blocked by auto-generated activation gate | +| `pull_request` (default) | ✅ Yes | Blocked by auto-generated activation gate unless `forks: ["*"]` is set | +| `pull_request` + `forks: ["*"]` | ✅ Yes | ✅ Works — user steps restore trusted infra before agent runs | | `workflow_dispatch` | ❌ Skipped | ✅ Works — user steps handle checkout and restore is final | | `issue_comment` (same-repo) | ✅ Yes | ✅ Works — files already on PR branch | -| `issue_comment` (fork) | N/A | ❌ Blocked by fail-closed fork guard in `Checkout-GhAwPr.ps1` | +| `issue_comment` (fork) | ✅ Yes | ⚠️ Works — `checkout_pr_branch.cjs` re-checks out fork branch after user steps, potentially overwriting restored infra. Acceptable because agent is sandboxed (no credentials, max 1 comment via safe-outputs). Pre-flight check catches missing `SKILL.md` if fork isn't rebased. | ### The `issue_comment` + Fork Problem -For `/slash-command` triggers on fork PRs, `checkout_pr_branch.cjs` runs AFTER all user steps and re-checks out the fork branch. This overwrites any files restored by user steps (e.g., `.github/skills/`). There is no way to run user steps after platform steps. A fork could include a crafted `SKILL.md` that alters the agent's evaluation behavior. +For `/slash-command` triggers on fork PRs, `checkout_pr_branch.cjs` runs AFTER all user steps and re-checks out the fork branch. This overwrites any files restored by user steps (e.g., `.github/skills/`). A fork could include a crafted `SKILL.md` that alters the agent's evaluation behavior. -**Current approach (fail-closed fork guard):** `Checkout-GhAwPr.ps1` checks `isCrossRepository` via `gh pr view` for `issue_comment` triggers. If the PR is from a fork or the API call fails, the script exits with code 1. Fork PRs should use `workflow_dispatch` instead, where `checkout_pr_branch.cjs` is skipped and the user step restore is the final workspace state. +**Accepted residual risk:** The agent runs in a sandboxed container with all credentials scrubbed. The worst outcome is a manipulated evaluation comment (`safe-outputs: add-comment: max: 1`). The agent has no ability to push code, access secrets, or exfiltrate data. The pre-flight check in the agent prompt catches the case where `SKILL.md` is missing entirely (fork not rebased on `main`). **Upstream issue:** [github/gh-aw#18481](https://github.com/github/gh-aw/issues/18481) — "Using gh-aw in forks of repositories" @@ -86,16 +89,16 @@ steps: The script: 1. Captures the base branch SHA before checkout -2. **Fork guard** (`issue_comment` only): checks `isCrossRepository` — exits 1 if fork or API failure -3. Checks out the PR branch via `gh pr checkout` -4. Deletes `.github/skills/` and `.github/instructions/` (prevents fork-added files) -5. Restores them from the base branch SHA (best-effort, non-fatal) +2. Checks out the PR branch via `gh pr checkout` +3. Deletes `.github/skills/` and `.github/instructions/` (prevents fork-added files) +4. Restores them from the base branch SHA (best-effort, non-fatal) **Behavior by trigger:** -- **`workflow_dispatch`**: Fork guard skipped. Platform checkout is skipped, so the restore IS the final workspace state (trusted files from base branch) -- **`issue_comment`** (same-repo): Fork guard passes. Platform re-checks out PR branch — files already match, effectively a no-op -- **`issue_comment`** (fork): Fork guard rejects — exits 1 with actionable notice to use `workflow_dispatch` -- **`pull_request`** (same-repo): Fork guard skipped. Files already exist, restore is a no-op +- **`workflow_dispatch`**: Platform checkout is skipped, so the restore IS the final workspace state (trusted files from base branch) +- **`pull_request`** (same-repo): User step restores trusted infra. `checkout_pr_branch.cjs` runs after and re-checks out PR branch — for same-repo PRs, skill files typically match main unless the PR modified them. +- **`pull_request`** (fork with `forks: ["*"]`): Same as above, but fork's skill files may differ. Same residual risk as `issue_comment` fork case — agent is sandboxed, pre-flight catches missing `SKILL.md`. +- **`issue_comment`** (same-repo): Platform re-checks out PR branch — files already match, effectively a no-op +- **`issue_comment`** (fork): Platform re-checks out fork branch after us, overwriting restored files. Agent is sandboxed; pre-flight in the prompt catches missing `SKILL.md` ### Anti-Patterns @@ -197,7 +200,7 @@ Manual triggers (`workflow_dispatch`, `issue_comment`) should bypass the gate. N | What | Behavior | Workaround | |------|----------|------------| -| User steps always before platform steps | Cannot run user code after `checkout_pr_branch.cjs` | Use `workflow_dispatch` for fork PRs; see [gh-aw#18481](https://github.com/github/gh-aw/issues/18481) | +| User steps always before platform steps | Cannot run user code after `checkout_pr_branch.cjs` | For `issue_comment` fork PRs, accept sandboxed residual risk; see [gh-aw#18481](https://github.com/github/gh-aw/issues/18481) | | `--allow-all-tools` in lock.yml | Emitted by `gh aw compile` | Cannot override from `.md` source | | MCP integrity filtering | Fork PRs blocked as "unapproved" | Use `steps:` checkout instead of MCP | | `gh` CLI inside agent | Credentials scrubbed | Use `steps:` for API calls, or MCP tools | @@ -215,8 +218,8 @@ Manual triggers (`workflow_dispatch`, `issue_comment`) should bypass the gate. N | Symptom | Cause | Fix | |---------|-------|-----| | Agent evaluates wrong PR | `workflow_dispatch` checks out workflow branch | Add `gh pr checkout` in `steps:` | -| Agent can't find SKILL.md | Fork PR branch doesn't have `.github/skills/` | Agent posts "rebase or use `workflow_dispatch`" message; or rebase fork on `main` | -| Fork PR rejected on `/evaluate-tests` | Fail-closed fork guard in `Checkout-GhAwPr.ps1` | Use `workflow_dispatch` with `pr_number` input instead | +| Agent can't find SKILL.md | Fork PR branch doesn't include `.github/skills/` | Rebase fork on `main`, or use `workflow_dispatch` with `pr_number` input | +| Fork PR skipped on `pull_request` | `forks: ["*"]` not in workflow frontmatter | Add `forks: ["*"]` under `pull_request:` in the `.md` source and recompile | | `gh` commands fail in agent | Credentials scrubbed inside container | Move to `steps:` section | | Lock file out of date | Forgot to recompile | Run `gh aw compile` | | Integrity filtering warning | MCP reading fork PR data | Expected, non-blocking | diff --git a/.github/scripts/Checkout-GhAwPr.ps1 b/.github/scripts/Checkout-GhAwPr.ps1 index 4a6380a50421..a2f9533bb7d2 100644 --- a/.github/scripts/Checkout-GhAwPr.ps1 +++ b/.github/scripts/Checkout-GhAwPr.ps1 @@ -4,10 +4,13 @@ .DESCRIPTION Checks out a PR branch and restores trusted agent infrastructure (skills, - instructions) from the base branch. For issue_comment triggers, fork PRs - are rejected (fail-closed) because the platform's checkout_pr_branch.cjs - overwrites restored files after user steps. Fork PRs should use - workflow_dispatch instead. + instructions) from the base branch. Works for both same-repo and fork PRs. + + This script is only invoked for workflow_dispatch triggers. For pull_request + and issue_comment, the gh-aw platform's checkout_pr_branch.cjs handles PR + checkout automatically (it runs as a platform step after all user steps). + workflow_dispatch skips the platform checkout entirely, so this script is + the only thing that gets the PR code onto disk. SECURITY NOTE: This script checks out PR code onto disk. This is safe because NO subsequent user steps execute workspace code — the gh-aw @@ -26,7 +29,6 @@ PR_NUMBER - PR number to check out GITHUB_REPOSITORY - owner/repo (set by GitHub Actions) GITHUB_ENV - path to env file (set by GitHub Actions) - GITHUB_EVENT_NAME - trigger type (set by GitHub Actions) #> $ErrorActionPreference = 'Stop' @@ -40,25 +42,6 @@ if (-not $env:PR_NUMBER -or $env:PR_NUMBER -eq '0') { $PrNumber = $env:PR_NUMBER -# ── Fork guard (issue_comment only) ───────────────────────────────────────── -# For issue_comment triggers, platform's checkout_pr_branch.cjs runs AFTER user -# steps and re-checks out the fork branch, overwriting any restored skill/instruction -# files. A fork could include a crafted SKILL.md that alters agent behavior. -# Fail closed: if we can't verify origin, exit 1 (not 0). -# Fork PRs can still be evaluated via workflow_dispatch (where platform checkout is skipped). - -if ($env:GITHUB_EVENT_NAME -eq 'issue_comment') { - $isFork = gh pr view $PrNumber --repo $env:GITHUB_REPOSITORY --json isCrossRepository --jq '.isCrossRepository' 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Could not verify PR origin — failing closed" - exit 1 - } - if ($isFork -eq 'true') { - Write-Host "::notice::Fork PR detected — /evaluate-tests via issue_comment is not supported for fork PRs. Use workflow_dispatch with pr_number=$PrNumber instead." - exit 1 - } -} - # ── Save base branch SHA ───────────────────────────────────────────────────── # Must be captured BEFORE checkout replaces HEAD. # Exported for potential use by downstream platform steps (e.g., checkout_pr_branch.cjs) @@ -82,11 +65,9 @@ Write-Host "✅ Checked out PR #$PrNumber" git log --oneline -1 # ── Restore agent infrastructure from base branch ──────────────────────────── -# Best-effort restore of skill/instruction files from the base branch. -# - workflow_dispatch: platform checkout is skipped, so this IS the final state -# - issue_comment (same-repo): platform's checkout_pr_branch.cjs runs after and -# overwrites, but files already match (same repo). Fork PRs are blocked above. -# - pull_request (same-repo): files already exist, this is a no-op +# This script only runs for workflow_dispatch (other triggers use the platform's +# checkout_pr_branch.cjs instead). For workflow_dispatch the platform checkout is +# skipped, so this restore IS the final workspace state. # rm -rf first to prevent fork-added files from surviving the restore. if (Test-Path '.github/skills/') { Remove-Item -Recurse -Force '.github/skills/' } diff --git a/.github/workflows/copilot-evaluate-tests.lock.yml b/.github/workflows/copilot-evaluate-tests.lock.yml index c1e75132b153..5b5dc7f9b37d 100644 --- a/.github/workflows/copilot-evaluate-tests.lock.yml +++ b/.github/workflows/copilot-evaluate-tests.lock.yml @@ -22,7 +22,7 @@ # # Evaluates test quality, coverage, and appropriateness on PRs that add or modify tests # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"9a0e480927d61956be62f85669d2e599525442694d280126849fe87e727c31df","compiler_version":"v0.62.2","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"d671028235c1b911c7a816a257b07b02793a6b57747b4358f792af183e26ca07","compiler_version":"v0.62.2","strict":true} name: "Evaluate PR Tests" "on": @@ -30,6 +30,8 @@ name: "Evaluate PR Tests" types: - created pull_request: + # forks: # Fork filtering applied via job conditions + # - "*" # Fork filtering applied via job conditions paths: - src/**/tests/** - src/**/test/** @@ -57,9 +59,9 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && + (needs.pre_activation.outputs.activated == 'true') && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && - startsWith(github.event.comment.body, '/evaluate-tests'))) && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id))) + startsWith(github.event.comment.body, '/evaluate-tests'))) runs-on: ubuntu-slim permissions: contents: read @@ -324,7 +326,8 @@ jobs: run: "TEST_FILES=$(gh pr diff \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --name-only \\\n | grep -E '\\.(cs|xaml)$' \\\n | grep -iE '(tests?/|TestCases|UnitTests|DeviceTests)' \\\n || true)\nif [ -z \"$TEST_FILES\" ]; then\n echo \"⏭️ No test source files (.cs/.xaml) found in PR diff. Skipping evaluation.\"\n exit 1\nfi\necho \"✅ Found test files to evaluate:\"\necho \"$TEST_FILES\" | head -20\n" - env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + PR_NUMBER: ${{ inputs.pr_number }} + if: github.event_name == 'workflow_dispatch' name: Checkout PR and restore agent infrastructure run: pwsh .github/scripts/Checkout-GhAwPr.ps1 @@ -986,9 +989,9 @@ jobs: pre_activation: if: > - ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && - startsWith(github.event.comment.body, '/evaluate-tests'))) && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) + startsWith(github.event.comment.body, '/evaluate-tests')) runs-on: ubuntu-slim outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} diff --git a/.github/workflows/copilot-evaluate-tests.md b/.github/workflows/copilot-evaluate-tests.md index c84f83f8855e..854c2ec407d8 100644 --- a/.github/workflows/copilot-evaluate-tests.md +++ b/.github/workflows/copilot-evaluate-tests.md @@ -3,6 +3,7 @@ description: Evaluates test quality, coverage, and appropriateness on PRs that a on: pull_request: types: [opened, synchronize, reopened, ready_for_review] + forks: ["*"] paths: - 'src/**/tests/**' - 'src/**/test/**' @@ -72,10 +73,14 @@ steps: echo "✅ Found test files to evaluate:" echo "$TEST_FILES" | head -20 + # Only needed for workflow_dispatch — for pull_request and issue_comment, + # the gh-aw platform's checkout_pr_branch.cjs handles PR checkout automatically. + # workflow_dispatch skips the platform checkout entirely, so we must do it here. - name: Checkout PR and restore agent infrastructure + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + PR_NUMBER: ${{ inputs.pr_number }} run: pwsh .github/scripts/Checkout-GhAwPr.ps1 --- From 53e657527f001ed18635eea3ab9a3a7de42c1acf Mon Sep 17 00:00:00 2001 From: Stephane Delcroix Date: Sat, 28 Mar 2026 20:23:57 +0100 Subject: [PATCH 03/11] Add regression test for #34713: Binding with Converter and x:DataType is compiled (#34717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds regression tests for #34713 verifying the XAML source generator correctly handles bindings with `Converter={StaticResource ...}` inside `x:DataType` scopes. Closes #34713 ## Investigation After thorough investigation of the source generator pipeline (`KnownMarkups.cs`, `CompiledBindingMarkup.cs`, `NodeSGExtensions.cs`): ### When converter IS in page resources (compile-time resolution ✅) `GetResourceNode()` walks the XAML tree, finds the converter resource, and `ProvideValueForStaticResourceExtension` returns the variable directly — **no runtime `ProvideValue` call**. The converter is referenced at compile time. ### When converter is NOT in page resources (runtime resolution ✅) `GetResourceNode()` returns null → falls through to `IsValueProvider` → generates `StaticResourceExtension.ProvideValue(serviceProvider)`. The `SimpleValueTargetProvider` provides the full parent chain, and `TryGetApplicationLevelResource` checks `Application.Current.Resources`. The binding IS still compiled into a `TypedBinding` — only the converter resolution is deferred. ### Verified on both `main` and `net11.0` All tests pass on both branches. ## Tests added | Test | What it verifies | |------|-----------------| | `SourceGenResolvesConverterAtCompileTime_ImplicitResources` | Converter in implicit `` → compile-time resolution, no `ProvideValue` | | `SourceGenResolvesConverterAtCompileTime_ExplicitResourceDictionary` | Converter in explicit `` → compile-time resolution, no `ProvideValue` | | `SourceGenCompilesBindingWithConverterToTypedBinding` | Converter NOT in page resources → still compiled to `TypedBinding`, no raw `Binding` fallback | | `BindingWithConverterFromAppResourcesWorksCorrectly` × 3 | Runtime behavior correct for all inflators (Runtime, XamlC, SourceGen) | Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xaml.UnitTests/Issues/Maui34713.xaml | 14 ++ .../Xaml.UnitTests/Issues/Maui34713.xaml.cs | 203 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml create mode 100644 src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml new file mode 100644 index 000000000000..b621d073f473 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs new file mode 100644 index 000000000000..098fb9051b21 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34713.xaml.cs @@ -0,0 +1,203 @@ +using System; +using System.Globalization; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Controls.Core.UnitTests; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.UnitTests; +using Xunit; + +using static Microsoft.Maui.Controls.Xaml.UnitTests.MockSourceGenerator; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public partial class Maui34713 : ContentPage +{ + public Maui34713() + { + InitializeComponent(); + } + + [Collection("Issue")] + public class Tests : IDisposable + { + public Tests() + { + DispatcherProvider.SetCurrent(new DispatcherProviderStub()); + } + + public void Dispose() + { + AppInfo.SetCurrent(null); + Application.SetCurrentApplication(null); + DispatcherProvider.SetCurrent(null); + } + + const string SharedCs = @" +using System; +using System.Globalization; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; +namespace Microsoft.Maui.Controls.Xaml.UnitTests +{ +public class Maui34713ViewModel { public bool IsActive { get; set; } public string Name { get; set; } = """"; } +public class Maui34713BoolToTextConverter : IValueConverter { + public object Convert(object v, Type t, object p, CultureInfo c) => v is true ? ""Active"" : ""Inactive""; + public object ConvertBack(object v, Type t, object p, CultureInfo c) => throw new NotImplementedException(); + } +}"; + +[Fact] +internal void SourceGenResolvesConverterAtCompileTime_ImplicitResources() +{ + // When converter IS in page resources (implicit), source gen should + // resolve it at compile time - no runtime ProvideValue needed. + var xaml = @" + + + + + + + "; + + var cs = @" +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; +namespace Microsoft.Maui.Controls.Xaml.UnitTests +{ +[XamlProcessing(XamlInflator.Runtime, true)] +public partial class Maui34713Test1 : ContentPage { public Maui34713Test1() { InitializeComponent(); } } + }" + SharedCs; + + var result = CreateMauiCompilation() + .WithAdditionalSource(cs, hintName: "Maui34713Test1.xaml.cs") + .RunMauiSourceGenerator(new AdditionalXamlFile("Issues/Maui34713Test1.xaml", xaml, TargetFramework: "net10.0")); + + var generated = result.GeneratedInitializeComponent(); + + Assert.Contains("TypedBinding", generated, StringComparison.Ordinal); + // Converter should be resolved at compile time - no ProvideValue call + Assert.DoesNotContain(".ProvideValue(", generated, StringComparison.Ordinal); +} + +[Fact] +internal void SourceGenResolvesConverterAtCompileTime_ExplicitResourceDictionary() +{ + // When converter IS in page resources (explicit RD), source gen should + // also resolve it at compile time. + var xaml = @" + + + + + + + + + "; + + var cs = @" +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; +namespace Microsoft.Maui.Controls.Xaml.UnitTests +{ +[XamlProcessing(XamlInflator.Runtime, true)] +public partial class Maui34713Test2 : ContentPage { public Maui34713Test2() { InitializeComponent(); } } + }" + SharedCs; + + var result = CreateMauiCompilation() + .WithAdditionalSource(cs, hintName: "Maui34713Test2.xaml.cs") + .RunMauiSourceGenerator(new AdditionalXamlFile("Issues/Maui34713Test2.xaml", xaml, TargetFramework: "net10.0")); + + var generated = result.GeneratedInitializeComponent(); + + Assert.Contains("TypedBinding", generated, StringComparison.Ordinal); + // Converter should be resolved at compile time - no ProvideValue call + Assert.DoesNotContain(".ProvideValue(", generated, StringComparison.Ordinal); +} + +[Fact] +internal void SourceGenCompilesBindingWithConverterToTypedBinding() +{ + // When the converter is NOT in page resources, the binding should + // still be compiled into a TypedBinding. + var result = CreateMauiCompilation() + .WithAdditionalSource( + @"using System; + using System.Globalization; + using Microsoft.Maui.Controls; + using Microsoft.Maui.Controls.Xaml; + + namespace Microsoft.Maui.Controls.Xaml.UnitTests; + + [XamlProcessing(XamlInflator.Runtime, true)] + public partial class Maui34713 : ContentPage + { + public Maui34713() => InitializeComponent(); + } + + public class Maui34713ViewModel + { + public bool IsActive { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Maui34713BoolToTextConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? ""Active"" : ""Inactive""; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); + } + ") + .RunMauiSourceGenerator(typeof(Maui34713)); + + var generated = result.GeneratedInitializeComponent(); + + Assert.Contains("TypedBinding", generated, StringComparison.Ordinal); + Assert.Contains("Converter = extension.Converter", generated, StringComparison.Ordinal); + Assert.DoesNotContain("new global::Microsoft.Maui.Controls.Binding(", generated, StringComparison.Ordinal); +} + +[Theory] +[XamlInflatorData] +internal void BindingWithConverterFromAppResourcesWorksCorrectly(XamlInflator inflator) +{ + var mockApp = new MockApplication(); + mockApp.Resources.Add("BoolToTextConverter", new Maui34713BoolToTextConverter()); + Application.SetCurrentApplication(mockApp); + + var page = new Maui34713(inflator); + page.BindingContext = new Maui34713ViewModel { IsActive = true, Name = "Test" }; + + Assert.Equal("Active", page.label0.Text); + Assert.Equal("Test", page.label1.Text); +} +} +} + +#nullable enable + +public class Maui34713ViewModel +{ + public bool IsActive { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class Maui34713BoolToTextConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? "Active" : "Inactive"; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} From 764351813224dc8de56860356fb918fbbd13f4d4 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:25:02 +0530 Subject: [PATCH 04/11] Fixed-33770 : [iOS 26] CarouselView does not scroll to the correct last item --- .../Items2/iOS/CarouselViewController2.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index abba6d291593..6d3248f4b586 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -537,7 +537,7 @@ internal void UpdateFromCurrentItem() UpdateVisualStates(); } - internal void UpdateFromPosition() + internal async void UpdateFromPosition() { if (!InitialPositionSet) { @@ -557,7 +557,20 @@ internal void UpdateFromPosition() var currentItemPosition = GetIndexForItem(carousel.CurrentItem).Row; var carouselPosition = carousel.Position; - ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + await Task.Delay(200).ContinueWith(_ => + { + MainThread.BeginInvokeOnMainThread(() => + { + ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + }); + }); + } + else + { + ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + } // SetCurrentItem(carouselPosition); } From 5a340ef083dfedec45f6c8027437aa7e9c7cc922 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:55:32 +0530 Subject: [PATCH 05/11] Add DeviceUdid support to verify-tests-fail skill and update CarouselView fix --- .../scripts/verify-tests-fail.ps1 | 33 +++++++++++++++++-- .../Items2/iOS/CarouselViewController2.cs | 15 +-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 index 5eacfed3135a..7619b01d93e2 100644 --- a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 +++ b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 @@ -76,6 +76,9 @@ param( [Parameter(Mandatory = $false)] [string]$PRNumber, + [Parameter(Mandatory = $false)] + [string]$DeviceUdid, + [Parameter(Mandatory = $false)] [switch]$RequireFullVerification ) @@ -379,7 +382,15 @@ if ($DetectedFixFiles.Count -eq 0) { # Use shared BuildAndRunHostApp.ps1 infrastructure with -Rebuild to ensure clean builds $buildScript = Join-Path $RepoRoot ".github/scripts/BuildAndRunHostApp.ps1" - & $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $TestLog + $buildParams = @{ + Platform = $Platform + TestFilter = $TestFilter + Rebuild = $true + } + if ($DeviceUdid) { + $buildParams.DeviceUdid = $DeviceUdid + } + & $buildScript @buildParams 2>&1 | Tee-Object -FilePath $TestLog # Parse test results using shared function $testOutputLog = Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log" @@ -749,7 +760,15 @@ Write-Log "==========================================" # Use shared BuildAndRunHostApp.ps1 infrastructure with -Rebuild to ensure clean builds $buildScript = Join-Path $RepoRoot ".github/scripts/BuildAndRunHostApp.ps1" -& $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $WithoutFixLog +$buildParams = @{ + Platform = $Platform + TestFilter = $TestFilter + Rebuild = $true +} +if ($DeviceUdid) { + $buildParams.DeviceUdid = $DeviceUdid +} +& $buildScript @buildParams 2>&1 | Tee-Object -FilePath $WithoutFixLog $withoutFixResult = Get-TestResultFromLog -LogFile (Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log") @@ -777,7 +796,15 @@ Write-Log "==========================================" Write-Log "STEP 4: Running tests WITH fix (should PASS)" Write-Log "==========================================" -& $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $WithFixLog +$buildParams = @{ + Platform = $Platform + TestFilter = $TestFilter + Rebuild = $true +} +if ($DeviceUdid) { + $buildParams.DeviceUdid = $DeviceUdid +} +& $buildScript @buildParams 2>&1 | Tee-Object -FilePath $WithFixLog $withFixResult = Get-TestResultFromLog -LogFile (Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log") diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index 6d3248f4b586..a61b70418cc2 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -557,20 +557,7 @@ internal async void UpdateFromPosition() var currentItemPosition = GetIndexForItem(carousel.CurrentItem).Row; var carouselPosition = carousel.Position; - if (OperatingSystem.IsIOSVersionAtLeast(26)) - { - await Task.Delay(200).ContinueWith(_ => - { - MainThread.BeginInvokeOnMainThread(() => - { - ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); - }); - }); - } - else - { - ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); - } + ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); // SetCurrentItem(carouselPosition); } From 8d937792e41ce7593611f70a5189e697f7be41ac Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:55:47 +0530 Subject: [PATCH 06/11] Revert "Add DeviceUdid support to verify-tests-fail skill and update CarouselView fix" This reverts commit 786fb73ba32c5a532e1adb10aacc639cf329079c. --- .../scripts/verify-tests-fail.ps1 | 33 ++----------------- .../Items2/iOS/CarouselViewController2.cs | 15 ++++++++- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 index 7619b01d93e2..5eacfed3135a 100644 --- a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 +++ b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 @@ -76,9 +76,6 @@ param( [Parameter(Mandatory = $false)] [string]$PRNumber, - [Parameter(Mandatory = $false)] - [string]$DeviceUdid, - [Parameter(Mandatory = $false)] [switch]$RequireFullVerification ) @@ -382,15 +379,7 @@ if ($DetectedFixFiles.Count -eq 0) { # Use shared BuildAndRunHostApp.ps1 infrastructure with -Rebuild to ensure clean builds $buildScript = Join-Path $RepoRoot ".github/scripts/BuildAndRunHostApp.ps1" - $buildParams = @{ - Platform = $Platform - TestFilter = $TestFilter - Rebuild = $true - } - if ($DeviceUdid) { - $buildParams.DeviceUdid = $DeviceUdid - } - & $buildScript @buildParams 2>&1 | Tee-Object -FilePath $TestLog + & $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $TestLog # Parse test results using shared function $testOutputLog = Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log" @@ -760,15 +749,7 @@ Write-Log "==========================================" # Use shared BuildAndRunHostApp.ps1 infrastructure with -Rebuild to ensure clean builds $buildScript = Join-Path $RepoRoot ".github/scripts/BuildAndRunHostApp.ps1" -$buildParams = @{ - Platform = $Platform - TestFilter = $TestFilter - Rebuild = $true -} -if ($DeviceUdid) { - $buildParams.DeviceUdid = $DeviceUdid -} -& $buildScript @buildParams 2>&1 | Tee-Object -FilePath $WithoutFixLog +& $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $WithoutFixLog $withoutFixResult = Get-TestResultFromLog -LogFile (Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log") @@ -796,15 +777,7 @@ Write-Log "==========================================" Write-Log "STEP 4: Running tests WITH fix (should PASS)" Write-Log "==========================================" -$buildParams = @{ - Platform = $Platform - TestFilter = $TestFilter - Rebuild = $true -} -if ($DeviceUdid) { - $buildParams.DeviceUdid = $DeviceUdid -} -& $buildScript @buildParams 2>&1 | Tee-Object -FilePath $WithFixLog +& $buildScript -Platform $Platform -TestFilter $TestFilter -Rebuild 2>&1 | Tee-Object -FilePath $WithFixLog $withFixResult = Get-TestResultFromLog -LogFile (Join-Path $RepoRoot "CustomAgentLogsTmp/UITests/test-output.log") diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index a61b70418cc2..6d3248f4b586 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -557,7 +557,20 @@ internal async void UpdateFromPosition() var currentItemPosition = GetIndexForItem(carousel.CurrentItem).Row; var carouselPosition = carousel.Position; - ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + await Task.Delay(200).ContinueWith(_ => + { + MainThread.BeginInvokeOnMainThread(() => + { + ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + }); + }); + } + else + { + ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); + } // SetCurrentItem(carouselPosition); } From aecb15707ae5fd902158ce16937af445f4324ec5 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:06:19 +0530 Subject: [PATCH 07/11] added ios26 image. --- ...arouselViewShouldScrollToRightPosition.png | Bin 28009 -> 28645 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/CarouselViewShouldScrollToRightPosition.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/CarouselViewShouldScrollToRightPosition.png index 434ad2893ccc5eb5213f93eb652849462c1be966..78afb8f9a6cee14477d39dc71de864a25cca7bed 100644 GIT binary patch literal 28645 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 Date: Fri, 20 Feb 2026 18:01:16 +0530 Subject: [PATCH 08/11] Update CarouselViewController2.cs --- .../Items2/iOS/CarouselViewController2.cs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index 6d3248f4b586..644e02d23d23 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics.CodeAnalysis; +using System.Threading; using System.Threading.Tasks; using CoreGraphics; using Foundation; @@ -18,6 +19,7 @@ public class CarouselViewController2 : ItemsViewController2 int _section = 0; bool _wasDetachedFromWindow = false; CarouselViewLoopManager _carouselViewLoopManager; + CancellationTokenSource _scrollDebounce; // We need to keep track of the old views to update the visual states // if this is null we are not attached to the window @@ -559,13 +561,48 @@ internal async void UpdateFromPosition() if (OperatingSystem.IsIOSVersionAtLeast(26)) { - await Task.Delay(200).ContinueWith(_ => + // Cancel any pending position update to prevent race conditions + _scrollDebounce?.Cancel(); + _scrollDebounce = new CancellationTokenSource(); + var token = _scrollDebounce.Token; + + try { - MainThread.BeginInvokeOnMainThread(() => + // On iOS 26, UICollectionView can emit intermediate scroll callbacks before settling. + // A slightly longer delay than UpdateInitialPosition's 100ms was empirically chosen + // to ensure the scroll operation runs after those intermediate callbacks complete. + await Task.Delay(100, token).ContinueWith(_ => { - ScrollToPosition(carouselPosition, currentItemPosition, carousel.AnimatePositionChanges); - }); - }); + MainThread.BeginInvokeOnMainThread(() => + { + // Re-validate state after the delay to avoid operating on stale or disposed views + if (!InitialPositionSet) + { + return; + } + + if (ItemsView is not CarouselView currentCarousel) + { + return; + } + + if (ItemsSource is null || ItemsSource.ItemCount == 0) + { + return; + } + + var updatedCurrentItemPosition = GetIndexForItem(currentCarousel.CurrentItem).Row; + var updatedCarouselPosition = currentCarousel.Position; + + ScrollToPosition(updatedCarouselPosition, updatedCurrentItemPosition, currentCarousel.AnimatePositionChanges); + }); + }, token); + } + catch (Exception) + { + // Silently handle exceptions to prevent app crashes in async void context + // If the delay or scroll operation fails, the carousel will remain in its current state + } } else { From 99bae88cc92deb99b4eb200cba9118bb75cc9965 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:26:41 +0530 Subject: [PATCH 09/11] Addressed concerns. --- .../Handlers/Items2/iOS/CarouselViewController2.cs | 12 ++++++++++++ .../Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt | 1 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 1 + 3 files changed, 14 insertions(+) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index 644e02d23d23..b41d3f9741e5 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -768,6 +768,18 @@ internal protected override void UpdateVisibility() CollectionView.Hidden = true; } } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _scrollDebounce?.Cancel(); + _scrollDebounce?.Dispose(); + _scrollDebounce = null; + } + + base.Dispose(disposing); + } } class CarouselViewLoopManager : IDisposable diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index f340c9351931..76a95b3835e3 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void +override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index f340c9351931..76a95b3835e3 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void +override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void From 6ae8f5ee187a9e449e075e5fa7b76b44549ea766 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:22:32 +0530 Subject: [PATCH 10/11] enable ios26 test. --- .../tests/TestCases.Shared.Tests/Tests/Issues/Issue16020.cs | 4 ---- .../tests/TestCases.Shared.Tests/Tests/Issues/Issue17283.cs | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue16020.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue16020.cs index feb3893e9313..3d79918e4cb2 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue16020.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue16020.cs @@ -16,10 +16,6 @@ public Issue16020(TestDevice testDevice) : base(testDevice) [Category(UITestCategories.CarouselView)] public void CarouselViewiOSCrashPreventionTest() { - if (App is AppiumIOSApp iosApp && HelperExtensions.IsIOS26OrHigher(iosApp)) // Issue Link: https://github.com/dotnet/maui/issues/33770 - { - Assert.Ignore("Ignored due to CarouselView scroll item issue in iOS 26."); - } // Wait for the page to load completely App.WaitForElement("My Recipes"); App.Tap("My Recipes"); diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17283.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17283.cs index 305de385f4d9..04ea53f73214 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17283.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17283.cs @@ -16,10 +16,6 @@ public Issue17283(TestDevice testDevice) : base(testDevice) { } [FailsOnWindowsWhenRunningOnXamarinUITest("Currently fails on Windows; see https://github.com/dotnet/maui/issues/24482")] public void CarouselViewShouldScrollToRightPosition() { - if (App is AppiumIOSApp iosApp && HelperExtensions.IsIOS26OrHigher(iosApp)) // iOS 26 has issues with CarouselView scrolling items. Issue Link: https://github.com/dotnet/maui/issues/33770 - { - Assert.Ignore("Ignored due to CarouselView scroll item issue in iOS 26."); - } App.WaitForElement("goToLastItemButton"); App.Click("goToLastItemButton"); App.WaitForElement("5"); From 8d220586c267af3b08f7da7fa258a3e1d83bbb51 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:10:55 +0530 Subject: [PATCH 11/11] Update CarouselViewController2.cs --- .../Handlers/Items2/iOS/CarouselViewController2.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index b41d3f9741e5..e2de6f215c40 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -561,9 +561,11 @@ internal async void UpdateFromPosition() if (OperatingSystem.IsIOSVersionAtLeast(26)) { - // Cancel any pending position update to prevent race conditions - _scrollDebounce?.Cancel(); + var old = _scrollDebounce; _scrollDebounce = new CancellationTokenSource(); + // Cancel any pending position update to prevent race conditions + old?.Cancel(); + old?.Dispose(); var token = _scrollDebounce.Token; try @@ -598,10 +600,9 @@ await Task.Delay(100, token).ContinueWith(_ => }); }, token); } - catch (Exception) + catch (OperationCanceledException) { - // Silently handle exceptions to prevent app crashes in async void context - // If the delay or scroll operation fails, the carousel will remain in its current state + // Expected when a newer UpdateFromPosition call cancels this one } } else