From 4686ad203c1244fb897287ff65323725c0d59c05 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 8 Jul 2026 13:28:58 +0200 Subject: [PATCH 1/3] [ci] Add /apply-gist workflow for updating expected app size files When the AppSizeTest fails in CI: 1. The Azure DevOps pipeline creates a GitHub gist with the updated expected files and posts a PR comment with the gist URL. 2. A maintainer can comment '/apply-gist ' on the PR to trigger a GitHub Actions workflow that downloads the gist files and commits them to the PR branch. Security: - Only comments from users with write access are honored - Only gists from approved owners are accepted (currently vs-mobiletools-engineering-service2) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/apply-gist.yml | 190 ++++++++++++++++++ .../automation/templates/tests/run-tests.yml | 54 +++++ 2 files changed, 244 insertions(+) create mode 100644 .github/workflows/apply-gist.yml diff --git a/.github/workflows/apply-gist.yml b/.github/workflows/apply-gist.yml new file mode 100644 index 000000000000..37b17b701b20 --- /dev/null +++ b/.github/workflows/apply-gist.yml @@ -0,0 +1,190 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# This workflow applies updated expected app size files from a GitHub gist +# when a maintainer comments '/apply-gist ' on a pull request. +# +# Security: +# - Only honors comments from users with write access to the repository +# - Only accepts gists from approved owners (see APPROVED_GIST_OWNERS below) + +name: Apply Gist + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + apply-gist: + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/apply-gist ') + runs-on: ubuntu-latest + steps: + - name: Check commenter permissions + id: check-permissions + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const level = permission.permission; + if (level !== 'admin' && level !== 'write' && level !== 'maintain') { + core.setFailed(`User '${context.payload.comment.user.login}' does not have write access (has '${level}'). Ignoring.`); + return; + } + core.info(`User '${context.payload.comment.user.login}' has '${level}' access. Proceeding.`); + + - name: React to comment + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket', + }); + + - name: Parse gist URL + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + GIST_URL=$(echo "$COMMENT_BODY" | grep -oP '(?<=/apply-gist\s)https://gist\.github\.com/\S+') + if [ -z "$GIST_URL" ]; then + echo "::error::Could not parse a valid gist URL from the comment." + exit 1 + fi + # Extract gist ID (last path component, strip any trailing slash) + GIST_ID=$(echo "$GIST_URL" | sed 's|/$||' | awk -F/ '{print $NF}') + if [ -z "$GIST_ID" ]; then + echo "::error::Could not extract gist ID from URL: $GIST_URL" + exit 1 + fi + echo "gist_url=$GIST_URL" >> "$GITHUB_OUTPUT" + echo "gist_id=$GIST_ID" >> "$GITHUB_OUTPUT" + + - name: Validate gist owner + id: validate-owner + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + GIST_ID: ${{ steps.parse.outputs.gist_id }} + with: + script: | + // Approved gist owners. Add more entries here as needed. + const approvedOwners = [ + 'vs-mobiletools-engineering-service2', + ]; + + const { data: gist } = await github.rest.gists.get({ + gist_id: process.env.GIST_ID, + }); + const owner = gist.owner.login; + if (!approvedOwners.includes(owner)) { + core.setFailed(`Gist owner '${owner}' is not in the approved list: [${approvedOwners.join(', ')}]`); + return; + } + core.info(`Gist owner '${owner}' is approved.`); + + - name: Get PR branch + id: pr-branch + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + core.setOutput('ref', pr.head.ref); + core.setOutput('repo_full_name', pr.head.repo.full_name); + + - name: Checkout PR branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ steps.pr-branch.outputs.ref }} + repository: ${{ steps.pr-branch.outputs.repo_full_name }} + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true + + - name: Download and apply gist files + env: + GIST_ID: ${{ steps.parse.outputs.gist_id }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXPECTED_DIR="tests/dotnet/UnitTests/expected" + mkdir -p "$EXPECTED_DIR" + + # Download gist metadata to get file URLs + GIST_JSON=$(gh api "gists/$GIST_ID") + echo "$GIST_JSON" | python3 -c " + import json, sys, urllib.request, os + + gist = json.load(sys.stdin) + expected_dir = os.environ['EXPECTED_DIR'] + files = gist['files'] + for filename, file_info in files.items(): + # Only apply .txt files that match expected file patterns + if not filename.endswith('.txt'): + print(f'Skipping non-txt file: {filename}') + continue + content = file_info.get('content') + if content is None: + # Large files need to be fetched from raw_url + raw_url = file_info['raw_url'] + content = urllib.request.urlopen(raw_url).read().decode('utf-8') + dest = os.path.join(expected_dir, filename) + with open(dest, 'w') as f: + f.write(content) + print(f'Applied: {filename}') + " + + - name: Commit and push + env: + GIST_URL: ${{ steps.parse.outputs.gist_url }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add tests/dotnet/UnitTests/expected/ + if git diff --cached --quiet; then + echo "No changes to commit." + exit 0 + fi + git commit -m "[tests] Update expected app size files + + Applied from gist: $GIST_URL" + git push + + - name: Post success comment + if: success() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + GIST_URL: ${{ steps.parse.outputs.gist_url }} + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `✅ Applied expected app size files from [gist](${process.env.GIST_URL}).`, + }); + + - name: Post failure comment + if: failure() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ Failed to apply gist. Check the [workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.`, + }); diff --git a/tools/devops/automation/templates/tests/run-tests.yml b/tools/devops/automation/templates/tests/run-tests.yml index d3f0db5236ad..91d7199e7e6c 100644 --- a/tools/devops/automation/templates/tests/run-tests.yml +++ b/tools/devops/automation/templates/tests/run-tests.yml @@ -190,6 +190,60 @@ steps: continueOnError: true condition: and(succeededOrFailed(), eq(variables['HAS_UPDATED_EXPECTED_SIZES'], 'true')) +# Create a GitHub gist with the updated expected files and post a PR comment so that +# a maintainer can apply the update by commenting '/apply-gist '. +- bash: | + UPDATED_DIR="$BUILD_ARTIFACTSTAGINGDIRECTORY/updated-expected-sizes" + FILES=$(find "$UPDATED_DIR" -name '*.txt' -type f) + if [ -z "$FILES" ]; then + echo "No updated expected files found." + exit 0 + fi + + # Create gist with all updated files + # shellcheck disable=SC2086 + GIST_URL=$(gh gist create --public -d "Updated expected app size files for PR #$PR_NUMBER (${{ parameters.testPrefix }})" $FILES 2>&1) + if [ $? -ne 0 ]; then + echo "##[warning]Failed to create gist: $GIST_URL" + exit 0 + fi + echo "Created gist: $GIST_URL" + + # Build the file list for the comment + FILE_LIST="" + for f in $FILES; do + FNAME="${f##*/}" + FILE_LIST="$FILE_LIST + - \`$FNAME\`" + done + + # Post a comment on the PR + COMMENT_BODY="### ⚠️ AppSizeTest expected files changed (${{ parameters.testPrefix }}) + + The AppSizeTest detected changes in the expected app size files. + + To update the expected files, add a comment with the following command: + + \`\`\` + /apply-gist $GIST_URL + \`\`\` + +
+ Updated files + $FILE_LIST + +
" + + gh pr comment "$PR_NUMBER" --repo "$BUILD_REPOSITORY_NAME" --body "$COMMENT_BODY" + displayName: 'Create gist and comment on PR with updated expected app size files' + continueOnError: true + condition: and(succeededOrFailed(), eq(variables['HAS_UPDATED_EXPECTED_SIZES'], 'true'), eq('${{ parameters.isPR }}', 'True')) + env: + GH_TOKEN: $(GitHub.Token) + BUILD_ARTIFACTSTAGINGDIRECTORY: $(Build.ArtifactStagingDirectory) + PR_NUMBER: $(System.PullRequest.PullRequestNumber) + BUILD_REPOSITORY_NAME: $(Build.Repository.Name) + - task: PublishPipelineArtifact@1 displayName: 'Publish Artifact: Updated app bundles' inputs: From d464ded6232e64cd9193af69cd2075de48c603a3 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 9 Jul 2026 09:12:39 +0200 Subject: [PATCH 2/3] [ci] Address review feedback on apply-gist workflow - Add issues: write permission (needed for reactions/comments) - Handle null gist owner gracefully - Reject fork PRs early with a clear message - Export EXPECTED_DIR so the Python subprocess can access it - Validate filenames with a strict regex pattern to prevent path traversal (only allow known patterns like *-size.txt and *-preservedapis.txt) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/apply-gist.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/apply-gist.yml b/.github/workflows/apply-gist.yml index 37b17b701b20..cff16aebfe08 100644 --- a/.github/workflows/apply-gist.yml +++ b/.github/workflows/apply-gist.yml @@ -17,6 +17,7 @@ on: permissions: contents: write pull-requests: write + issues: write jobs: apply-gist: @@ -87,6 +88,10 @@ jobs: const { data: gist } = await github.rest.gists.get({ gist_id: process.env.GIST_ID, }); + if (!gist.owner) { + core.setFailed('Gist has no owner (anonymous or deleted). Cannot verify ownership.'); + return; + } const owner = gist.owner.login; if (!approvedOwners.includes(owner)) { core.setFailed(`Gist owner '${owner}' is not in the approved list: [${approvedOwners.join(', ')}]`); @@ -104,6 +109,10 @@ jobs: repo: context.repo.repo, pull_number: context.issue.number, }); + if (pr.head.repo.full_name !== pr.base.repo.full_name) { + core.setFailed(`Cannot apply gist to fork PRs (head repo: ${pr.head.repo.full_name}). Push the updated files manually.`); + return; + } core.setOutput('ref', pr.head.ref); core.setOutput('repo_full_name', pr.head.repo.full_name); @@ -122,19 +131,21 @@ jobs: run: | EXPECTED_DIR="tests/dotnet/UnitTests/expected" mkdir -p "$EXPECTED_DIR" + export EXPECTED_DIR # Download gist metadata to get file URLs GIST_JSON=$(gh api "gists/$GIST_ID") echo "$GIST_JSON" | python3 -c " - import json, sys, urllib.request, os + import json, sys, urllib.request, os, re gist = json.load(sys.stdin) expected_dir = os.environ['EXPECTED_DIR'] files = gist['files'] + # Only allow known expected-file patterns + allowed_pattern = re.compile(r'^[A-Za-z0-9]+-[A-Za-z0-9-]+-(size|preservedapis)\.txt$') for filename, file_info in files.items(): - # Only apply .txt files that match expected file patterns - if not filename.endswith('.txt'): - print(f'Skipping non-txt file: {filename}') + if not allowed_pattern.match(filename): + print(f'Skipping file with unexpected name: {filename}') continue content = file_info.get('content') if content is None: From f8ab245eae573a3bd6737eaa623b6096d8540059 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 9 Jul 2026 10:41:46 +0200 Subject: [PATCH 3/3] [ci] Use private (secret) gist instead of public Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tools/devops/automation/templates/tests/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/devops/automation/templates/tests/run-tests.yml b/tools/devops/automation/templates/tests/run-tests.yml index 91d7199e7e6c..c76bbb3dc49a 100644 --- a/tools/devops/automation/templates/tests/run-tests.yml +++ b/tools/devops/automation/templates/tests/run-tests.yml @@ -202,7 +202,7 @@ steps: # Create gist with all updated files # shellcheck disable=SC2086 - GIST_URL=$(gh gist create --public -d "Updated expected app size files for PR #$PR_NUMBER (${{ parameters.testPrefix }})" $FILES 2>&1) + GIST_URL=$(gh gist create -d "Updated expected app size files for PR #$PR_NUMBER (${{ parameters.testPrefix }})" $FILES 2>&1) if [ $? -ne 0 ]; then echo "##[warning]Failed to create gist: $GIST_URL" exit 0