From e23ffddb0c0bb8399ef811d025926cdd5103b0ef Mon Sep 17 00:00:00 2001 From: Travis Plunk Date: Thu, 30 Oct 2025 14:27:19 -0700 Subject: [PATCH 1/2] Cherry-pick PR #26355 with conflicts for manual resolution --- .../get-changed-files/README.md | 122 ++++++++++++++++++ .../get-changed-files/action.yml | 117 +++++++++++++++++ .../infrastructure/markdownlinks/action.yml | 47 ++----- .../infrastructure/path-filters/action.yml | 89 +++++++++---- 4 files changed, 311 insertions(+), 64 deletions(-) create mode 100644 .github/actions/infrastructure/get-changed-files/README.md create mode 100644 .github/actions/infrastructure/get-changed-files/action.yml diff --git a/.github/actions/infrastructure/get-changed-files/README.md b/.github/actions/infrastructure/get-changed-files/README.md new file mode 100644 index 00000000000..277b28c0674 --- /dev/null +++ b/.github/actions/infrastructure/get-changed-files/README.md @@ -0,0 +1,122 @@ +# Get Changed Files Action + +A reusable composite action that retrieves the list of files changed in a pull request or push event. + +## Features + +- Supports both `pull_request` and `push` events +- Optional filtering by file pattern +- Returns files as JSON array for easy consumption +- Filters out deleted files (only returns added, modified, or renamed files) +- Handles up to 100 changed files per request + +## Usage + +### Basic Usage (Pull Requests Only) + +```yaml +- name: Get changed files + id: changed-files + uses: "./.github/actions/infrastructure/get-changed-files" + +- name: Process files + run: | + echo "Changed files: ${{ steps.changed-files.outputs.files }}" + echo "Count: ${{ steps.changed-files.outputs.count }}" +``` + +### With Filtering + +```yaml +# Get only markdown files +- name: Get changed markdown files + id: changed-md + uses: "./.github/actions/infrastructure/get-changed-files" + with: + filter: '*.md' + +# Get only GitHub workflow/action files +- name: Get changed GitHub files + id: changed-github + uses: "./.github/actions/infrastructure/get-changed-files" + with: + filter: '.github/' +``` + +### Support Both PR and Push Events + +```yaml +- name: Get changed files + id: changed-files + uses: "./.github/actions/infrastructure/get-changed-files" + with: + event-types: 'pull_request,push' +``` + +## Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `filter` | Optional filter pattern (e.g., `*.md` for markdown files, `.github/` for GitHub files) | No | `''` | +| `event-types` | Comma-separated list of event types to support (`pull_request`, `push`) | No | `pull_request` | + +## Outputs + +| Name | Description | +|------|-------------| +| `files` | JSON array of changed file paths | +| `count` | Number of changed files | + +## Filter Patterns + +The action supports simple filter patterns: + +- **Extension matching**: Use `*.ext` to match files with a specific extension + - Example: `*.md` matches all markdown files + - Example: `*.yml` matches all YAML files + +- **Path prefix matching**: Use a path prefix to match files in a directory + - Example: `.github/` matches all files in the `.github` directory + - Example: `tools/` matches all files in the `tools` directory + +## Example: Processing Changed Files + +```yaml +- name: Get changed files + id: changed-files + uses: "./.github/actions/infrastructure/get-changed-files" + +- name: Process each file + shell: pwsh + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + run: | + $changedFilesJson = $env:CHANGED_FILES + $changedFiles = $changedFilesJson | ConvertFrom-Json + + foreach ($file in $changedFiles) { + Write-Host "Processing: $file" + # Your processing logic here + } +``` + +## Limitations + +- Simple filter patterns only (no complex glob or regex patterns) + +## Pagination + +The action automatically handles pagination to fetch **all** changed files in a PR, regardless of how many files were changed: + +- Fetches files in batches of 100 per page +- Continues fetching until all files are retrieved +- Logs a note when pagination occurs, showing the total file count +- **No file limit** - all changed files will be processed, even in very large PRs + +This ensures that critical workflows (such as merge conflict checking, link validation, etc.) don't miss files due to pagination limits. + +## Related Actions + +- **markdownlinks**: Uses this pattern to get changed markdown files +- **merge-conflict-checker**: Uses this pattern to get changed files for conflict detection +- **path-filters**: Similar functionality but with more complex filtering logic diff --git a/.github/actions/infrastructure/get-changed-files/action.yml b/.github/actions/infrastructure/get-changed-files/action.yml new file mode 100644 index 00000000000..c897d4f388d --- /dev/null +++ b/.github/actions/infrastructure/get-changed-files/action.yml @@ -0,0 +1,117 @@ +name: 'Get Changed Files' +description: 'Gets the list of files changed in a pull request or push event' +inputs: + filter: + description: 'Optional filter pattern (e.g., "*.md" for markdown files, ".github/" for GitHub files)' + required: false + default: '' + event-types: + description: 'Comma-separated list of event types to support (pull_request, push)' + required: false + default: 'pull_request' +outputs: + files: + description: 'JSON array of changed file paths' + value: ${{ steps.get-files.outputs.files }} + count: + description: 'Number of changed files' + value: ${{ steps.get-files.outputs.count }} +runs: + using: 'composite' + steps: + - name: Get changed files + id: get-files + uses: actions/github-script@v7 + with: + script: | + const eventTypes = '${{ inputs.event-types }}'.split(',').map(t => t.trim()); + const filter = '${{ inputs.filter }}'; + let changedFiles = []; + + if (eventTypes.includes('pull_request') && context.eventName === 'pull_request') { + console.log(`Getting files changed in PR #${context.payload.pull_request.number}`); + + // Fetch all files changed in the PR with pagination + let allFiles = []; + let page = 1; + let fetchedCount; + + do { + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + page: page + }); + + allFiles = allFiles.concat(files); + fetchedCount = files.length; + page++; + } while (fetchedCount === 100); + + if (allFiles.length >= 100) { + console.log(`Note: This PR has ${allFiles.length} changed files. All files fetched using pagination.`); + } + + changedFiles = allFiles + .filter(file => file.status === 'added' || file.status === 'modified' || file.status === 'renamed') + .map(file => file.filename); + + } else if (eventTypes.includes('push') && context.eventName === 'push') { + console.log(`Getting files changed in push to ${context.ref}`); + + const { data: comparison } = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: context.payload.before, + head: context.payload.after, + }); + + changedFiles = comparison.files + .filter(file => file.status === 'added' || file.status === 'modified' || file.status === 'renamed') + .map(file => file.filename); + + } else { + core.setFailed(`Unsupported event type: ${context.eventName}. Supported types: ${eventTypes.join(', ')}`); + return; + } + + // Apply filter if provided + if (filter) { + const filterLower = filter.toLowerCase(); + const beforeFilter = changedFiles.length; + changedFiles = changedFiles.filter(file => { + const fileLower = file.toLowerCase(); + // Support simple patterns like "*.md" or ".github/" + if (filterLower.startsWith('*.')) { + const ext = filterLower.substring(1); + return fileLower.endsWith(ext); + } else { + return fileLower.startsWith(filterLower); + } + }); + console.log(`Filter '${filter}' applied: ${beforeFilter} → ${changedFiles.length} files`); + } + + // Calculate simple hash for verification + const crypto = require('crypto'); + const filesJson = JSON.stringify(changedFiles.sort()); + const hash = crypto.createHash('sha256').update(filesJson).digest('hex').substring(0, 8); + + // Log changed files in a collapsible group + core.startGroup(`Changed Files (${changedFiles.length} total, hash: ${hash})`); + if (changedFiles.length > 0) { + changedFiles.forEach(file => console.log(` - ${file}`)); + } else { + console.log(' (no files changed)'); + } + core.endGroup(); + + console.log(`Found ${changedFiles.length} changed files`); + core.setOutput('files', JSON.stringify(changedFiles)); + core.setOutput('count', changedFiles.length); + +branding: + icon: 'file-text' + color: 'blue' diff --git a/.github/actions/infrastructure/markdownlinks/action.yml b/.github/actions/infrastructure/markdownlinks/action.yml index 1d6d0864784..de2952252d4 100644 --- a/.github/actions/infrastructure/markdownlinks/action.yml +++ b/.github/actions/infrastructure/markdownlinks/action.yml @@ -31,52 +31,23 @@ runs: steps: - name: Get changed markdown files id: changed-files - uses: actions/github-script@v7 + uses: "./.github/actions/infrastructure/get-changed-files" with: - script: | - let changedMarkdownFiles = []; - - if (context.eventName === 'pull_request') { - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - - changedMarkdownFiles = files - .filter(file => file.filename.endsWith('.md')) - .map(file => file.filename); - } else if (context.eventName === 'push') { - const { data: comparison } = await github.rest.repos.compareCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - base: context.payload.before, - head: context.payload.after, - }); - - changedMarkdownFiles = comparison.files - .filter(file => file.filename.endsWith('.md')) - .map(file => file.filename); - } else { - core.setFailed(`Unsupported event type: ${context.eventName}. This action only supports 'pull_request' and 'push' events.`); - return; - } - - console.log('Changed markdown files:', changedMarkdownFiles); - core.setOutput('files', JSON.stringify(changedMarkdownFiles)); - core.setOutput('count', changedMarkdownFiles.length); - return changedMarkdownFiles; + filter: '*.md' + event-types: 'pull_request,push' - name: Verify markdown links id: verify shell: pwsh + env: + CHANGED_FILES_JSON: ${{ steps.changed-files.outputs.files }} run: | Write-Host "Starting markdown link verification..." -ForegroundColor Cyan - # Get changed markdown files from previous step - $changedFilesJson = '${{ steps.changed-files.outputs.files }}' + # Get changed markdown files from environment variable (secure against injection) + $changedFilesJson = $env:CHANGED_FILES_JSON $changedFiles = $changedFilesJson | ConvertFrom-Json - + if ($changedFiles.Count -eq 0) { Write-Host "No markdown files changed, skipping verification" -ForegroundColor Yellow "total=0" >> $env:GITHUB_OUTPUT @@ -85,7 +56,7 @@ runs: "skipped=0" >> $env:GITHUB_OUTPUT exit 0 } - + Write-Host "Changed markdown files: $($changedFiles.Count)" -ForegroundColor Cyan $changedFiles | ForEach-Object { Write-Host " - $_" -ForegroundColor Gray } diff --git a/.github/actions/infrastructure/path-filters/action.yml b/.github/actions/infrastructure/path-filters/action.yml index 61ffa00a442..6279d170544 100644 --- a/.github/actions/infrastructure/path-filters/action.yml +++ b/.github/actions/infrastructure/path-filters/action.yml @@ -32,9 +32,16 @@ outputs: runs: using: composite steps: + - name: Get changed files + id: get-files + if: github.event_name == 'pull_request' + uses: "./.github/actions/infrastructure/get-changed-files" + - name: Check if GitHubWorkflowChanges is present id: filter uses: actions/github-script@v7.0.1 + env: + FILES_JSON: ${{ steps.get-files.outputs.files }} with: github-token: ${{ inputs.GITHUB_TOKEN }} script: | @@ -53,41 +60,52 @@ runs: return; } - console.log(`Getting files changed in PR #${context.issue.number}`); - - // Fetch the list of files changed in the PR - let files = []; - let page = 1; - let fetchedFiles; - do { - fetchedFiles = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 100, - page: page++ - }); - files = files.concat(fetchedFiles.data); - } while (fetchedFiles.data.length > 0); - - const actionsChanged = files.some(file => file.filename.startsWith('.github/actions')); - const workflowsChanged = files.some(file => file.filename.startsWith('.github/workflows')); + // Get files from environment variable (secure against injection) + const files = JSON.parse(process.env.FILES_JSON || '[]'); + + // Calculate hash for verification (matches get-changed-files action) + const crypto = require('crypto'); + const filesJson = JSON.stringify(files.sort()); + const hash = crypto.createHash('sha256').update(filesJson).digest('hex').substring(0, 8); + console.log(`Received ${files.length} files (hash: ${hash})`); + + // Analyze changes with detailed logging + core.startGroup('Path Filter Analysis'); + + const actionsChanged = files.some(file => file.startsWith('.github/actions')); + console.log(`✓ Actions changed: ${actionsChanged}`); + + const workflowsChanged = files.some(file => file.startsWith('.github/workflows')); + console.log(`✓ Workflows changed: ${workflowsChanged}`); + const githubChanged = actionsChanged || workflowsChanged; + console.log(`→ GitHub changed (actions OR workflows): ${githubChanged}`); + + const toolsCiPsm1Changed = files.some(file => file === 'tools/ci.psm1'); + console.log(`✓ tools/ci.psm1 changed: ${toolsCiPsm1Changed}`); + + const toolsBuildCommonChanged = files.some(file => file.startsWith('tools/buildCommon/')); + console.log(`✓ tools/buildCommon/ changed: ${toolsBuildCommonChanged}`); - const toolsCiPsm1Changed = files.some(file => file.filename.startsWith('tools/ci.psm1')); - const toolsBuildCommonChanged = files.some(file => file.filename.startsWith('tools/buildCommon/')); const toolsChanged = toolsCiPsm1Changed || toolsBuildCommonChanged; + console.log(`→ Tools changed: ${toolsChanged}`); - const propsChanged = files.some(file => file.filename.endsWith('.props')); + const propsChanged = files.some(file => file.endsWith('.props')); + console.log(`✓ Props files changed: ${propsChanged}`); - const testsChanged = files.some(file => file.filename.startsWith('test/powershell/') || file.filename.startsWith('test/tools/') || file.filename.startsWith('test/xUnit/')); + const testsChanged = files.some(file => file.startsWith('test/powershell/') || file.startsWith('test/tools/') || file.startsWith('test/xUnit/')); + console.log(`✓ Tests changed: ${testsChanged}`); - const mainSourceChanged = files.some(file => file.filename.startsWith('src/')); + const mainSourceChanged = files.some(file => file.startsWith('src/')); + console.log(`✓ Main source (src/) changed: ${mainSourceChanged}`); - const buildModuleChanged = files.some(file => file.filename.startsWith('build.psm1')); + const buildModuleChanged = files.some(file => file === 'build.psm1'); + console.log(`✓ build.psm1 changed: ${buildModuleChanged}`); - const globalConfigChanged = files.some(file => file.filename.startsWith('.globalconfig')) || files.some(file => file.filename.startsWith('nuget.config')) || files.some(file => file.filename.startsWith('global.json')); + const globalConfigChanged = files.some(file => file === '.globalconfig' || file === 'nuget.config' || file === 'global.json'); + console.log(`✓ Global config changed: ${globalConfigChanged}`); +<<<<<<< HEAD const packagingChanged = files.some(file => file.filename === '.github/workflows/windows-ci.yml' || file.filename.startsWith('assets/wix/') || @@ -96,12 +114,28 @@ runs: file.filename.startsWith('test/packaging/windows/') || file.filename.startsWith('tools/packaging/') || file.filename.startsWith('tools/wix/') +======= + const packagingChanged = files.some(file => + file === '.github/workflows/windows-ci.yml' || + file === '.github/workflows/linux-ci.yml' || + file.startsWith('assets/wix/') || + file === 'PowerShell.Common.props' || + file.match(/^src\/.*\.csproj$/) || + file.startsWith('test/packaging/windows/') || + file.startsWith('test/packaging/linux/') || + file.startsWith('tools/packaging/') || + file.startsWith('tools/wix/') +>>>>>>> a93688463 (Add reusable get-changed-files action and refactor existing actions (#26355)) ) || buildModuleChanged || globalConfigChanged || toolsCiPsm1Changed; + console.log(`→ Packaging changed: ${packagingChanged}`); const source = mainSourceChanged || toolsChanged || githubChanged || propsChanged || testsChanged || globalConfigChanged; + console.log(`→ Source (composite): ${source}`); + + core.endGroup(); core.setOutput('toolsChanged', toolsChanged); core.setOutput('githubChanged', githubChanged); @@ -112,6 +146,7 @@ runs: core.setOutput('globalConfigChanged', globalConfigChanged); core.setOutput('packagingChanged', packagingChanged); core.setOutput('source', source); +<<<<<<< HEAD - name: Capture outputs run: | @@ -124,3 +159,5 @@ runs: Write-Verbose -Verbose "buildModule: ${{ steps.filter.outputs.buildModuleChanged }}" Write-Verbose -Verbose "packaging: ${{ steps.filter.outputs.packagingChanged }}" shell: pwsh +======= +>>>>>>> a93688463 (Add reusable get-changed-files action and refactor existing actions (#26355)) From f14c101b18aecb7db6ac412d06dc98fd76f25e7f Mon Sep 17 00:00:00 2001 From: Travis Plunk Date: Sun, 2 Nov 2025 15:27:15 -0800 Subject: [PATCH 2/2] merge - Refactor packaging change detection in action.yml --- .../infrastructure/path-filters/action.yml | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/.github/actions/infrastructure/path-filters/action.yml b/.github/actions/infrastructure/path-filters/action.yml index 6279d170544..656719262b2 100644 --- a/.github/actions/infrastructure/path-filters/action.yml +++ b/.github/actions/infrastructure/path-filters/action.yml @@ -105,16 +105,6 @@ runs: const globalConfigChanged = files.some(file => file === '.globalconfig' || file === 'nuget.config' || file === 'global.json'); console.log(`✓ Global config changed: ${globalConfigChanged}`); -<<<<<<< HEAD - const packagingChanged = files.some(file => - file.filename === '.github/workflows/windows-ci.yml' || - file.filename.startsWith('assets/wix/') || - file.filename === 'PowerShell.Common.props' || - file.filename.match(/^src\/.*\.csproj$/) || - file.filename.startsWith('test/packaging/windows/') || - file.filename.startsWith('tools/packaging/') || - file.filename.startsWith('tools/wix/') -======= const packagingChanged = files.some(file => file === '.github/workflows/windows-ci.yml' || file === '.github/workflows/linux-ci.yml' || @@ -125,7 +115,6 @@ runs: file.startsWith('test/packaging/linux/') || file.startsWith('tools/packaging/') || file.startsWith('tools/wix/') ->>>>>>> a93688463 (Add reusable get-changed-files action and refactor existing actions (#26355)) ) || buildModuleChanged || globalConfigChanged || @@ -146,18 +135,3 @@ runs: core.setOutput('globalConfigChanged', globalConfigChanged); core.setOutput('packagingChanged', packagingChanged); core.setOutput('source', source); -<<<<<<< HEAD - - - name: Capture outputs - run: | - Write-Verbose -Verbose "source: ${{ steps.filter.outputs.source }}" - Write-Verbose -Verbose "github: ${{ steps.filter.outputs.githubChanged }}" - Write-Verbose -Verbose "tools: ${{ steps.filter.outputs.toolsChanged }}" - Write-Verbose -Verbose "props: ${{ steps.filter.outputs.propsChanged }}" - Write-Verbose -Verbose "tests: ${{ steps.filter.outputs.testsChanged }}" - Write-Verbose -Verbose "mainSource: ${{ steps.filter.outputs.mainSourceChanged }}" - Write-Verbose -Verbose "buildModule: ${{ steps.filter.outputs.buildModuleChanged }}" - Write-Verbose -Verbose "packaging: ${{ steps.filter.outputs.packagingChanged }}" - shell: pwsh -======= ->>>>>>> a93688463 (Add reusable get-changed-files action and refactor existing actions (#26355))