28 Commits

Author SHA1 Message Date
Michal Dorner
7c0f15b688 Update CHANGELOG 2021-01-29 20:10:42 +01:00
Michal Dorner
cbc3287af3 Merge pull request #65 from dorny/add-count-output
Add count output
2021-01-29 20:03:56 +01:00
Michal Dorner
a2730492f0 Add test for ${FILTER_NAME}_count output 2021-01-29 19:59:09 +01:00
Michal Dorner
c2766acabb Add ${FILTER_NAME}_count output 2021-01-29 19:54:44 +01:00
Michal Dorner
363576b9ea Merge pull request #61 from tun0/fix-grouping
Fix grouping of changes
2021-01-26 21:31:41 +01:00
Michal Dorner
b1a097ef7b Rebuild dist/index.js 2021-01-26 21:28:06 +01:00
Ruben Laban
2c79a825c0 Fix grouping 2021-01-26 12:22:37 +01:00
Michal Dorner
4e7fcc37b4 Fix link to CHANGELOG in README 2020-12-17 22:54:24 +01:00
Michal Dorner
c506bed1ae Update CHANGELOG.md for v2.7.0 2020-12-17 22:51:53 +01:00
Michal Dorner
9b7572ffb2 Merge pull request #59 from dorny/array-to-use-in-matrix
Add "changes" output variable to support matrix job configuration
2020-12-17 22:48:28 +01:00
Michal Dorner
9e8c9af501 Add "changes" output variable to support matrix job configuration 2020-12-17 22:33:11 +01:00
Michal Dorner
84e1697bff Merge pull request #58 from dorny/unquoted-shell-escape
Improved listing of matching files with `list-files: shell` and `list-files: escape` options
2020-12-17 22:28:11 +01:00
Michal Dorner
e4d886f503 Provide shell and escape options when formatting matching files 2020-12-17 22:13:28 +01:00
Michal Dorner
ada1eee648 Simplify shell escaping - escape chars instead of quoting whole string 2020-12-17 22:13:28 +01:00
Michal Dorner
44ac6d8e25 Merge pull request #57 from lgatto/patch-1
Fix typos in README.md
2020-12-17 10:28:30 +01:00
Laurent Gatto
3c5b7d242c Update README.md 2020-12-17 08:57:31 +01:00
Michal Dorner
eb75a1edc1 Merge pull request #53 from dorny/detect-local-changes
Support local changes
2020-11-22 21:17:54 +01:00
Michal Dorner
181b35e268 Update docs for v2.6.0 2020-11-22 21:17:07 +01:00
Michal Dorner
1934d574ce Support local changes 2020-11-22 20:59:32 +01:00
Michal Dorner
d599443ba5 Fix change detection using Github API (#51) 2020-11-13 20:15:41 +01:00
Michal Dorner
eb8fe2c24b Update dist/index.js and docs after PR #50 2020-11-13 18:55:57 +01:00
Michal Dorner
dec8b8030e Merge pull request #50 from simllll/patch-1
fix: retrieve all changes via api
2020-11-13 18:48:29 +01:00
Simon Tretter
785a14adbe fix: add missing Octokit import 2020-11-13 17:30:27 +01:00
Simon Tretter
e84bc6af29 fix: retrieve all changes via api
the number returned by pullRequest.changed_files doesn't reflect the correct number of real changed files. Therefore lot of filters didn't work in my scenario, as it said nothing has changed. This especially happens if there are more than 100 files changed (first time I experienced it where over 1000 files have changed, and now when there were about 300 files that have changed). 

I changed the detection by querying the api as long as it returns new results. This ensures all pages have been retrieved. It's tested and used in production on our end, but please check again if I didn't miss anything.

Thanks a lot for this awesome github action :)
2020-11-13 17:28:22 +01:00
Michal Dorner
b4eabb6049 Use picomatch + impl followups from #48 2020-11-09 00:45:53 +01:00
Michal Dorner
550eb4925d Merge pull request #48 from getsentry/master
feat: Add support for multiple patterns when using file status
2020-11-09 00:26:00 +01:00
Billy Vong
5282566eab feat: Add support for multiple patterns when using file status
This adds support for using multiple patterns when checking for file status (added, modified, deleted) and as a result also allows you to use YAML anchors.
2020-11-06 17:41:51 -08:00
Billy Vong
804ec66d7a Fix typo in README example (#47)
The example was missing `outputs`
2020-11-03 09:23:07 +01:00
13 changed files with 405 additions and 2142 deletions

View File

@@ -29,6 +29,9 @@ jobs:
- name: filter-test
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
- name: changes-test
if: contains(fromJSON(steps.filter.outputs.changes), 'error') || !contains(fromJSON(steps.filter.outputs.changes), 'any')
run: exit 1
test-external:
runs-on: ubuntu-latest
@@ -71,6 +74,26 @@ jobs:
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
test-local-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: echo "NEW FILE" > local
- run: git add local
- uses: ./
id: filter
with:
base: HEAD
filters: |
local:
- local
- name: filter-test
if: steps.filter.outputs.local != 'true'
run: exit 1
- name: count-test
if: steps.filter.outputs.local_count != 1
run: exit 1
test-change-type:
runs-on: ubuntu-latest
steps:
@@ -102,15 +125,12 @@ jobs:
- name: Print 'deleted_files'
run: echo ${{steps.filter.outputs.deleted_files}}
- name: filter-test
# only single quotes are supported in GH action literal
# single quote needs to be escaped with single quote
# '''add.txt''' resolves to string 'add.txt'
if: |
steps.filter.outputs.added != 'true'
|| steps.filter.outputs.deleted != 'true'
|| steps.filter.outputs.modified != 'true'
|| steps.filter.outputs.any != 'true'
|| steps.filter.outputs.added_files != '''add.txt'''
|| steps.filter.outputs.modified_files != '''LICENSE'''
|| steps.filter.outputs.deleted_files != '''README.md'''
|| steps.filter.outputs.added_files != 'add.txt'
|| steps.filter.outputs.modified_files != 'LICENSE'
|| steps.filter.outputs.deleted_files != 'README.md'
run: exit 1

View File

@@ -1,5 +1,24 @@
# Changelog
## v2.8.0
- [Add count output variable](https://github.com/dorny/paths-filter/pull/65)
- [Fix log grouping of changes](https://github.com/dorny/paths-filter/pull/61)
## v2.7.0
- [Add "changes" output variable to support matrix job configuration](https://github.com/dorny/paths-filter/pull/59)
- [Improved listing of matching files with `list-files: shell` and `list-files: escape` options](https://github.com/dorny/paths-filter/pull/58)
## v2.6.0
- [Support local changes](https://github.com/dorny/paths-filter/pull/53)
## v2.5.3
- [Fixed mapping of removed/deleted change status from github API](https://github.com/dorny/paths-filter/pull/51)
- [Fixed retrieval of all changes via Github API when there are 100+ changes](https://github.com/dorny/paths-filter/pull/50)
## v2.5.2
- [Add support for multiple patterns when using file status](https://github.com/dorny/paths-filter/pull/48)
- [Use picomatch directly instead of micromatch wrapper](https://github.com/dorny/paths-filter/pull/49)
## v2.5.1
- [Improved path matching with micromatch](https://github.com/dorny/paths-filter/pull/46)

106
README.md
View File

@@ -5,7 +5,7 @@ based on the files modified by pull request, feature branch or in pushed commits
It saves time and resources especially in monorepo setups, where you can run slow tasks (e.g. integration tests or deployments) only for changed components.
Github workflows built-in [path filters](https://docs.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.
don't allow this because they don't work on a level of individual jobs or steps.
**Real world usage examples:**
- [sentry.io](https://sentry.io/) - [backend-test-py3.6.yml](https://github.com/getsentry/sentry/blob/ca0e43dc5602a9ab2e06d3f6397cc48fb5a78541/.github/workflows/backend-test-py3.6.yml#L32)
@@ -35,6 +35,10 @@ doesn't allow this because they doesn't work on a level of individual jobs or st
when `base` input parameter is same as the branch that triggered the workflow:
- Changes are detected from last commit
- Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
- **Local changes**
- Workflow triggered by any event when `base` input parameter is set to `HEAD`
- Changes are detected against current HEAD
- Untracked files are ignored
## Example
```yaml
@@ -52,9 +56,9 @@ doesn't allow this because they doesn't work on a level of individual jobs or st
For more scenarios see [examples](#examples) section.
## Notes:
- Paths expressions are evaluated using [micromatch](https://github.com/micromatch/micromatch) library.
- Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
Documentation for path expression format can be found on project github page.
- Micromatch [dot](https://github.com/micromatch/micromatch#options) option is set to true.
- Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
Globbing will match also paths where file or folder name starts with a dot.
- It's recommended to quote your path expressions with `'` or `"`. Otherwise you will get an error if it starts with `*`.
- Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary.
@@ -62,18 +66,15 @@ For more scenarios see [examples](#examples) section.
# What's New
- Paths expressions are now evaluated using [micromatch](https://github.com/micromatch/micromatch) library
- Configure matrix job to run for each folder with changes using `changes` output
- Improved listing of matching files with `list-files: shell` and `list-files: escape` options
- Support local changes
- Fixed retrieval of all changes via Github API when there are 100+ changes
- Paths expressions are now evaluated using [picomatch](https://github.com/micromatch/picomatch) library
- Support workflows triggered by any event
- Fixed compatibility with older (<2.23) versions of git
- Support for tag pushes and tags as a base reference
- Fixes for various edge cases when event payload is incomplete
- Supports local execution with [act](https://github.com/nektos/act)
- Fixed behavior of feature branch workflow:
- Detects only changes introduced by feature branch. Later modifications on base branch are ignored
- Filter by type of file change:
- Optionally consider if file was added, modified or deleted
For more information see [CHANGELOG](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
# Usage
@@ -122,8 +123,10 @@ For more information see [CHANGELOG](https://github.com/actions/checkout/blob/ma
# Enables listing of files matching the filter:
# 'none' - Disables listing of matching files (default).
# 'json' - Matching files paths are formatted as JSON array.
# 'shell' - Matching files paths are escaped and space-delimited.
# Output is usable as command line argument list in linux shell.
# 'shell' - Space delimited list usable as command line argument list in Linux shell.
# If needed it uses single or double quotes to wrap filename with unsafe characters.
# 'escape'- Space delimited list usable as command line argument list in Linux shell.
# Backslash escapes every potentially unsafe character.
# Default: none
list-files: ''
@@ -144,7 +147,9 @@ For more information see [CHANGELOG](https://github.com/actions/checkout/blob/ma
- For each filter it sets output variable named by the filter to the text:
- `'true'` - if **any** of changed files matches any of filter rules
- `'false'` - if **none** of changed files matches any of filter rules
- For each filter it sets output variable with name `${FILTER_NAME}_count` to the count of matching files.
- If enabled, for each filter it sets output variable with name `${FILTER_NAME}_files`. It will contain list of all files matching the filter.
- `changes` - JSON array with names of all filters matching any of changed files.
# Examples
@@ -228,6 +233,41 @@ jobs:
```
</details>
<details>
<summary>Use change detection to configure matrix job</summary>
```yaml
jobs:
# JOB to run change detection
changes:
runs-on: ubuntu-latest
outputs:
# Expose matched filters as job 'packages' output variable
packages: ${{ steps.filter.outputs.changes }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
package1: src/package1
package2: src/package2
# JOB to build and test each of modified packages
build:
needs: changes
strategy:
matrix:
# Parse JSON array containing names of all filters matching any of changed files
# e.g. ['package1', 'package2'] if both package folders contains changes
package: ${{ fromJson(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- ...
```
</details>
## Change detection workflows
<details>
@@ -303,6 +343,35 @@ jobs:
```
</details>
<details>
<summary><b>Local changes:</b> Detect staged and unstaged local changes</summary>
```yaml
on:
push:
branches: # Push to following branches will trigger the workflow
- master
- develop
- release/**
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Some action which modifies files tracked by git (e.g. code linter)
- uses: johndoe/some-action@v1
# Filter to detect which files were modified
# Changes could be for example automatically committed
- uses: dorny/paths-filter@v2
id: filter
with:
base: HEAD
filters: ... # Configure your filters
```
</details>
## Advanced options
<details>
@@ -350,10 +419,15 @@ jobs:
# dictionary, where type(s) of change composes the key.
# Multiple change types can be specified using `|` as delimiter.
filters: |
shared: &shared
- common/**
- config/**
addedOrModified:
- added|modified: '**'
allChanges:
- added|deleted|modified: '**'
addedOrModifiedAnchors:
- added|modified: *shared
```
</details>
@@ -370,7 +444,7 @@ jobs:
# Enable listing of files matching each filter.
# Paths to files will be available in `${FILTER_NAME}_files` output variable.
# Paths will be escaped and space-delimited.
# Output is usable as command line argument list in linux shell
# Output is usable as command line argument list in Linux shell
list-files: shell
# In this example changed files will be checked by linter.
@@ -405,7 +479,7 @@ jobs:
- name: Lint Markdown
uses: johndoe/some-action@v1
with:
files: ${{ steps.filter.changed_files }}
files: ${{ steps.filter.outputs.changed_files }}
```
</details>

View File

@@ -165,6 +165,20 @@ describe('matching specific change status', () => {
const match = filter.match(files)
expect(match.addOrModify).toEqual(files)
})
test('matches when using an anchor', () => {
const yaml = `
shared: &shared
- common/**/*
- config/**/*
src:
- modified: *shared
`
let filter = new Filter(yaml)
const files = modified(['config/file.js', 'common/anotherFile.js'])
const match = filter.match(files)
expect(match.src).toEqual(files)
})
})
function modified(paths: string[]): File[] {

View File

@@ -1,16 +1,57 @@
import shellEscape from '../src/shell-escape'
import {escape, shellEscape} from '../src/shell-escape'
test('simple path escaped', () => {
expect(shellEscape('file')).toBe("'file'")
describe('escape() backslash escapes every character except subset of definitely safe characters', () => {
test('simple filename should not be modified', () => {
expect(escape('file.txt')).toBe('file.txt')
})
test('directory separator should be preserved and not escaped', () => {
expect(escape('path/to/file.txt')).toBe('path/to/file.txt')
})
test('spaces should be escaped with backslash', () => {
expect(escape('file with space')).toBe('file\\ with\\ space')
})
test('quotes should be escaped with backslash', () => {
expect(escape('file\'with quote"')).toBe('file\\\'with\\ quote\\"')
})
test('$variables should be escaped', () => {
expect(escape('$var')).toBe('\\$var')
})
})
test('path with space is wrapped with single quotes', () => {
expect(shellEscape('file with space')).toBe("'file with space'")
})
describe('shellEscape() returns human readable filenames with as few escaping applied as possible', () => {
test('simple filename should not be modified', () => {
expect(shellEscape('file.txt')).toBe('file.txt')
})
test('path with quote is divided into quoted segments and escaped quote', () => {
expect(shellEscape("file'with quote")).toBe("'file'\\''with quote'")
})
test('path with leading quote does not have double quotes at beginning', () => {
expect(shellEscape("'file-leading-quote")).toBe("\\''file-leading-quote'")
test('directory separator should be preserved and not escaped', () => {
expect(shellEscape('path/to/file.txt')).toBe('path/to/file.txt')
})
test('filename with spaces should be quoted', () => {
expect(shellEscape('file with space')).toBe("'file with space'")
})
test('filename with spaces should be quoted', () => {
expect(shellEscape('file with space')).toBe("'file with space'")
})
test('filename with $ should be quoted', () => {
expect(shellEscape('$var')).toBe("'$var'")
})
test('filename with " should be quoted', () => {
expect(shellEscape('file"name')).toBe("'file\"name'")
})
test('filename with single quote should be wrapped in double quotes', () => {
expect(shellEscape("file'with quote")).toBe('"file\'with quote"')
})
test('filename with single quote and special characters is split and quoted/escaped as needed', () => {
expect(shellEscape("file'with $quote")).toBe("file\\''with $quote'")
})
})

View File

@@ -22,8 +22,11 @@ inputs:
description: |
Enables listing of files matching the filter:
'none' - Disables listing of matching files (default).
'json' - Matching files paths are serialized as JSON array.
'shell' - Matching files paths are escaped and space-delimited. Output is usable as command line argument list in linux shell.
'json' - Serialized as JSON array.
'shell' - Space delimited list usable as command line argument list in linux shell.
If needed it uses single or double quotes to wrap filename with unsafe characters.
'escape'- Space delimited list usable as command line argument list in linux shell.
Backslash escapes every potentially unsafe character.
required: true
default: none
initial-fetch-depth:
@@ -34,6 +37,9 @@ inputs:
This option takes effect only when changes are detected using git against different base branch.
required: false
default: '10'
outputs:
changes:
description: JSON array with names of all filters matching any of changed files
runs:
using: 'node12'
main: 'dist/index.js'

2131
dist/index.js vendored

File diff suppressed because it is too large Load Diff

59
package-lock.json generated
View File

@@ -3723,12 +3723,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/braces": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.0.tgz",
"integrity": "sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw==",
"dev": true
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -3797,15 +3791,6 @@
"integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==",
"dev": true
},
"@types/micromatch": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.1.tgz",
"integrity": "sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw==",
"dev": true,
"requires": {
"@types/braces": "*"
}
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -3823,6 +3808,12 @@
"integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==",
"dev": true
},
"@types/picomatch": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.2.1.tgz",
"integrity": "sha512-26/tQcDmJXYHiaWAAIjnTVL5nwrT+IVaqFZIbBImAuKk/r/j1r/1hmZ7uaOzG6IknqP3QHcNNQ6QO8Vp28lUoA==",
"dev": true
},
"@types/prettier": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.1.tgz",
@@ -4980,14 +4971,6 @@
"concat-map": "0.0.1"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"requires": {
"fill-range": "^7.0.1"
}
},
"browser-process-hrtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
@@ -6586,14 +6569,6 @@
"flat-cache": "^2.0.1"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"requires": {
"to-regex-range": "^5.0.1"
}
},
"find-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
@@ -7228,11 +7203,6 @@
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@@ -12570,15 +12540,6 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
},
"micromatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
"integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.0.5"
}
},
"mime-db": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
@@ -14397,14 +14358,6 @@
"safe-regex": "^1.1.0"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"requires": {
"is-number": "^7.0.0"
}
},
"tough-cookie": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz",

View File

@@ -29,14 +29,14 @@
"@actions/exec": "^1.0.4",
"@actions/github": "^2.2.0",
"@octokit/webhooks": "^7.6.2",
"micromatch": "^4.0.2"
"picomatch": "^2.2.2"
},
"devDependencies": {
"@types/jest": "^25.2.3",
"@types/js-yaml": "^3.12.4",
"@types/micromatch": "^4.0.1",
"@types/minimatch": "^3.0.3",
"@types/node": "^14.0.5",
"@types/picomatch": "^2.2.1",
"@typescript-eslint/parser": "^3.3.0",
"@zeit/ncc": "^0.22.3",
"eslint": "^7.3.0",

View File

@@ -1,5 +1,5 @@
import * as jsyaml from 'js-yaml'
import * as micromatch from 'micromatch'
import picomatch from 'picomatch'
import {File, ChangeStatus} from './file'
// Type definition of object we expect to load from YAML
@@ -8,11 +8,11 @@ interface FilterYaml {
}
type FilterItemYaml =
| string // Filename pattern, e.g. "path/to/*.js"
| {[changeTypes: string]: string} // Change status and filename, e.g. added|modified: "path/to/*.js"
| {[changeTypes: string]: string | string[]} // Change status and filename, e.g. added|modified: "path/to/*.js"
| FilterItemYaml[] // Supports referencing another rule via YAML anchor
// Minimatch options used in all matchers
const MatchOptions: micromatch.Options = {
const MatchOptions = {
dot: true
}
@@ -73,14 +73,14 @@ export class Filter {
}
if (typeof item === 'string') {
return [{status: undefined, isMatch: micromatch.matcher(item, MatchOptions)}]
return [{status: undefined, isMatch: picomatch(item, MatchOptions)}]
}
if (typeof item === 'object') {
return Object.entries(item).map(([key, pattern]) => {
if (typeof key !== 'string' || typeof pattern !== 'string') {
if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) {
this.throwInvalidFormatError(
`Expected [key:string]= pattern:string, but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
)
}
return {
@@ -89,7 +89,7 @@ export class Filter {
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()) as ChangeStatus[],
isMatch: micromatch.matcher(pattern, MatchOptions)
isMatch: picomatch(pattern, MatchOptions)
}
})
}

View File

@@ -3,6 +3,7 @@ import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const HEAD = 'HEAD'
export async function getChangesInLastCommit(): Promise<File[]> {
core.startGroup(`Change detection in last commit`)
@@ -39,6 +40,20 @@ export async function getChanges(ref: string): Promise<File[]> {
return parseGitDiffOutput(output)
}
export async function getChangesOnHead(): Promise<File[]> {
// Get current changes - both staged and unstaged
core.startGroup(`Change detection on HEAD`)
let output = ''
try {
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
}
export async function getChangesSinceMergeBase(ref: string, initialFetchDepth: number): Promise<File[]> {
if (!(await hasCommit(ref))) {
// Fetch and add base branch

View File

@@ -6,9 +6,9 @@ import {Webhooks} from '@octokit/webhooks'
import {Filter, FilterResults} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import shellEscape from './shell-escape'
import {escape, shellEscape} from './shell-escape'
type ExportFormat = 'none' | 'json' | 'shell'
type ExportFormat = 'none' | 'json' | 'shell' | 'escape'
async function run(): Promise<void> {
try {
@@ -55,6 +55,12 @@ function getConfigFileContent(configPath: string): string {
}
async function getChangedFiles(token: string, base: string, initialFetchDepth: number): Promise<File[]> {
// if base is 'HEAD' only local uncommitted changes will be detected
// This is the simplest case as we don't need to fetch more commits or evaluate current/before refs
if (base === git.HEAD) {
return await git.getChangesOnHead()
}
if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') {
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
if (token) {
@@ -75,7 +81,7 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
const pushRef =
git.getShortName(github.context.ref) ||
(core.warning(`'ref' field is missing in PUSH event payload - using current branch, tag or commit SHA`),
(core.warning(`'ref' field is missing in event payload - using current branch, tag or commit SHA`),
await git.getCurrentRef())
const baseRef = git.getShortName(base) || defaultRef
@@ -88,11 +94,11 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
const isBaseRefSha = git.isGitSha(baseRef)
const isBaseSameAsPush = baseRef === pushRef
// If base is commit SHA will do comparison against the referenced commit
// Or If base references same branch it was pushed to, we will do comparison against the previously pushed commit
// If base is commit SHA we will do comparison against the referenced commit
// Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit
if (isBaseRefSha || isBaseSameAsPush) {
if (!isBaseRefSha && !beforeSha) {
core.warning(`'before' field is missing in PUSH event payload - changes will be detected from last commit`)
core.warning(`'before' field is missing in event payload - changes will be detected from last commit`)
return await git.getChangesInLastCommit()
}
@@ -123,11 +129,13 @@ async function getChangedFilesFromApi(
token: string,
pullRequest: Webhooks.WebhookPayloadPullRequestPullRequest
): Promise<File[]> {
core.info(`Fetching list of changed files for PR#${pullRequest.number} from Github API`)
core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`)
core.info(`Number of changed_files is ${pullRequest.changed_files}`)
const client = new github.GitHub(token)
const pageSize = 100
const files: File[] = []
for (let page = 0; page * pageSize < pullRequest.changed_files; page++) {
for (let page = 1; (page - 1) * pageSize < pullRequest.changed_files; page++) {
core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, page: ${page}, per_page: ${pageSize})`)
const response = await client.pulls.listFiles({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
@@ -136,6 +144,7 @@ async function getChangedFilesFromApi(
per_page: pageSize
})
for (const row of response.data) {
core.info(`[${row.status}] ${row.filename}`)
// There's no obvious use-case for detection of renames
// Therefore we treat it as if rename detection in git diff was turned off.
// Rename is replaced by delete of original filename and add of new filename
@@ -150,23 +159,28 @@ async function getChangedFilesFromApi(
status: ChangeStatus.Deleted
})
} else {
// Github status and git status variants are same except for deleted files
const status = row.status === 'removed' ? ChangeStatus.Deleted : (row.status as ChangeStatus)
files.push({
filename: row.filename,
status: row.status as ChangeStatus
status
})
}
}
}
core.endGroup()
return files
}
function exportResults(results: FilterResults, format: ExportFormat): void {
core.info('Results:')
const changes = []
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0
core.startGroup(`Filter ${key} = ${value}`)
if (files.length > 0) {
changes.push(key)
core.info('Matching files:')
for (const file of files) {
core.info(`${file.filename} [${file.status}]`)
@@ -176,12 +190,21 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
}
core.setOutput(key, value)
core.setOutput(`${key}_count`, files.length)
if (format !== 'none') {
const filesValue = serializeExport(files, format)
core.setOutput(`${key}_files`, filesValue)
}
core.endGroup()
}
if (results['changes'] === undefined) {
const changesJson = JSON.stringify(changes)
core.info(`Changes output set to ${changesJson}`)
core.setOutput('changes', changesJson)
} else {
core.info('Cannot set changes output variable - name already used by filter output')
}
core.endGroup()
}
function serializeExport(files: File[], format: ExportFormat): string {
@@ -189,6 +212,8 @@ function serializeExport(files: File[], format: ExportFormat): string {
switch (format) {
case 'json':
return JSON.stringify(fileNames)
case 'escape':
return fileNames.map(escape).join(' ')
case 'shell':
return fileNames.map(shellEscape).join(' ')
default:
@@ -197,7 +222,7 @@ function serializeExport(files: File[], format: ExportFormat): string {
}
function isExportFormat(value: string): value is ExportFormat {
return value === 'none' || value === 'shell' || value === 'json'
return value === 'none' || value === 'shell' || value === 'json' || value === 'escape'
}
run()

View File

@@ -1,7 +1,28 @@
// Credits to https://github.com/xxorax/node-shell-escape
export default function shellEscape(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
.replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning
.replace(/\\'''/g, "\\'") // remove non-escaped single-quote if there are enclosed between 2 escaped
// Backslash escape every character except small subset of definitely safe characters
export function escape(value: string): string {
return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1')
}
// Returns filename escaped for usage as shell argument.
// Applies "human readable" approach with as few escaping applied as possible
export function shellEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
return value
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
return `"${value}"`
}
// Split by single quote and apply escaping recursively
return value.split("'").map(shellEscape).join("\\'")
}
// Contains some unsafe characters but no single quote
return `'${value}'`
}