diff --git a/.github/docs/trigger-azdo-pipeline-setup.md b/.github/docs/trigger-azdo-pipeline-setup.md new file mode 100644 index 000000000000..7a2c7252262f --- /dev/null +++ b/.github/docs/trigger-azdo-pipeline-setup.md @@ -0,0 +1,225 @@ +# Triggering Azure DevOps Pipelines from GitHub Actions (No PAT) + +This guide explains how to invoke Azure DevOps pipelines (e.g. in **dnceng-public** or **DevDiv**) +from GitHub Actions using **OIDC federated credentials** — no PAT or stored secrets needed. + +## Architecture + +``` +GitHub Actions ──► GitHub OIDC Provider ──► Azure AD (federated credential) ──► AzDO REST API + (JWT id-token) (exchange for bearer token) (Run Pipeline) +``` + +1. The workflow requests an OIDC JWT from GitHub's token endpoint +2. The JWT is exchanged with Azure AD via the managed identity's federated credential +3. Azure AD returns a bearer token scoped to Azure DevOps +4. The bearer token is used to call the AzDO REST API to trigger the pipeline + +> **Important:** The `azure/login` GitHub Action may be **blocked by org policy** +> (e.g. in the `dotnet` org). The workflow uses **manual OIDC token exchange via +> `curl`** instead, which works everywhere that `id-token: write` is allowed. + +## Prerequisites + +- Azure CLI installed locally (for one-time setup) +- Access to an Azure subscription + resource group +- **Project Collection Administrator** (or delegated) access in the target AzDO org to add users +- GitHub repo admin access to configure secrets + +--- + +## Step 1: Create a User-Assigned Managed Identity + +```bash +# Choose your resource group and identity name +RG="rg-maui-automation" +IDENTITY_NAME="id-maui-azdo-trigger" +LOCATION="eastus" + +# Create the resource group if it doesn't exist +az group create --name $RG --location $LOCATION + +# Create the managed identity +az identity create --name $IDENTITY_NAME --resource-group $RG --location $LOCATION + +# Capture the IDs you'll need +CLIENT_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RG --query clientId -o tsv) +PRINCIPAL_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RG --query principalId -o tsv) +TENANT_ID=$(az account show --query tenantId -o tsv) +SUBSCRIPTION_ID=$(az account show --query id -o tsv) + +echo "CLIENT_ID: $CLIENT_ID" +echo "PRINCIPAL_ID: $PRINCIPAL_ID" +echo "TENANT_ID: $TENANT_ID" +echo "SUBSCRIPTION_ID: $SUBSCRIPTION_ID" +``` + +## Step 2: Add OIDC Federated Credential for GitHub Actions + +This lets GitHub Actions authenticate as the identity without storing any secrets. + +> **Critical: Subject claim is CASE-SENSITIVE.** The GitHub username/org in the +> subject must match the exact casing used by GitHub (e.g. `JanKrivanek` not +> `jankrivanek`). A mismatch produces `AADSTS70021`. + +> **Microsoft tenant restriction:** For managed identities in the Microsoft +> corporate tenant (`72f988bf-...`), the OIDC token must include an `enterprise` +> claim with value `microsoft`, `github`, or `microsoftopensource`. Personal forks +> outside these GitHub Enterprise orgs will fail with `AADSTS7002381`. +> This means **only repos in `dotnet`, `microsoft`, etc. orgs work** — not personal forks. + +```bash +# Allow from main branch +az identity federated-credential create \ + --name github-actions-main \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:ref:refs/heads/main" \ + --audiences "api://AzureADTokenExchange" +``` + +> **Subject claim mapping:** The OIDC token's `sub` claim is what Azure AD matches +> against the `--subject` parameter. For `issue_comment` events (like the `/review` +> command), the workflow runs from the default branch, so the subject is +> `repo:dotnet/maui:ref:refs/heads/main`. For `pull_request` events, the subject +> would be `repo:dotnet/maui:pull_request`. This is why the case-sensitivity +> warning above is critical — the `sub` claim value must match exactly. + +Add more federated credentials for other branches or trigger types as needed: + +```bash +# Specific dev branch +az identity federated-credential create \ + --name github-actions-dev-branch \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:ref:refs/heads/dev/myteam/feature" \ + --audiences "api://AzureADTokenExchange" + +# Pull request events +az identity federated-credential create \ + --name github-actions-pr \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:pull_request" \ + --audiences "api://AzureADTokenExchange" + +# GitHub environment (recommended for production — enables approval gates) +az identity federated-credential create \ + --name github-actions-env-azdo \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:environment:azdo-trigger" \ + --audiences "api://AzureADTokenExchange" +``` + +## Step 3: Add the Identity to Azure DevOps + +The managed identity must be added as a user in **each** AzDO organization you want to trigger pipelines in. + +### Adding the identity + +1. Go to the AzDO org → **Organization Settings** → **Users** +2. Click **Add users** +3. Search for the managed identity by its **display name** +4. Set **Access level** to **Basic** (see note below) +5. Add the user to the target project +6. Click **Add** + +> **Critical: Access level must be Basic, not Stakeholder.** Stakeholder access +> does not grant sufficient permissions for build operations. Even with explicit +> "Queue builds" permissions, Stakeholder-level identities get `TF215106: Access +> denied` errors. Request **Basic** access when filing the request. + +> **Important:** Use the identity's **Object (Principal) ID** from the +> **Enterprise Applications** pane in Entra admin center — NOT the App +> Registration object ID. + +### Grant Build Queue Permission + +The identity needs **"Queue builds"** permission on the target pipeline(s): + +1. Go to the project → **Pipelines** → find the target pipeline +2. Click the **⋮** menu → **Manage security** +3. Find your managed identity user +4. Set **"Queue builds"** to **Allow** + +### Per-organization requirements + +| AzDO Organization | Project | Example Pipelines | +|---|---|---| +| `dnceng-public` | `public` | 302 (maui-pr), 314 (maui-pr-devicetests) | +| `DevDiv` | `DevDiv` | 27723 | + +## Step 4: Set GitHub Repository Secrets + +In **dotnet/maui** → **Settings** → **Secrets and variables** → **Actions**, add: + +| Secret Name | Value | +|---|---| +| `AZDO_TRIGGER_CLIENT_ID` | The managed identity's Client ID | +| `AZDO_TRIGGER_TENANT_ID` | Your Azure AD Tenant ID | +| `AZDO_TRIGGER_SUBSCRIPTION_ID` | Your Azure Subscription ID | + +> Using distinct secret names (prefixed with `AZDO_TRIGGER_`) avoids conflicts +> with any existing `AZURE_*` secrets in the repo. + +## Step 5: Create the GitHub Actions Workflow + +See [`.github/workflows/review-trigger.yml`](../workflows/review-trigger.yml) for a ready-to-use workflow. + +## How It Works (Token Flow) + +``` +1. Workflow declares `permissions: { id-token: write }` at job level +2. Step 1 requests an OIDC JWT from GitHub's token endpoint via + $ACTIONS_ID_TOKEN_REQUEST_URL (audience: api://AzureADTokenExchange) +3. Step 2 sends the JWT to Azure AD token endpoint as a client_assertion + (grant_type=client_credentials) for the managed identity's client_id +4. Azure AD validates the JWT against the federated credential and returns + a bearer token scoped to AzDO (resource: 499b84ac-1321-427f-aa17-267ca6975798) +5. Step 3 calls POST dev.azure.com/{org}/{project}/_apis/pipelines/{id}/runs + with the bearer token +6. AzDO validates the token, checks the identity's permissions, and queues the build +``` + +> **Why not `azure/login`?** The `dotnet` GitHub org restricts which third-party +> Actions can run. `azure/login@v3` causes `startup_failure` because it's not in +> the org's allowed actions list. The manual `curl`-based OIDC exchange achieves +> the same result without any third-party dependencies. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `startup_failure` (no logs at all) | Third-party Action blocked by org policy | Don't use `azure/login`. Use manual `curl`-based OIDC exchange. | +| `AADSTS70021: No matching federated identity record found` | Subject claim case mismatch | Federated credential subject is **case-sensitive**. Use exact GitHub username casing (e.g. `JanKrivanek` not `jankrivanek`). | +| `AADSTS7002381: ... enterprise claim ... actual value is ''` | Personal fork outside GitHub Enterprise | Microsoft tenant requires `enterprise` claim. Only repos in `dotnet`, `microsoft`, etc. GitHub Enterprise orgs work. | +| `TF215106: Access denied. needs Queue builds permissions` | Stakeholder access level or missing permission | Upgrade identity to **Basic** access (not Stakeholder). Verify "Queue builds" is explicitly allowed on the pipeline. | +| `TF401444: Sign-in required` | Identity not added to AzDO org | Add the MI as a user in the AzDO Organization Settings → Users. | +| `403` from AzDO REST API | Missing permissions | Ensure the identity has "Queue builds" on the specific pipeline AND Basic access level. | +| `OIDC environment variables not available` | Missing `id-token: write` permission | Add `permissions: { id-token: write }` at the **job** level (not workflow level). | +| `Failed to get Azure AD token` | Wrong client_id/tenant_id or federated credential mismatch | Verify secrets match the MI's Client ID and Tenant ID. Check federated credential subject matches the actual OIDC claim. | + +## Lessons Learned + +1. **`azure/login` Action is blocked** in the `dotnet` GitHub org — use manual + `curl`-based OIDC token exchange instead. +2. **Federated credential subjects are case-sensitive** — `JanKrivanek` ≠ + `jankrivanek`. Always verify exact GitHub username/org casing. +3. **Microsoft tenant requires GitHub Enterprise membership** — personal forks + fail with `AADSTS7002381`. Only repos in enterprise-managed orgs work. +4. **Stakeholder access is insufficient** — even with explicit "Queue builds" + permissions, Stakeholder-level identities get `TF215106`. Request Basic. +5. **Add identity to EACH AzDO org separately** — permissions in `dnceng-public` + don't carry over to `DevDiv` and vice versa. + +## References + +- [Use service principals and managed identities in Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity) +- [AzDO Pipelines REST API — Run Pipeline](https://learn.microsoft.com/en-us/rest/api/azure/devops/pipelines/runs/run-pipeline?view=azure-devops-rest-7.1) +- [GitHub OIDC token docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) diff --git a/.github/workflows/review-trigger.yml b/.github/workflows/review-trigger.yml new file mode 100644 index 000000000000..ff9b28b17ec3 --- /dev/null +++ b/.github/workflows/review-trigger.yml @@ -0,0 +1,276 @@ +# Trigger the maui-copilot DevDiv pipeline when a maintainer comments '/review' on a PR. +# Uses OIDC (no PAT) — see .github/docs/trigger-azdo-pipeline-setup.md for identity setup. + +name: Review Trigger + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to review' + required: true + platform: + description: 'Target platform (android, ios, catalyst, windows, or empty for pipeline default)' + required: false + type: choice + options: + - '' + - android + - ios + - catalyst + - windows + pipeline_ref: + description: 'AzDO pipeline branch (default: main)' + required: false + default: 'main' + +jobs: + trigger-review: + # For issue_comment: only run on PR comments that are exactly '/review' or start with '/review ' + # For workflow_dispatch: always run + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.issue.pull_request && + (github.event.comment.body == '/review' || + startsWith(github.event.comment.body, '/review '))) + runs-on: ubuntu-latest + concurrency: + group: review-trigger-${{ github.event.issue.number || inputs.pr_number }} + cancel-in-progress: false + timeout-minutes: 10 + permissions: + id-token: write + contents: read + pull-requests: read + steps: + - name: Check actor permission + if: github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ github.token }} + run: | + PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission') + echo "User ${{ github.actor }} has permission: ${PERMISSION}" + # write, maintain, and admin can all trigger /review + if [[ "${PERMISSION}" != "admin" && "${PERMISSION}" != "maintain" && "${PERMISSION}" != "write" ]]; then + echo "::error::User ${{ github.actor }} does not have sufficient access. Only write/maintain/admin can trigger /review." + exit 1 + fi + + - name: Parse parameters + id: params + env: + GH_TOKEN: ${{ github.token }} + COMMENT_BODY: ${{ github.event.comment.body }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_PIPELINE_REF: ${{ inputs.pipeline_ref }} + run: | + # Valid platforms (from AzDO pipeline definition) + VALID_PLATFORMS="android ios catalyst windows" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + PR_NUMBER="${INPUT_PR_NUMBER}" + PLATFORM="${INPUT_PLATFORM}" + PIPELINE_REF="${INPUT_PIPELINE_REF:-main}" + else + PR_NUMBER="${{ github.event.issue.number }}" + # Strip the '/review' prefix and parse remaining args + ARGS=$(echo "${COMMENT_BODY}" | sed -n 's|^/review[[:space:]]*||p' | tr -s ' ') + PLATFORM="" + PIPELINE_REF="main" + # Parse args: positional platform, --branch , --platform + # Disable globbing so user input like '*.cs' doesn't expand + set -f + set -- ${ARGS} + while [ $# -gt 0 ]; do + case "$1" in + --branch|-b) + shift + if [ $# -gt 0 ] && [[ "$1" != --* ]]; then + PIPELINE_REF="$1" + fi + ;; + --platform|-p) + shift + if [ $# -gt 0 ] && [[ "$1" != --* ]]; then + CANDIDATE=$(echo "$1" | tr '[:upper:]' '[:lower:]') + for p in ${VALID_PLATFORMS}; do + if [ "${CANDIDATE}" = "${p}" ]; then + PLATFORM="${p}" + break + fi + done + fi + ;; + *) + # Check if it's a valid platform name + for p in ${VALID_PLATFORMS}; do + if [ "$(echo "$1" | tr '[:upper:]' '[:lower:]')" = "${p}" ]; then + PLATFORM="${p}" + break + fi + done + ;; + esac + shift || true + done + fi + + # Sanitize ref to valid git ref characters only + PIPELINE_REF=$(echo "${PIPELINE_REF}" | sed 's/[^a-zA-Z0-9/_.\-]//g') + # Reject path traversal, empty segments, and leading / + case "${PIPELINE_REF}" in + *..*|//*|*//*|*/|/*) PIPELINE_REF="main" ;; + esac + if [ -z "${PIPELINE_REF}" ]; then + PIPELINE_REF="main" + fi + + # Validate PR number is a positive integer + if ! [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::pr_number must be a positive integer, got: '${PR_NUMBER}'" + exit 1 + fi + + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "platform=${PLATFORM}" >> "$GITHUB_OUTPUT" + echo "pipeline_ref=${PIPELINE_REF}" >> "$GITHUB_OUTPUT" + echo "Parsed — PR: #${PR_NUMBER}, Platform: '${PLATFORM:-}', Ref: ${PIPELINE_REF}" + + - name: Validate PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.params.outputs.pr_number }} + run: | + PR_JSON=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}) + PR_STATE=$(echo "${PR_JSON}" | jq -r '.state') + if [ "${PR_STATE}" != "open" ]; then + echo "::error::PR #${PR_NUMBER} is not open (state: ${PR_STATE})" + exit 1 + fi + PR_TITLE=$(echo "${PR_JSON}" | jq -r '.title') + echo "PR #${PR_NUMBER}: ${PR_TITLE}" + echo "### Reviewing PR #${PR_NUMBER}" >> "$GITHUB_STEP_SUMMARY" + echo "${PR_TITLE}" >> "$GITHUB_STEP_SUMMARY" + + - name: Infer platform + id: infer + env: + GH_TOKEN: ${{ github.token }} + PLATFORM: ${{ steps.params.outputs.platform }} + PR_NUMBER: ${{ steps.params.outputs.pr_number }} + run: | + + # If platform was explicitly set, use it as-is + if [ -n "${PLATFORM}" ]; then + echo "Platform explicitly set to: ${PLATFORM}" + echo "platform=${PLATFORM}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "No platform specified — inferring from PR #${PR_NUMBER} labels..." + echo "(File-based detection is handled by the agentic-labeler.md workflow on PR open/reopen.)" + + # Check PR labels applied by agentic-labeler.md or manually + LABELS=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.labels[].name' 2>/dev/null || true) + LABELS_LOWER=$(echo "${LABELS}" | tr '[:upper:]' '[:lower:]') + echo "PR labels: ${LABELS_LOWER:-}" + + if echo "${LABELS_LOWER}" | grep -qE '^platform/ios$'; then + PLATFORM="ios" + elif echo "${LABELS_LOWER}" | grep -qE '^(platform/macos|platform/maccatalyst)$'; then + PLATFORM="catalyst" + elif echo "${LABELS_LOWER}" | grep -qE '^platform/android$'; then + PLATFORM="android" + elif echo "${LABELS_LOWER}" | grep -qE '^platform/windows$'; then + PLATFORM="windows" + fi + + # Default to android when labels are inconclusive + if [ -z "${PLATFORM}" ]; then + echo "No platform label found — defaulting to android. Use --platform to specify explicitly." + PLATFORM="android" + fi + + echo "Inferred platform: ${PLATFORM}" + echo "platform=${PLATFORM}" >> "$GITHUB_OUTPUT" + + - name: Get OIDC Token + id: oidc + run: | + OIDC_TOKEN=$(curl -s -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://AzureADTokenExchange" \ + | jq -r '.value') + if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then + echo "::error::Failed to get OIDC token" + exit 1 + fi + echo "::add-mask::${OIDC_TOKEN}" + echo "oidc_token=${OIDC_TOKEN}" >> "$GITHUB_OUTPUT" + + - name: Exchange for AzDO Token + id: token + env: + OIDC_TOKEN: ${{ steps.oidc.outputs.oidc_token }} + run: | + AZURE_RESPONSE=$(curl -s -X POST \ + "https://login.microsoftonline.com/${{ secrets.AZDO_TRIGGER_TENANT_ID }}/oauth2/v2.0/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=${{ secrets.AZDO_TRIGGER_CLIENT_ID }}" \ + -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ + -d "client_assertion=${OIDC_TOKEN}" \ + -d "scope=499b84ac-1321-427f-aa17-267ca6975798/.default") + + AZDO_TOKEN=$(echo "$AZURE_RESPONSE" | jq -r '.access_token') + if [ -z "$AZDO_TOKEN" ] || [ "$AZDO_TOKEN" = "null" ]; then + echo "::error::Failed to get Azure AD token" + echo "$AZURE_RESPONSE" | jq '{error, error_description, error_codes, timestamp, trace_id}' 2>/dev/null \ + || echo "(failed to parse AAD response — check job permissions)" + exit 1 + fi + echo "::add-mask::${AZDO_TOKEN}" + echo "azdo_token=${AZDO_TOKEN}" >> "$GITHUB_OUTPUT" + + - name: Trigger maui-copilot pipeline + env: + AZDO_TOKEN: ${{ steps.token.outputs.azdo_token }} + PR_NUMBER: ${{ steps.params.outputs.pr_number }} + PIPELINE_REF: ${{ steps.params.outputs.pipeline_ref }} + PLATFORM: ${{ steps.infer.outputs.platform }} + run: | + echo "Triggering maui-copilot pipeline for PR #${PR_NUMBER} (platform: ${PLATFORM}, ref: ${PIPELINE_REF})..." + + # Platform is always resolved at this point (inferred or explicit) + # Build JSON payload safely with jq to avoid injection + PAYLOAD=$(jq -n \ + --arg pr "${PR_NUMBER}" \ + --arg plat "${PLATFORM}" \ + --arg ref "refs/heads/${PIPELINE_REF}" \ + '{ + templateParameters: { PRNumber: $pr, Platform: $plat }, + resources: { repositories: { self: { refName: $ref } } } + }') + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "https://dev.azure.com/DevDiv/DevDiv/_apis/pipelines/27723/runs?api-version=7.1" \ + -H "Authorization: Bearer ${AZDO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}") + + HTTP_CODE=$(echo "${RESPONSE}" | tail -1) + RESPONSE_BODY=$(echo "${RESPONSE}" | head -n -1) + echo "HTTP Status: ${HTTP_CODE}" + + if [ "${HTTP_CODE}" -ge 200 ] && [ "${HTTP_CODE}" -lt 300 ]; then + RUN_ID=$(echo "${RESPONSE_BODY}" | jq -r '.id') + PIPELINE_NAME=$(echo "${RESPONSE_BODY}" | jq -r '.pipeline.name') + echo "Pipeline '${PIPELINE_NAME}' triggered! Run ID: ${RUN_ID}" + echo "View: https://devdiv.visualstudio.com/DevDiv/_build/results?buildId=${RUN_ID}" + else + echo "::error::Failed to trigger pipeline. HTTP ${HTTP_CODE}" + echo "${RESPONSE_BODY}" | jq . 2>/dev/null || echo "${RESPONSE_BODY}" + exit 1 + fi diff --git a/eng/Versions.props b/eng/Versions.props index db665fa0fa24..e12763542413 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -3,7 +3,7 @@ 10 0 - 70 + 80 10.0.100 ci.main ci.inflight diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml index 2f77543be29d..fa8baa15b0b4 100644 --- a/eng/pipelines/ci-copilot.yml +++ b/eng/pipelines/ci-copilot.yml @@ -73,7 +73,7 @@ stages: ${{ elseif eq(parameters.Platform, 'windows') }}: pool: ${{ parameters.windowsPool }} ${{ else }}: - pool: ${{ parameters.windowsPool }} # fallback — should not be reached; AzDO parameter validation prevents unknown values + pool: ${{ parameters.androidPool }} # fallback to android pool timeoutInMinutes: 360 steps: - checkout: self @@ -81,18 +81,30 @@ stages: persistCredentials: true # Validate Parameters + # PRNumber is received via env var to avoid compile-time shell injection. + # Anyone triggering this pipeline directly (AzDO UI/REST) could pass arbitrary + # strings — so we validate before any further use. - bash: | echo "Validating PR Number parameter..." - if [ -z "${{ parameters.PRNumber }}" ]; then + PR_NUM="${PARAM_PR_NUMBER}" + if [ -z "${PR_NUM}" ]; then echo "##vso[task.logissue type=error]PRNumber parameter is required" exit 1 fi - echo "PR Number: ${{ parameters.PRNumber }}" + if ! [[ "${PR_NUM}" =~ ^[1-9][0-9]*$ ]]; then + echo "##vso[task.logissue type=error]PRNumber must be a positive integer, got: '${PR_NUM}'" + exit 1 + fi + echo "PR Number: ${PR_NUM}" displayName: 'Validate Parameters' + env: + PARAM_PR_NUMBER: ${{ parameters.PRNumber }} - bash: | - echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}" + echo "##vso[build.updatebuildnumber]PR ${PARAM_PR_NUMBER} ${{ parameters.Platform }}" displayName: 'Set Pipeline Run Title' + env: + PARAM_PR_NUMBER: ${{ parameters.PRNumber }} # Enable KVM for Android emulator on Linux (same as ui-tests-steps.yml / device-tests-steps.yml) - ${{ if eq(parameters.Platform, 'android') }}: @@ -374,7 +386,6 @@ stages: echo "Copilot CLI installed successfully" displayName: 'Install GitHub Copilot CLI' - # Boot iOS Simulator (only for iOS platform) # UI test baseline screenshots are captured on iPhone Xs - must use same device - bash: | @@ -532,7 +543,7 @@ stages: - bash: | echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..." - echo "Reviewing PR #${{ parameters.PRNumber }}..." + echo "Reviewing PR #${PARAM_PR_NUMBER}..." # Ensure copilot CLI is accessible to pwsh subprocess. # npm global install on Linux goes to UseNode@1 toolcache path which may not @@ -586,8 +597,10 @@ stages: # Invoke the PR reviewer using our PowerShell script # The script will merge the PR into the current branch # -PostSummaryComment and -RunFinalize handle posting comments + echo "Review platform: ${{ parameters.Platform }}" + set +e - pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" + pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber "${PARAM_PR_NUMBER}" -Platform "${{ parameters.Platform }}" -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" COPILOT_EXIT_CODE=$? set -e @@ -649,6 +662,7 @@ stages: COPILOT_GITHUB_TOKEN: $(COPILOT_TOKEN) GH_TOKEN: $(GH_COMMENT_TOKEN) DEVICE_UDID: $(DEVICE_UDID) + PARAM_PR_NUMBER: ${{ parameters.PRNumber }} COMMENTS_VIA_FILE: "true" # Publish Copilot logs and session artifacts diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32871.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32871.cs new file mode 100644 index 000000000000..cb671f4c3014 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32871.cs @@ -0,0 +1,141 @@ +#if ANDROID +using Android.Views; +using AView = Android.Views.View; +#endif + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 32871, "[Android] Bottom insets issues when keyboard is shown", PlatformAffected.Android)] +public partial class Issue32871 : ContentPage +{ + public Issue32871() + { + SafeAreaEdges = SafeAreaEdges.None; + BackgroundColor = Colors.Green; + + var paddingLabel = new Label + { + Text = "waiting", + AutomationId = "PaddingLabel", + TextColor = Colors.White, + FontSize = 12 + }; + + var entry = new Entry + { + Placeholder = "Tap here to show keyboard", + AutomationId = "TestEntry", + VerticalOptions = LayoutOptions.Start, + HorizontalOptions = LayoutOptions.Fill, + HeightRequest = 56 + }; + + var grid = new Grid + { + AutomationId = "MainGrid", + SafeAreaEdges = SafeAreaEdges.Default, + BackgroundColor = Colors.Red, + RowDefinitions = + { + new RowDefinition(80), + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Star), + new RowDefinition(GridLength.Auto) + } + }; + + var label = new Label + { + Text = "Issue 32871", + AutomationId = "HeaderLabel", + HorizontalTextAlignment = Microsoft.Maui.TextAlignment.Center, + VerticalOptions = LayoutOptions.Start, + TextColor = Colors.White + }; + + var bottomButton = new Button + { + Text = "Bottom Button", + AutomationId = "BottomButton", + BackgroundColor = Colors.Blue, + TextColor = Colors.White + }; + + Grid.SetRow(label, 0); + Grid.SetRow(paddingLabel, 1); + Grid.SetRow(entry, 2); + Grid.SetRow(bottomButton, 3); + + grid.Children.Add(label); + grid.Children.Add(paddingLabel); + grid.Children.Add(entry); + grid.Children.Add(bottomButton); + + Content = grid; + + SetupPlatform(grid, paddingLabel); + } + + partial void SetupPlatform(Grid grid, Label paddingLabel); + + protected override void OnDisappearing() + { + base.OnDisappearing(); + CleanupPlatform(); + } + + partial void CleanupPlatform(); +} + +#if ANDROID +public partial class Issue32871 +{ + SoftInput _previousSoftInputMode; + + partial void SetupPlatform(Grid grid, Label paddingLabel) + { + var window = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity?.Window; + if (window?.Attributes is WindowManagerLayoutParams attr) + { + _previousSoftInputMode = attr.SoftInputMode; + } + window?.SetSoftInputMode(SoftInput.AdjustUnspecified | SoftInput.StateHidden); + + grid.HandlerChanged += (s, e) => + { + if (grid.Handler?.PlatformView is AView nativeView) + { + paddingLabel.Text = $"NativePadding: B={nativeView.PaddingBottom}"; + nativeView.AddOnLayoutChangeListener(new LayoutListener(nativeView, paddingLabel)); + } + }; + } + + partial void CleanupPlatform() + { + var window = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity?.Window; + window?.SetSoftInputMode(_previousSoftInputMode); + } + + class LayoutListener : Java.Lang.Object, AView.IOnLayoutChangeListener + { + readonly WeakReference _view; + readonly WeakReference