Improve change detection for feature branches (#16)

* Detect changes against configured base branch

* Update README and action.yml

* Add job.outputs example

* Update CHANGELOG
This commit is contained in:
Michal Dorner 2020-06-24 21:53:31 +02:00 committed by GitHub
parent 7d201829e2
commit 83deb9f037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 202 additions and 35 deletions

View File

@ -1,5 +1,8 @@
# Changelog
## v2.2.0
- [Improve change detection for feature branches](https://github.com/dorny/paths-filter/pull/16)
## v2.1.0
- [Support reusable paths blocks with yaml anchors](https://github.com/dorny/paths-filter/pull/13)

View File

@ -10,9 +10,12 @@ It saves time and resources especially in monorepo setups, where you can run slo
Github workflows built-in [path filters](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths)
doesn't allow this because they doesn't work on a level of individual jobs or steps.
Action supports workflows triggered by:
- **[pull_request](https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request)**: changes are detected against the base branch
- **[push](https://help.github.com/en/actions/reference/events-that-trigger-workflows#push-event-push)**: changes are detected against the most recent commit on the same branch before the push
Supported workflows:
- Action triggered by **[pull_request](https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request)** event:
- changes detected against the pull request base branch
- Action triggered by **[push](https://help.github.com/en/actions/reference/events-that-trigger-workflows#push-event-push)** event:
- changes detected against the most recent commit on the same branch before the push
- changes detected against the top of the configured *base* branch (e.g. master)
## Usage
@ -22,7 +25,10 @@ Corresponding output variable will be created to indicate if there's a changed f
Output variables can be later used in the `if` clause to conditionally run specific steps.
### Inputs
- **`token`**: GitHub Access Token - defaults to `${{ github.token }}`
- **`token`**: GitHub Access Token - defaults to `${{ github.token }}` so you don't have to explicitly provide it.
- **`base`**: Git reference (e.g. branch name) against which the changes will be detected. Defaults to repository default branch (e.g. master).
If it references same branch it was pushed to, changes are detected against the most recent commit before the push.
This option is ignored if action is triggered by *pull_request* event.
- **`filters`**: Path to the configuration file or directly embedded string in YAML format. Filter configuration is a dictionary, where keys specifies rule names and values are lists of file path patterns.
### Outputs
@ -34,6 +40,8 @@ Output variables can be later used in the `if` clause to conditionally run speci
- minimatch [dot](https://www.npmjs.com/package/minimatch#dot) option is set to true - therefore
globbing will match also paths where file or folder name starts with a dot.
- You can use YAML anchors to reuse path expression(s) inside another rule. See example in the tests.
- If changes are detected against the previous commit and there is none (i.e. first push of a new branch), all filter rules will report changed files.
- You can use `base: ${{ github.ref }}` to configure change detection against previous commit for every branch you create.
### Example
```yaml
@ -49,7 +57,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: dorny/paths-filter@v2.1.0
- uses: dorny/paths-filter@v2.2.0
id: filter
with:
# inline YAML or path to separate file (e.g.: .github/filters.yaml)
@ -75,13 +83,47 @@ jobs:
run: ...
```
If your workflow uses multiple jobs, you can put *paths-filter* into own job and use
[job outputs](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjobs_idoutputs)
in other jobs [if](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idif) statements:
```yml
on:
pull_request:
branches:
- master
jobs:
changes:
runs-on: ubuntu-latest
# Set job outputs to values from filter step
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2.2.0
id: filter
with:
# Filters stored in own yaml file
filters: '.github/filters.yml'
backend:
if: ${{ needs.changes.outputs.backend == 'true' }}
steps:
- ...
frontend:
if: ${{ needs.changes.outputs.frontend == 'true' }}
steps:
- ...
```
## How it works
1. If action was triggered by pull request:
- If access token was provided it's used to fetch list of changed files from Github API.
- If access token was not provided, top of the base branch is fetched and changed files are detected using `git diff-index` command.
- If access token was not provided, top of the base branch is fetched and changed files are detected using `git diff-index <SHA>` command.
2. If action was triggered by push event
- Last commit before the push is fetched and changed files are detected using `git diff-index` command.
- if *base* input parameter references same branch it was pushed to, most recent commit before the push is fetched
- If *base* input parameter references other branch, top of that branch is fetched
- changed files are detected using `git diff-index FETCH_HEAD` command.
3. For each filter rule it checks if there is any matching file
4. Output variables are set

19
__tests__/git.test.ts Normal file
View File

@ -0,0 +1,19 @@
import * as git from '../src/git'
describe('git utility function tests (those not invoking git)', () => {
test('Detects if ref references a tag', () => {
expect(git.isTagRef('refs/tags/v1.0')).toBeTruthy()
expect(git.isTagRef('refs/heads/master')).toBeFalsy()
expect(git.isTagRef('master')).toBeFalsy()
})
test('Trims "refs/" from ref', () => {
expect(git.trimRefs('refs/heads/master')).toBe('heads/master')
expect(git.trimRefs('heads/master')).toBe('heads/master')
expect(git.trimRefs('master')).toBe('master')
})
test('Trims "refs/" and "heads/" from ref', () => {
expect(git.trimRefsHeads('refs/heads/master')).toBe('master')
expect(git.trimRefsHeads('heads/master')).toBe('master')
expect(git.trimRefsHeads('master')).toBe('master')
})
})

View File

@ -6,6 +6,12 @@ inputs:
description: 'GitHub Access Token'
required: false
default: ${{ github.token }}
base:
description: |
Git reference (e.g. branch name) against which the changes will be detected. Defaults to repository default branch (e.g. master).
If it references same branch it was pushed to, changes are detected against the most recent commit before the push.
This option is ignored if action is triggered by pull_request event.
required: false
filters:
description: 'Path to the configuration file or YAML string with filters definition'
required: true

75
dist/index.js vendored
View File

@ -3798,21 +3798,23 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getChangedFiles = exports.fetchCommit = void 0;
exports.trimRefsHeads = exports.trimRefs = exports.isTagRef = exports.getChangedFiles = exports.fetchCommit = exports.FETCH_HEAD = exports.NULL_SHA = void 0;
const exec_1 = __webpack_require__(986);
function fetchCommit(sha) {
exports.NULL_SHA = '0000000000000000000000000000000000000000';
exports.FETCH_HEAD = 'FETCH_HEAD';
function fetchCommit(ref) {
return __awaiter(this, void 0, void 0, function* () {
const exitCode = yield exec_1.exec('git', ['fetch', '--depth=1', 'origin', sha]);
const exitCode = yield exec_1.exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref]);
if (exitCode !== 0) {
throw new Error(`Fetching commit ${sha} failed`);
throw new Error(`Fetching ${ref} failed`);
}
});
}
exports.fetchCommit = fetchCommit;
function getChangedFiles(sha) {
function getChangedFiles(ref) {
return __awaiter(this, void 0, void 0, function* () {
let output = '';
const exitCode = yield exec_1.exec('git', ['diff-index', '--name-only', sha], {
const exitCode = yield exec_1.exec('git', ['diff-index', '--name-only', ref], {
listeners: {
stdout: (data) => (output += data.toString())
}
@ -3827,6 +3829,22 @@ function getChangedFiles(sha) {
});
}
exports.getChangedFiles = getChangedFiles;
function isTagRef(ref) {
return ref.startsWith('refs/tags/');
}
exports.isTagRef = isTagRef;
function trimRefs(ref) {
return trimStart(ref, 'refs/');
}
exports.trimRefs = trimRefs;
function trimRefsHeads(ref) {
const trimRef = trimStart(ref, 'refs/');
return trimStart(trimRef, 'heads/');
}
exports.trimRefsHeads = trimRefsHeads;
function trimStart(ref, start) {
return ref.startsWith(start) ? ref.substr(start.length) : ref;
}
/***/ }),
@ -4487,9 +4505,18 @@ function run() {
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput;
const filter = new filter_1.default(filtersYaml);
const files = yield getChangedFiles(token);
const result = filter.match(files);
for (const key in result) {
core.setOutput(key, String(result[key]));
if (files === null) {
// Change detection was not possible
// Set all filter keys to true (i.e. changed)
for (const key in filter.rules) {
core.setOutput(key, String(true));
}
}
else {
const result = filter.match(files);
for (const key in result) {
core.setOutput(key, String(result[key]));
}
}
}
catch (error) {
@ -4516,20 +4543,40 @@ function getChangedFiles(token) {
return token ? yield getChangedFilesFromApi(token, pr) : yield getChangedFilesFromGit(pr.base.sha);
}
else if (github.context.eventName === 'push') {
const push = github.context.payload;
return yield getChangedFilesFromGit(push.before);
return getChangedFilesFromPush();
}
else {
throw new Error('This action can be triggered only by pull_request or push event');
}
});
}
function getChangedFilesFromPush() {
return __awaiter(this, void 0, void 0, function* () {
const push = github.context.payload;
// No change detection for pushed tags
if (git.isTagRef(push.ref))
return null;
// Get base from input or use repo default branch.
// It it starts with 'refs/', it will be trimmed (git fetch refs/heads/<NAME> doesn't work)
const baseInput = git.trimRefs(core.getInput('base', { required: false }) || push.repository.default_branch);
// If base references same branch it was pushed to, we will do comparison against the previously pushed commit.
// Otherwise changes are detected against the base reference
const base = git.trimRefsHeads(baseInput) === git.trimRefsHeads(push.ref) ? push.before : baseInput;
// There is no previous commit for comparison
// e.g. change detection against previous commit of just pushed new branch
if (base === git.NULL_SHA)
return null;
return yield getChangedFilesFromGit(base);
});
}
// Fetch base branch and use `git diff` to determine changed files
function getChangedFilesFromGit(sha) {
function getChangedFilesFromGit(ref) {
return __awaiter(this, void 0, void 0, function* () {
core.debug('Fetching base branch and using `git diff-index` to determine changed files');
yield git.fetchCommit(sha);
return yield git.getChangedFiles(sha);
yield git.fetchCommit(ref);
// FETCH_HEAD will always point to the just fetched commit
// No matter if ref is SHA, branch or tag name or full git ref
return yield git.getChangedFiles(git.FETCH_HEAD);
});
}
// Uses github REST api to get list of files changed in PR

View File

@ -1,15 +1,18 @@
import {exec} from '@actions/exec'
export async function fetchCommit(sha: string): Promise<void> {
const exitCode = await exec('git', ['fetch', '--depth=1', 'origin', sha])
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const FETCH_HEAD = 'FETCH_HEAD'
export async function fetchCommit(ref: string): Promise<void> {
const exitCode = await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref])
if (exitCode !== 0) {
throw new Error(`Fetching commit ${sha} failed`)
throw new Error(`Fetching ${ref} failed`)
}
}
export async function getChangedFiles(sha: string): Promise<string[]> {
export async function getChangedFiles(ref: string): Promise<string[]> {
let output = ''
const exitCode = await exec('git', ['diff-index', '--name-only', sha], {
const exitCode = await exec('git', ['diff-index', '--name-only', ref], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
@ -24,3 +27,20 @@ export async function getChangedFiles(sha: string): Promise<string[]> {
.map(s => s.trim())
.filter(s => s.length > 0)
}
export function isTagRef(ref: string): boolean {
return ref.startsWith('refs/tags/')
}
export function trimRefs(ref: string): string {
return trimStart(ref, 'refs/')
}
export function trimRefsHeads(ref: string): string {
const trimRef = trimStart(ref, 'refs/')
return trimStart(trimRef, 'heads/')
}
function trimStart(ref: string, start: string): string {
return ref.startsWith(start) ? ref.substr(start.length) : ref
}

View File

@ -15,9 +15,17 @@ async function run(): Promise<void> {
const filter = new Filter(filtersYaml)
const files = await getChangedFiles(token)
const result = filter.match(files)
for (const key in result) {
core.setOutput(key, String(result[key]))
if (files === null) {
// Change detection was not possible
// Set all filter keys to true (i.e. changed)
for (const key in filter.rules) {
core.setOutput(key, String(true))
}
} else {
const result = filter.match(files)
for (const key in result) {
core.setOutput(key, String(result[key]))
}
}
} catch (error) {
core.setFailed(error.message)
@ -40,23 +48,45 @@ function getConfigFileContent(configPath: string): string {
return fs.readFileSync(configPath, {encoding: 'utf8'})
}
async function getChangedFiles(token: string): Promise<string[]> {
async function getChangedFiles(token: string): Promise<string[] | null> {
if (github.context.eventName === 'pull_request') {
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
return token ? await getChangedFilesFromApi(token, pr) : await getChangedFilesFromGit(pr.base.sha)
} else if (github.context.eventName === 'push') {
const push = github.context.payload as Webhooks.WebhookPayloadPush
return await getChangedFilesFromGit(push.before)
return getChangedFilesFromPush()
} else {
throw new Error('This action can be triggered only by pull_request or push event')
}
}
async function getChangedFilesFromPush(): Promise<string[] | null> {
const push = github.context.payload as Webhooks.WebhookPayloadPush
// No change detection for pushed tags
if (git.isTagRef(push.ref)) return null
// Get base from input or use repo default branch.
// It it starts with 'refs/', it will be trimmed (git fetch refs/heads/<NAME> doesn't work)
const baseInput = git.trimRefs(core.getInput('base', {required: false}) || push.repository.default_branch)
// If base references same branch it was pushed to, we will do comparison against the previously pushed commit.
// Otherwise changes are detected against the base reference
const base = git.trimRefsHeads(baseInput) === git.trimRefsHeads(push.ref) ? push.before : baseInput
// There is no previous commit for comparison
// e.g. change detection against previous commit of just pushed new branch
if (base === git.NULL_SHA) return null
return await getChangedFilesFromGit(base)
}
// Fetch base branch and use `git diff` to determine changed files
async function getChangedFilesFromGit(sha: string): Promise<string[]> {
async function getChangedFilesFromGit(ref: string): Promise<string[]> {
core.debug('Fetching base branch and using `git diff-index` to determine changed files')
await git.fetchCommit(sha)
return await git.getChangedFiles(sha)
await git.fetchCommit(ref)
// FETCH_HEAD will always point to the just fetched commit
// No matter if ref is SHA, branch or tag name or full git ref
return await git.getChangedFiles(git.FETCH_HEAD)
}
// Uses github REST api to get list of files changed in PR