mirror of
https://github.com/PowerShell/PowerShell
synced 2026-06-08 12:12:50 +00:00
Add reusable get-changed-files action and refactor existing actions (#26355)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,57 +60,71 @@ runs:
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Getting files changed in PR #${context.issue.number}`);
|
||||
// Get files from environment variable (secure against injection)
|
||||
const files = JSON.parse(process.env.FILES_JSON || '[]');
|
||||
|
||||
// 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);
|
||||
// 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 actionsChanged = files.some(file => file.filename.startsWith('.github/actions'));
|
||||
const workflowsChanged = files.some(file => file.filename.startsWith('.github/workflows'));
|
||||
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}`);
|
||||
|
||||
const packagingChanged = files.some(file =>
|
||||
file.filename === '.github/workflows/windows-ci.yml' ||
|
||||
file.filename === '.github/workflows/linux-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('test/packaging/linux/') ||
|
||||
file.filename.startsWith('tools/packaging/') ||
|
||||
file.filename.startsWith('tools/wix/')
|
||||
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/')
|
||||
) ||
|
||||
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);
|
||||
@@ -114,16 +135,3 @@ runs:
|
||||
core.setOutput('globalConfigChanged', globalConfigChanged);
|
||||
core.setOutput('packagingChanged', packagingChanged);
|
||||
core.setOutput('source', source);
|
||||
|
||||
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user