mirror of
https://gitea.com/actions/dorny-paths-filter.git
synced 2025-12-23 05:48:41 +00:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc4621a31 | ||
|
|
7267a8516b | ||
|
|
e36f1124bf | ||
|
|
2f74457227 | ||
|
|
67617953b4 | ||
|
|
a35d8d6a33 | ||
|
|
b5a5203f8b | ||
|
|
3c49e64ca2 | ||
|
|
8ec7be4734 | ||
|
|
100a1198b2 | ||
|
|
96be2b61c4 | ||
|
|
f5071954e8 | ||
|
|
4067d88573 | ||
|
|
fc3b4e8a61 | ||
|
|
fbb4d78dec | ||
|
|
4512585405 | ||
|
|
5f5fe18015 | ||
|
|
e12ca0e584 | ||
|
|
ffe0943825 | ||
|
|
c763b521be | ||
|
|
9e7258bb2a | ||
|
|
baa26e3237 | ||
|
|
513ea69ce3 | ||
|
|
027a82c128 | ||
|
|
e0f036e43d | ||
|
|
737cb1986a | ||
|
|
0ef5f0d812 | ||
|
|
248cda557c | ||
|
|
b0e6c31fb8 | ||
|
|
ce8f47aa7f | ||
|
|
829abbf5d3 | ||
|
|
c232e225e7 | ||
|
|
e7dd821189 | ||
|
|
375cc8a558 | ||
|
|
a458940404 | ||
|
|
ff17951ef9 | ||
|
|
8c7f485a57 | ||
|
|
17e486d015 | ||
|
|
bdd8d7ab6c | ||
|
|
38e0a049f6 | ||
|
|
6b40481b02 | ||
|
|
b55f63c13c | ||
|
|
816eb040ab | ||
|
|
1ec7035ff5 | ||
|
|
74cfa7995e | ||
|
|
a0e43af4ae | ||
|
|
b7a9db5c9b | ||
|
|
b2feaf19c2 | ||
|
|
71d51d8208 | ||
|
|
af2564d3e0 | ||
|
|
78ab00f877 | ||
|
|
f3d3fc848b | ||
|
|
aae9c5619e | ||
|
|
e59743163d | ||
|
|
78b1672eeb | ||
|
|
f1c461fccf | ||
|
|
0b18612ac3 | ||
|
|
87375a4a68 | ||
|
|
24a74833cc | ||
|
|
d0507d9a8a | ||
|
|
f093f3520b | ||
|
|
8d029eb508 | ||
|
|
07d6abdb9c | ||
|
|
e8f370c197 | ||
|
|
58ed00ec48 | ||
|
|
02eeef4973 | ||
|
|
37a6d38b2d | ||
|
|
208adf42c8 | ||
|
|
ad1ae68cd0 | ||
|
|
5d414b88ab | ||
|
|
a6989ad592 | ||
|
|
6d8169070c | ||
|
|
3d4a25053b | ||
|
|
e59197f91b | ||
|
|
ca8fa4002c | ||
|
|
c64be944bf | ||
|
|
138368ff4f | ||
|
|
a301a0ad83 | ||
|
|
0c0d1a854a | ||
|
|
0aa1597c2b | ||
|
|
46d2898cef | ||
|
|
c90ecaa5a1 | ||
|
|
49abb091ed | ||
|
|
8801c887e9 | ||
|
|
68792bf56a | ||
|
|
31c576896e | ||
|
|
3be8c93277 | ||
|
|
1cdd3bbdf6 | ||
|
|
e5b96fe4da | ||
|
|
a339507743 | ||
|
|
febe8330ca | ||
|
|
b5fa2d5c02 | ||
|
|
e2bed85912 | ||
|
|
7c0f15b688 | ||
|
|
cbc3287af3 | ||
|
|
a2730492f0 | ||
|
|
c2766acabb | ||
|
|
363576b9ea | ||
|
|
b1a097ef7b | ||
|
|
2c79a825c0 | ||
|
|
4e7fcc37b4 | ||
|
|
c506bed1ae | ||
|
|
9b7572ffb2 | ||
|
|
9e8c9af501 | ||
|
|
84e1697bff | ||
|
|
e4d886f503 | ||
|
|
ada1eee648 | ||
|
|
44ac6d8e25 | ||
|
|
3c5b7d242c | ||
|
|
eb75a1edc1 | ||
|
|
181b35e268 | ||
|
|
1934d574ce | ||
|
|
d599443ba5 | ||
|
|
eb8fe2c24b | ||
|
|
dec8b8030e | ||
|
|
785a14adbe | ||
|
|
e84bc6af29 | ||
|
|
b4eabb6049 | ||
|
|
550eb4925d | ||
|
|
5282566eab | ||
|
|
804ec66d7a | ||
|
|
b37d4e9e86 | ||
|
|
7b5334ddb5 | ||
|
|
75cbfb4be9 | ||
|
|
9bd03c0d68 | ||
|
|
9553dabbd8 | ||
|
|
8b399ed168 | ||
|
|
ff5bb057bf | ||
|
|
d9e86af7c0 | ||
|
|
adb239d623 | ||
|
|
1f7b23edeb | ||
|
|
bfc5803f5e | ||
|
|
b4f32c4f08 | ||
|
|
81c90ccae8 | ||
|
|
3f845744aa | ||
|
|
483986d0a7 | ||
|
|
3b817c9974 | ||
|
|
b365bd8768 | ||
|
|
f34047f516 | ||
|
|
1ff702da35 | ||
|
|
caef9bef1f | ||
|
|
14dd70c742 | ||
|
|
e1ae9889cb | ||
|
|
83deb9f037 | ||
|
|
7d201829e2 | ||
|
|
4eb15bc267 | ||
|
|
9ef7936e79 | ||
|
|
affb29871a | ||
|
|
910e8b1235 | ||
|
|
1cbb925a17 | ||
|
|
a2e5f9f7bb | ||
|
|
0612377665 | ||
|
|
0c9e16cc6d | ||
|
|
9b321c4b3a | ||
|
|
25327a213e |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"plugins": ["jest", "@typescript-eslint"],
|
||||
"extends": ["plugin:github/es6"],
|
||||
"extends": ["plugin:github/internal"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -16,23 +16,19 @@
|
||||
"@typescript-eslint/no-require-imports": "error",
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/ban-ts-ignore": "error",
|
||||
"camelcase": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/class-name-casing": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
|
||||
"@typescript-eslint/func-call-spacing": ["error", "never"],
|
||||
"@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"],
|
||||
"@typescript-eslint/no-array-constructor": "error",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "error",
|
||||
"@typescript-eslint/no-for-in-array": "error",
|
||||
"@typescript-eslint/no-inferrable-types": "error",
|
||||
"@typescript-eslint/no-misused-new": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-object-literal-type-assertion": "error",
|
||||
"@typescript-eslint/no-unnecessary-qualifier": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
@@ -40,9 +36,8 @@
|
||||
"@typescript-eslint/prefer-for-of": "warn",
|
||||
"@typescript-eslint/prefer-function-type": "warn",
|
||||
"@typescript-eslint/prefer-includes": "error",
|
||||
"@typescript-eslint/prefer-interface": "error",
|
||||
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/promise-function-async": ["error", { "allowAny": true }],
|
||||
"@typescript-eslint/require-array-sort-compare": "error",
|
||||
"@typescript-eslint/restrict-plus-operands": "error",
|
||||
"semi": "off",
|
||||
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
4
.github/filters.yml
vendored
Normal file
4
.github/filters.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
error:
|
||||
- not_existing_path/**/*
|
||||
any:
|
||||
- "**/*"
|
||||
32
.github/workflows/build.yml
vendored
Normal file
32
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: "Build"
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore: [ '*.md' ]
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- run: |
|
||||
npm install
|
||||
npm run all
|
||||
|
||||
self-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
filters: '.github/filters.yml'
|
||||
- name: filter-test
|
||||
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
|
||||
run: exit 1
|
||||
145
.github/workflows/pull-request-verification.yml
vendored
Normal file
145
.github/workflows/pull-request-verification.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: "Pull Request Verification"
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore: [ '*.md' ]
|
||||
branches:
|
||||
- master
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- run: |
|
||||
npm install
|
||||
npm run all
|
||||
|
||||
test-inline:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
error:
|
||||
- not_existing_path/**/*
|
||||
any:
|
||||
- "**/*"
|
||||
- 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
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
filters: '.github/filters.yml'
|
||||
- name: filter-test
|
||||
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
|
||||
run: exit 1
|
||||
|
||||
test-without-token:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
filters: '.github/filters.yml'
|
||||
- name: filter-test
|
||||
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
|
||||
run: exit 1
|
||||
|
||||
test-wd-without-token:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: somewhere
|
||||
- uses: ./somewhere
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
working-directory: somewhere
|
||||
filters: '.github/filters.yml'
|
||||
- name: filter-test
|
||||
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@v4
|
||||
- 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:
|
||||
- uses: actions/checkout@v4
|
||||
- name: configure GIT user
|
||||
run: git config user.email "john@nowhere.local" && git config user.name "John Doe"
|
||||
- name: modify working tree
|
||||
run: touch add.txt && rm README.md && echo "TEST" > LICENSE
|
||||
- name: commit changes
|
||||
run: git add -A && git commit -a -m 'testing this action'
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
list-files: shell
|
||||
filters: |
|
||||
added:
|
||||
- added: "add.txt"
|
||||
deleted:
|
||||
- deleted: "README.md"
|
||||
modified:
|
||||
- modified: "LICENSE"
|
||||
any:
|
||||
- added|deleted|modified: "*"
|
||||
- name: Print 'added_files'
|
||||
run: echo ${{steps.filter.outputs.added_files}}
|
||||
- name: Print 'modified_files'
|
||||
run: echo ${{steps.filter.outputs.modified_files}}
|
||||
- name: Print 'deleted_files'
|
||||
run: echo ${{steps.filter.outputs.deleted_files}}
|
||||
- name: filter-test
|
||||
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'
|
||||
run: exit 1
|
||||
36
.github/workflows/test.yml
vendored
36
.github/workflows/test.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: "build-test"
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: |
|
||||
npm install
|
||||
npm run all
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
githubToken: ${{ github.token }}
|
||||
filters: |
|
||||
src:
|
||||
- src/**/*
|
||||
tests:
|
||||
- __tests__/**/*
|
||||
any:
|
||||
- "**/*"
|
||||
- name: filter-test
|
||||
if: steps.filter.outputs.any != 'true'
|
||||
run: exit 1
|
||||
105
CHANGELOG.md
Normal file
105
CHANGELOG.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## v3.0.0
|
||||
- [Update to Node.js 20 ](https://github.com/dorny/paths-filter/pull/210)
|
||||
- [Update all dependencies](https://github.com/dorny/paths-filter/pull/215)
|
||||
|
||||
## v2.11.1
|
||||
- [Update @actions/core to v1.10.0 - Fixes warning about deprecated set-output](https://github.com/dorny/paths-filter/pull/167)
|
||||
- [Document need for pull-requests: read permission](https://github.com/dorny/paths-filter/pull/168)
|
||||
- [Updating to actions/checkout@v3](https://github.com/dorny/paths-filter/pull/164)
|
||||
|
||||
## v2.11.0
|
||||
- [Set list-files input parameter as not required](https://github.com/dorny/paths-filter/pull/157)
|
||||
- [Update Node.js](https://github.com/dorny/paths-filter/pull/161)
|
||||
- [Fix incorrect handling of Unicode characters in exec()](https://github.com/dorny/paths-filter/pull/162)
|
||||
- [Use Octokit pagination](https://github.com/dorny/paths-filter/pull/163)
|
||||
- [Updates real world links](https://github.com/dorny/paths-filter/pull/160)
|
||||
|
||||
## v2.10.2
|
||||
- [Fix getLocalRef() returns wrong ref](https://github.com/dorny/paths-filter/pull/91)
|
||||
|
||||
## v2.10.1
|
||||
- [Improve robustness of change detection](https://github.com/dorny/paths-filter/pull/85)
|
||||
|
||||
## v2.10.0
|
||||
- [Add ref input parameter](https://github.com/dorny/paths-filter/pull/82)
|
||||
- [Fix change detection in PR when pullRequest.changed_files is incorrect](https://github.com/dorny/paths-filter/pull/83)
|
||||
|
||||
## v2.9.3
|
||||
- [Fix change detection when base is a tag](https://github.com/dorny/paths-filter/pull/78)
|
||||
|
||||
## v2.9.2
|
||||
- [Fix fetching git history](https://github.com/dorny/paths-filter/pull/75)
|
||||
|
||||
## v2.9.1
|
||||
- [Fix fetching git history + fallback to unshallow repo](https://github.com/dorny/paths-filter/pull/74)
|
||||
|
||||
## v2.9.0
|
||||
- [Add list-files: csv format](https://github.com/dorny/paths-filter/pull/68)
|
||||
|
||||
## 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)
|
||||
|
||||
## v2.5.0
|
||||
- [Support workflows triggered by any event](https://github.com/dorny/paths-filter/pull/44)
|
||||
|
||||
## v2.4.2
|
||||
- [Fixed compatibility with older (<2.23) versions of git](https://github.com/dorny/paths-filter/pull/42)
|
||||
|
||||
## v2.4.0
|
||||
- [Support pushes of tags or when tag is used as base](https://github.com/dorny/paths-filter/pull/40)
|
||||
- [Use git log to detect changes from PRs merge commit if token is not available](https://github.com/dorny/paths-filter/pull/40)
|
||||
- [Support local execution with act](https://github.com/dorny/paths-filter/pull/40)
|
||||
- [Improved processing of repository initial push](https://github.com/dorny/paths-filter/pull/40)
|
||||
- [Improved processing of first push of new branch](https://github.com/dorny/paths-filter/pull/40)
|
||||
|
||||
|
||||
## v2.3.0
|
||||
- [Improved documentation](https://github.com/dorny/paths-filter/pull/37)
|
||||
- [Change detection using git "three dot" diff](https://github.com/dorny/paths-filter/pull/35)
|
||||
- [Export files matching filter](https://github.com/dorny/paths-filter/pull/32)
|
||||
- [Extend filter syntax with optional specification of file status: add, modified, deleted](https://github.com/dorny/paths-filter/pull/22)
|
||||
- [Add working-directory input](https://github.com/dorny/paths-filter/pull/21)
|
||||
|
||||
## v2.2.1
|
||||
- [Add support for pull_request_target](https://github.com/dorny/paths-filter/pull/29)
|
||||
|
||||
## 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)
|
||||
|
||||
## v2.0.0
|
||||
- [Added support for workflows triggered by push events](https://github.com/dorny/paths-filter/pull/10)
|
||||
- Action and repository renamed to paths-filter - original name doesn't make sense anymore
|
||||
|
||||
## v1.1.0
|
||||
- [Allows filters to be specified in own .yml file](https://github.com/dorny/paths-filter/pull/8)
|
||||
- [Adds alternative change detection using git fetch and git diff-index](https://github.com/dorny/paths-filter/pull/9)
|
||||
|
||||
## v1.0.1
|
||||
Updated dependencies - fixes github security alert
|
||||
|
||||
## v1.0.0
|
||||
First official release uploaded to marketplace.
|
||||
2
LICENSE
2
LICENSE
@@ -1,7 +1,7 @@
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 GitHub, Inc. and contributors
|
||||
Copyright (c) 2020 Michal Dorner and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
553
README.md
553
README.md
@@ -1,74 +1,197 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/dorny/pr-changed-files-filter/actions"><img alt="typescript-action status" src="https://github.com/dorny/pr-changed-files-filter/workflows/build-test/badge.svg"></a>
|
||||
</p>
|
||||
# Paths Changes Filter
|
||||
|
||||
**CAUTION**: This action can be only used in a workflow triggered by `pull_request` event.
|
||||
[GitHub Action](https://github.com/features/actions) that enables conditional execution of workflow steps and jobs, based on the files modified by pull request, on a feature
|
||||
branch, or by the recently pushed commits.
|
||||
|
||||
# Pull request changed files filter
|
||||
Run slow tasks like integration tests or deployments only for changed components. It saves time and resources, especially in monorepo setups.
|
||||
GitHub workflows built-in [path filters](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths)
|
||||
don't allow this because they don't work on a level of individual jobs or steps.
|
||||
|
||||
This [Github Action](https://github.com/features/actions) enables conditional execution of workflow job steps considering which files are modified by a pull request.
|
||||
**Real world usage examples:**
|
||||
|
||||
It saves time and resources especially in monorepo setups, where you can run slow tasks (e.g. integration tests) only for changed components.
|
||||
Github workflows built-in
|
||||
[path filters](https://help.github.com/en/actions/referenceworkflow-syntax-for-github-actions#onpushpull_requestpaths)
|
||||
doesn't allow this because they doesn't work on a level of individual jobs or steps.
|
||||
- [sentry.io](https://sentry.io/) - [backend.yml](https://github.com/getsentry/sentry/blob/2ebe01feab863d89aa7564e6d243b6d80c230ddc/.github/workflows/backend.yml#L36)
|
||||
- [GoogleChrome/web.dev](https://web.dev/) - [lint-workflow.yml](https://github.com/GoogleChrome/web.dev/blob/3a57b721e7df6fc52172f676ca68d16153bda6a3/.github/workflows/lint-workflow.yml#L26)
|
||||
- [blog post Configuring python linting to be part of CI/CD using GitHub actions](https://dev.to/freshbooks/configuring-python-linting-to-be-part-of-cicd-using-github-actions-1731#what-files-does-it-run-against) - [py_linter.yml](https://github.com/iamtodor/demo-github-actions-python-linter-configuration/blob/main/.github/workflows/py_linter.yml#L31)
|
||||
|
||||
## Supported workflows
|
||||
|
||||
- **Pull requests:**
|
||||
- Workflow triggered by **[pull_request](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request)**
|
||||
or **[pull_request_target](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target)** event
|
||||
- Changes are detected against the pull request base branch
|
||||
- Uses GitHub REST API to fetch a list of modified files
|
||||
- Requires [pull-requests: read](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs) permission
|
||||
- **Feature branches:**
|
||||
- Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)**
|
||||
or any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
|
||||
- The `base` input parameter must not be the same as the branch that triggered the workflow
|
||||
- Changes are detected against the merge-base with the configured base branch or the default branch
|
||||
- Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
|
||||
- **Master, Release, or other long-lived branches:**
|
||||
- Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)** event
|
||||
when `base` input parameter is the same as the branch that triggered the workflow:
|
||||
- Changes are detected against the most recent commit on the same branch before the push
|
||||
- Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
|
||||
when `base` input parameter is commit SHA:
|
||||
- Changes are detected against the provided `base` commit
|
||||
- Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
|
||||
when `base` input parameter is the same as the branch that triggered the workflow:
|
||||
- Changes are detected from the 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 the current HEAD
|
||||
- Untracked files are ignored
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- 'src/**'
|
||||
|
||||
# run only if some file in 'src' folder was changed
|
||||
- if: steps.changes.outputs.src == 'true'
|
||||
run: ...
|
||||
```
|
||||
|
||||
For more scenarios see [examples](#examples) section.
|
||||
|
||||
## Notes
|
||||
|
||||
- Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
|
||||
Documentation for path expression format can be found on the project GitHub page.
|
||||
- Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
|
||||
Globbing will also match 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.
|
||||
- Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04`
|
||||
|
||||
## What's New
|
||||
|
||||
- Add `ref` input parameter
|
||||
- Add `list-files: csv` format
|
||||
- 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
|
||||
- Paths expressions are now evaluated using [picomatch](https://github.com/micromatch/picomatch) library
|
||||
|
||||
For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
|
||||
|
||||
## Usage
|
||||
|
||||
The action accepts filter rules in the YAML format.
|
||||
Each filter rule is a list of [glob expressions](https://github.com/isaacs/minimatch).
|
||||
Corresponding output variable will be created to indicate if there's a changed file matching any of the rule glob expressions.
|
||||
Output variables can be later used in the `if` clause to conditionally run specific steps.
|
||||
|
||||
### Inputs
|
||||
- **`githubToken`**: GitHub Access Token - use `${{ github.token }}`
|
||||
- **`filters`**: YAML dictionary where keys specifies rule names and values are lists of file path patterns
|
||||
|
||||
### Outputs
|
||||
- For each rule it sets output variable named by the rule to text:
|
||||
- `'true'` - if **any** of changed files matches any of rule patterns
|
||||
- `'false'` - if **none** of changed files matches any of rule patterns
|
||||
|
||||
|
||||
### Notes
|
||||
- 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.
|
||||
|
||||
### Sample workflow
|
||||
```yaml
|
||||
...
|
||||
name: Build verification
|
||||
- uses: dorny/paths-filter@v3
|
||||
with:
|
||||
# Defines filters applied to detected changed files.
|
||||
# Each filter has a name and a list of rules.
|
||||
# Rule is a glob expression - paths of all changed
|
||||
# files are matched against it.
|
||||
# Rule can optionally specify if the file
|
||||
# should be added, modified, or deleted.
|
||||
# For each filter, there will be a corresponding output variable to
|
||||
# indicate if there's a changed file matching any of the rules.
|
||||
# Optionally, there can be a second output variable
|
||||
# set to list of all files matching the filter.
|
||||
# Filters can be provided inline as a string (containing valid YAML document),
|
||||
# or as a relative path to a file (e.g.: .github/filters.yaml).
|
||||
# Filters syntax is documented by example - see examples section.
|
||||
filters: ''
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
branches:
|
||||
- master
|
||||
# Branch, tag, or commit SHA against which the changes will be detected.
|
||||
# If it references the same branch it was pushed to,
|
||||
# changes are detected against the most recent commit before the push.
|
||||
# Otherwise, it uses git merge-base to find the best common ancestor between
|
||||
# current branch (HEAD) and base.
|
||||
# When merge-base is found, it's used for change detection - only changes
|
||||
# introduced by the current branch are considered.
|
||||
# All files are considered as added if there is no common ancestor with
|
||||
# base branch or no previous commit.
|
||||
# This option is ignored if action is triggered by pull_request event.
|
||||
# Default: repository default branch (e.g. master)
|
||||
base: ''
|
||||
|
||||
# Git reference (e.g. branch name) from which the changes will be detected.
|
||||
# Useful when workflow can be triggered only on the default branch (e.g. repository_dispatch event)
|
||||
# but you want to get changes on a different branch.
|
||||
# This option is ignored if action is triggered by pull_request event.
|
||||
# default: ${{ github.ref }}
|
||||
ref:
|
||||
|
||||
# How many commits are initially fetched from the base branch.
|
||||
# If needed, each subsequent fetch doubles the
|
||||
# previously requested number of commits until the merge-base
|
||||
# is found, or there are no more commits in the history.
|
||||
# This option takes effect only when changes are detected
|
||||
# using git against base branch (feature branch workflow).
|
||||
# Default: 100
|
||||
initial-fetch-depth: ''
|
||||
|
||||
# Enables listing of files matching the filter:
|
||||
# 'none' - Disables listing of matching files (default).
|
||||
# 'csv' - Coma separated list of filenames.
|
||||
# If needed, it uses double quotes to wrap filename with unsafe characters.
|
||||
# 'json' - File paths are formatted 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.
|
||||
# Default: none
|
||||
list-files: ''
|
||||
|
||||
# Relative path under $GITHUB_WORKSPACE where the repository was checked out.
|
||||
working-directory: ''
|
||||
|
||||
# Personal access token used to fetch a list of changed files
|
||||
# from GitHub REST API.
|
||||
# It's only used if action is triggered by a pull request event.
|
||||
# GitHub token from workflow context is used as default value.
|
||||
# If an empty string is provided, the action falls back to detect
|
||||
# changes using git commands.
|
||||
# Default: ${{ github.token }}
|
||||
token: ''
|
||||
```
|
||||
|
||||
## Outputs
|
||||
|
||||
- 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 an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
|
||||
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter.
|
||||
- `changes` - JSON array with names of all filters matching any of the changed files.
|
||||
|
||||
## Examples
|
||||
|
||||
### Conditional execution
|
||||
|
||||
<details>
|
||||
<summary>Execute <b>step</b> in a workflow job only if some file in a subfolder is changed</summary>
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: dorny/pr-changed-files-filter@v1
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
githubToken: ${{ github.token }}
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**/*'
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**/*'
|
||||
- 'frontend/**'
|
||||
|
||||
# run only if 'backend' files were changed
|
||||
- name: backend unit tests
|
||||
- name: backend tests
|
||||
if: steps.filter.outputs.backend == 'true'
|
||||
run: ...
|
||||
|
||||
# run only if 'frontend' files were changed
|
||||
- name: frontend unit tests
|
||||
- name: frontend tests
|
||||
if: steps.filter.outputs.frontend == 'true'
|
||||
run: ...
|
||||
|
||||
@@ -78,24 +201,324 @@ jobs:
|
||||
run: ...
|
||||
```
|
||||
|
||||
## How it works
|
||||
</details>
|
||||
|
||||
1. Required inputs are checked (`githubToken` & `filters`)
|
||||
2. Provided access token is used to fetch list of changed files.
|
||||
3. For each filter rule it checks if there is any matching file
|
||||
4. Output variables are set
|
||||
<details>
|
||||
<summary>Execute <b>job</b> in a workflow only if some file in a subfolder is changed</summary>
|
||||
|
||||
## Difference from related projects:
|
||||
```yml
|
||||
jobs:
|
||||
# JOB to run change detection
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# 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@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
|
||||
- [Has Changed Path](https://github.com/MarceloPrado/has-changed-path)
|
||||
- detects changes from previous commit
|
||||
- you have to configure `checkout` action to fetch some number of previous commits
|
||||
- `git diff` is used for change detection
|
||||
- outputs only single `true` / `false` value if any of provided paths contains changes
|
||||
- [Changed Files Exporter](https://github.com/futuratrepadeira/changed-files)
|
||||
- outputs lists with paths of created, updated and deleted files
|
||||
- output is not directly usable in the `if` clause
|
||||
- [Changed File Filter](https://github.com/tony84727/changed-file-filter)
|
||||
- allows change detection between any refs or commits
|
||||
- fetches whole history of your git repository
|
||||
- might have negative performance impact on big repositories (github by default fetches only single commit)
|
||||
# JOB to build and test backend code
|
||||
backend:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.backend == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- ...
|
||||
|
||||
# JOB to build and test frontend code
|
||||
frontend:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- ...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Use change detection to configure matrix job</summary>
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
# JOB to run change detection
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
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@v3
|
||||
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@v4
|
||||
- ...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Change detection workflows
|
||||
|
||||
<details>
|
||||
<summary><b>Pull requests:</b> Detect changes against PR base branch</summary>
|
||||
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
branches: # PRs to the following branches will trigger the workflow
|
||||
- master
|
||||
- develop
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: ... # Configure your filters
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Feature branch:</b> Detect changes against configured base branch</summary>
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: # Push to following branches will trigger the workflow
|
||||
- feature/**
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# This may save additional git fetch roundtrip if
|
||||
# merge-base is found within latest 20 commits
|
||||
fetch-depth: 20
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
base: develop # Change detection against merge-base with this branch
|
||||
filters: ... # Configure your filters
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Long lived branches:</b> Detect changes against the most recent commit on the same branch before the push</summary>
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: # Push to the following branches will trigger the workflow
|
||||
- master
|
||||
- develop
|
||||
- release/**
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# Use context to get the branch where commits were pushed.
|
||||
# If there is only one long-lived branch (e.g. master),
|
||||
# you can specify it directly.
|
||||
# If it's not configured, the repository default branch is used.
|
||||
base: ${{ github.ref }}
|
||||
filters: ... # Configure your filters
|
||||
```
|
||||
|
||||
</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@v4
|
||||
|
||||
# Some action that 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@v3
|
||||
id: filter
|
||||
with:
|
||||
base: HEAD
|
||||
filters: ... # Configure your filters
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Advanced options
|
||||
|
||||
<details>
|
||||
<summary>Define filter rules in own file</summary>
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# Path to file where filters are defined
|
||||
filters: .github/filters.yaml
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Use YAML anchors to reuse path expression(s) inside another rule</summary>
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# &shared is YAML anchor,
|
||||
# *shared references previously defined anchor
|
||||
# src filter will match any path under common, config and src folders
|
||||
filters: |
|
||||
shared: &shared
|
||||
- common/**
|
||||
- config/**
|
||||
src:
|
||||
- *shared
|
||||
- src/**
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Consider if file was added, modified or deleted</summary>
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# Changed file can be 'added', 'modified', or 'deleted'.
|
||||
# By default, the type of change is not considered.
|
||||
# Optionally, it's possible to specify it using nested
|
||||
# dictionary, where the type of change composes the key.
|
||||
# Multiple change types can be specified using `|` as the delimiter.
|
||||
filters: |
|
||||
shared: &shared
|
||||
- common/**
|
||||
- config/**
|
||||
addedOrModified:
|
||||
- added|modified: '**'
|
||||
allChanges:
|
||||
- added|deleted|modified: '**'
|
||||
addedOrModifiedAnchors:
|
||||
- added|modified: *shared
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Custom processing of changed files
|
||||
|
||||
<details>
|
||||
<summary>Passing list of modified files as command line args in Linux shell</summary>
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# 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
|
||||
list-files: shell
|
||||
|
||||
# In this example changed files will be checked by linter.
|
||||
# It doesn't make sense to lint deleted files.
|
||||
# Therefore we specify we are only interested in added or modified files.
|
||||
filters: |
|
||||
markdown:
|
||||
- added|modified: '*.md'
|
||||
- name: Lint Markdown
|
||||
if: ${{ steps.filter.outputs.markdown == 'true' }}
|
||||
run: npx textlint ${{ steps.filter.outputs.markdown_files }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Passing list of modified files as JSON array to another action</summary>
|
||||
|
||||
```yaml
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# Enable listing of files matching each filter.
|
||||
# Paths to files will be available in `${FILTER_NAME}_files` output variable.
|
||||
# Paths will be formatted as JSON array
|
||||
list-files: json
|
||||
|
||||
# In this example all changed files are passed to the following action to do
|
||||
# some custom processing.
|
||||
filters: |
|
||||
changed:
|
||||
- '**'
|
||||
- name: Lint Markdown
|
||||
uses: johndoe/some-action@v1
|
||||
with:
|
||||
files: ${{ steps.filter.outputs.changed_files }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## See also
|
||||
|
||||
- [test-reporter](https://github.com/dorny/test-reporter) - Displays test results from popular testing frameworks directly in GitHub
|
||||
|
||||
## License
|
||||
|
||||
The scripts and documentation in this project are released under the [MIT License](https://github.com/dorny/paths-filter/blob/master/LICENSE)
|
||||
|
||||
23
__tests__/csv-escape.test.ts
Normal file
23
__tests__/csv-escape.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {csvEscape} from '../src/list-format/csv-escape'
|
||||
|
||||
describe('csvEscape() backslash escapes every character except subset of definitely safe characters', () => {
|
||||
test('simple filename should not be modified', () => {
|
||||
expect(csvEscape('file.txt')).toBe('file.txt')
|
||||
})
|
||||
|
||||
test('directory separator should be preserved and not escaped', () => {
|
||||
expect(csvEscape('path/to/file.txt')).toBe('path/to/file.txt')
|
||||
})
|
||||
|
||||
test('filename with spaces should be quoted', () => {
|
||||
expect(csvEscape('file with space')).toBe('"file with space"')
|
||||
})
|
||||
|
||||
test('filename with "," should be quoted', () => {
|
||||
expect(csvEscape('file, with coma')).toBe('"file, with coma"')
|
||||
})
|
||||
|
||||
test('Double quote should be escaped by another double quote', () => {
|
||||
expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
|
||||
})
|
||||
})
|
||||
188
__tests__/filter.test.ts
Normal file
188
__tests__/filter.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {Filter} from '../src/filter'
|
||||
import {File, ChangeStatus} from '../src/file'
|
||||
|
||||
describe('yaml filter parsing tests', () => {
|
||||
test('throws if yaml is not a dictionary', () => {
|
||||
const yaml = 'not a dictionary'
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
test('throws if pattern is not a string', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
- dict:
|
||||
some: value
|
||||
`
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching tests', () => {
|
||||
test('matches single inline rule', () => {
|
||||
const yaml = `
|
||||
src: "src/**/*.js"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const files = modified(['src/app/module/file.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.src).toEqual(files)
|
||||
})
|
||||
test('matches single rule in single group', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['src/app/module/file.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.src).toEqual(files)
|
||||
})
|
||||
|
||||
test('no match when file is in different folder', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(modified(['not_src/other_file.js']))
|
||||
expect(match.src).toEqual([])
|
||||
})
|
||||
|
||||
test('match only within second groups ', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
test:
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['test/test.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.src).toEqual([])
|
||||
expect(match.test).toEqual(files)
|
||||
})
|
||||
|
||||
test('match only withing second rule of single group', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['test/test.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.src).toEqual(files)
|
||||
})
|
||||
|
||||
test('matches anything', () => {
|
||||
const yaml = `
|
||||
any:
|
||||
- "**"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['test/test.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.any).toEqual(files)
|
||||
})
|
||||
|
||||
test('globbing matches path where file or folder name starts with dot', () => {
|
||||
const yaml = `
|
||||
dot:
|
||||
- "**/*.js"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['.test/.test.js'])
|
||||
const match = filter.match(files)
|
||||
expect(match.dot).toEqual(files)
|
||||
})
|
||||
|
||||
test('matches all except tsx and less files (negate a group with or-ed parts)', () => {
|
||||
const yaml = `
|
||||
backend:
|
||||
- '!(**/*.tsx|**/*.less)'
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const tsxFiles = modified(['src/ui.tsx'])
|
||||
const lessFiles = modified(['src/ui.less'])
|
||||
const pyFiles = modified(['src/server.py'])
|
||||
|
||||
const tsxMatch = filter.match(tsxFiles)
|
||||
const lessMatch = filter.match(lessFiles)
|
||||
const pyMatch = filter.match(pyFiles)
|
||||
|
||||
expect(tsxMatch.backend).toEqual([])
|
||||
expect(lessMatch.backend).toEqual([])
|
||||
expect(pyMatch.backend).toEqual(pyFiles)
|
||||
})
|
||||
|
||||
test('matches path based on rules included using YAML anchor', () => {
|
||||
const yaml = `
|
||||
shared: &shared
|
||||
- common/**/*
|
||||
- config/**/*
|
||||
src:
|
||||
- *shared
|
||||
- src/**/*
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const files = modified(['config/settings.yml'])
|
||||
const match = filter.match(files)
|
||||
expect(match.src).toEqual(files)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching specific change status', () => {
|
||||
test('does not match modified file as added', () => {
|
||||
const yaml = `
|
||||
add:
|
||||
- added: "**/*"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const match = filter.match(modified(['file.js']))
|
||||
expect(match.add).toEqual([])
|
||||
})
|
||||
|
||||
test('match added file as added', () => {
|
||||
const yaml = `
|
||||
add:
|
||||
- added: "**/*"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const files = [{status: ChangeStatus.Added, filename: 'file.js'}]
|
||||
const match = filter.match(files)
|
||||
expect(match.add).toEqual(files)
|
||||
})
|
||||
|
||||
test('matches when multiple statuses are configured', () => {
|
||||
const yaml = `
|
||||
addOrModify:
|
||||
- added|modified: "**/*"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const files = [{status: ChangeStatus.Modified, filename: 'file.js'}]
|
||||
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[] {
|
||||
return paths.map(filename => {
|
||||
return {filename, status: ChangeStatus.Modified}
|
||||
})
|
||||
}
|
||||
35
__tests__/git.test.ts
Normal file
35
__tests__/git.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as git from '../src/git'
|
||||
import {ChangeStatus} from '../src/file'
|
||||
|
||||
describe('parsing output of the git diff command', () => {
|
||||
test('parseGitDiffOutput returns files with correct change status', async () => {
|
||||
const files = git.parseGitDiffOutput(
|
||||
'A\u0000LICENSE\u0000' + 'M\u0000src/index.ts\u0000' + 'D\u0000src/main.ts\u0000'
|
||||
)
|
||||
expect(files.length).toBe(3)
|
||||
expect(files[0].filename).toBe('LICENSE')
|
||||
expect(files[0].status).toBe(ChangeStatus.Added)
|
||||
expect(files[1].filename).toBe('src/index.ts')
|
||||
expect(files[1].status).toBe(ChangeStatus.Modified)
|
||||
expect(files[2].filename).toBe('src/main.ts')
|
||||
expect(files[2].status).toBe(ChangeStatus.Deleted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('git utility function tests (those not invoking git)', () => {
|
||||
test('Trims "refs/" and "heads/" from ref', () => {
|
||||
expect(git.getShortName('refs/heads/master')).toBe('master')
|
||||
expect(git.getShortName('heads/master')).toBe('heads/master')
|
||||
expect(git.getShortName('master')).toBe('master')
|
||||
|
||||
expect(git.getShortName('refs/tags/v1')).toBe('v1')
|
||||
expect(git.getShortName('tags/v1')).toBe('tags/v1')
|
||||
expect(git.getShortName('v1')).toBe('v1')
|
||||
})
|
||||
|
||||
test('isGitSha(ref) returns true only for 40 characters of a-z and 0-9', () => {
|
||||
expect(git.isGitSha('8b399ed1681b9efd6b1e048ca1c5cba47edf3855')).toBeTruthy()
|
||||
expect(git.isGitSha('This_is_very_long_name_for_a_branch_1111')).toBeFalsy()
|
||||
expect(git.isGitSha('master')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import Filter from '../src/filter'
|
||||
|
||||
describe('yaml filter parsing tests', () => {
|
||||
test('throws if yaml is not a dictionary', () => {
|
||||
const yaml = 'not a dictionary'
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
test('throws on invalid yaml', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
src/**/*.js
|
||||
`
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
test('throws if pattern is not a string', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
- dict:
|
||||
some: value
|
||||
`
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching tests', () => {
|
||||
test('matches single rule in single group', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['src/app/module/file.js'])
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
|
||||
test('no match when file is in different folder', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['not_src/other_file.js'])
|
||||
expect(match.src).toBeFalsy()
|
||||
})
|
||||
|
||||
test('match only within second groups ', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
test:
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
expect(match.src).toBeFalsy()
|
||||
expect(match.test).toBeTruthy()
|
||||
})
|
||||
|
||||
test('match only withing second rule of single group', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
|
||||
test('matches anything', () => {
|
||||
const yaml = `
|
||||
any:
|
||||
- "**/*"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
expect(match.any).toBeTruthy()
|
||||
})
|
||||
|
||||
test('globbing matches path where file or folder name starts with dot', () => {
|
||||
const yaml = `
|
||||
dot:
|
||||
- "**/*.js"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['.test/.test.js'])
|
||||
expect(match.dot).toBeTruthy()
|
||||
})
|
||||
})
|
||||
57
__tests__/shell-escape.test.ts
Normal file
57
__tests__/shell-escape.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {backslashEscape, shellEscape} from '../src/list-format/shell-escape'
|
||||
|
||||
describe('escape() backslash escapes every character except subset of definitely safe characters', () => {
|
||||
test('simple filename should not be modified', () => {
|
||||
expect(backslashEscape('file.txt')).toBe('file.txt')
|
||||
})
|
||||
|
||||
test('directory separator should be preserved and not escaped', () => {
|
||||
expect(backslashEscape('path/to/file.txt')).toBe('path/to/file.txt')
|
||||
})
|
||||
|
||||
test('spaces should be escaped with backslash', () => {
|
||||
expect(backslashEscape('file with space')).toBe('file\\ with\\ space')
|
||||
})
|
||||
|
||||
test('quotes should be escaped with backslash', () => {
|
||||
expect(backslashEscape('file\'with quote"')).toBe('file\\\'with\\ quote\\"')
|
||||
})
|
||||
|
||||
test('$variables should be escaped', () => {
|
||||
expect(backslashEscape('$var')).toBe('\\$var')
|
||||
})
|
||||
})
|
||||
|
||||
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('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'")
|
||||
})
|
||||
})
|
||||
53
action.yml
53
action.yml
@@ -1,16 +1,55 @@
|
||||
name: 'Pull request changed files filter'
|
||||
description: 'Enables conditional execution of workflow job steps considering which files are modified by a pull request.'
|
||||
name: 'Paths Changes Filter'
|
||||
description: 'Execute your workflow steps only if relevant files are modified.'
|
||||
author: 'Michal Dorner <dorner.michal@gmail.com>'
|
||||
inputs:
|
||||
githubToken:
|
||||
token:
|
||||
description: 'GitHub Access Token'
|
||||
required: true
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
working-directory:
|
||||
description: 'Relative path under $GITHUB_WORKSPACE where the repository was checked out.'
|
||||
required: false
|
||||
ref:
|
||||
description: |
|
||||
Git reference (e.g. branch name) from which the changes will be detected.
|
||||
This option is ignored if action is triggered by pull_request event.
|
||||
required: false
|
||||
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: 'YAML dictionary where keys specifies rule names and values are lists of (globbing) file path patterns'
|
||||
description: 'Path to the configuration file or YAML string with filters definition'
|
||||
required: true
|
||||
list-files:
|
||||
description: |
|
||||
Enables listing of files matching the filter:
|
||||
'none' - Disables listing of matching files (default).
|
||||
'csv' - Coma separated list of filenames.
|
||||
If needed it uses double quotes to wrap filename with unsafe characters.
|
||||
'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: false
|
||||
default: none
|
||||
initial-fetch-depth:
|
||||
description: |
|
||||
How many commits are initially fetched from base branch.
|
||||
If needed, each subsequent fetch doubles the previously requested number of commits
|
||||
until the merge-base is found or there are no more commits in the history.
|
||||
This option takes effect only when changes are detected using git against different base branch.
|
||||
required: false
|
||||
default: '100'
|
||||
outputs:
|
||||
changes:
|
||||
description: JSON array with names of all filters matching any of changed files
|
||||
runs:
|
||||
using: 'node12'
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
branding:
|
||||
color: blue
|
||||
icon: filter
|
||||
icon: filter
|
||||
|
||||
65606
dist/index.js
vendored
65606
dist/index.js
vendored
File diff suppressed because one or more lines are too long
12427
package-lock.json
generated
12427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "pr-changed-files-filter",
|
||||
"name": "paths-filter",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"private": true,
|
||||
"description": "Enables conditional execution of workflow job steps considering which files are modified by a pull request.",
|
||||
"description": "Execute your workflow steps only if relevant files are modified.",
|
||||
"main": "lib/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -25,29 +28,28 @@
|
||||
"author": "YourNameOrOrganization",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.2.0",
|
||||
"@actions/github": "^2.2.0",
|
||||
"@octokit/webhooks": "^7.6.1",
|
||||
"minimatch": "^3.0.4"
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "6.0.0",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^24.0.23",
|
||||
"@types/js-yaml": "^3.12.4",
|
||||
"@types/minimatch": "^3.0.3",
|
||||
"@types/node": "^12.7.12",
|
||||
"@typescript-eslint/parser": "^2.8.0",
|
||||
"@zeit/ncc": "^0.22.2",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"eslint-plugin-jest": "^22.21.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-circus": "^24.9.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-jest": "^24.2.0",
|
||||
"typescript": "^3.6.4"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node"
|
||||
"@octokit/webhooks-types": "^7.3.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.11.6",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-github": "^4.10.1",
|
||||
"eslint-plugin-jest": "^27.6.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-circus": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
13
src/file.ts
Normal file
13
src/file.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface File {
|
||||
filename: string
|
||||
status: ChangeStatus
|
||||
}
|
||||
|
||||
export enum ChangeStatus {
|
||||
Added = 'added',
|
||||
Copied = 'copied',
|
||||
Deleted = 'deleted',
|
||||
Modified = 'modified',
|
||||
Renamed = 'renamed',
|
||||
Unmerged = 'unmerged'
|
||||
}
|
||||
121
src/filter.ts
121
src/filter.ts
@@ -1,42 +1,109 @@
|
||||
import * as jsyaml from 'js-yaml'
|
||||
import * as minimatch from 'minimatch'
|
||||
import picomatch from 'picomatch'
|
||||
import {File, ChangeStatus} from './file'
|
||||
|
||||
export default class Filter {
|
||||
rules: {[key: string]: minimatch.IMinimatch[]} = {}
|
||||
// Type definition of object we expect to load from YAML
|
||||
interface FilterYaml {
|
||||
[name: string]: FilterItemYaml
|
||||
}
|
||||
type FilterItemYaml =
|
||||
| string // Filename pattern, e.g. "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
|
||||
|
||||
constructor(yaml: string) {
|
||||
const doc = jsyaml.safeLoad(yaml)
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
// Minimatch options used in all matchers
|
||||
const MatchOptions = {
|
||||
dot: true
|
||||
}
|
||||
|
||||
const opts: minimatch.IOptions = {
|
||||
dot: true
|
||||
}
|
||||
// Internal representation of one item in named filter rule
|
||||
// Created as simplified form of data in FilterItemYaml
|
||||
interface FilterRuleItem {
|
||||
status?: ChangeStatus[] // Required change status of the matched files
|
||||
isMatch: (str: string) => boolean // Matches the filename
|
||||
}
|
||||
|
||||
for (const name of Object.keys(doc)) {
|
||||
const patterns = doc[name] as string[]
|
||||
if (!Array.isArray(patterns)) {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
if (!patterns.every(x => typeof x === 'string')) {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
this.rules[name] = patterns.map(x => new minimatch.Minimatch(x, opts))
|
||||
export interface FilterResults {
|
||||
[key: string]: File[]
|
||||
}
|
||||
|
||||
export class Filter {
|
||||
rules: {[key: string]: FilterRuleItem[]} = {}
|
||||
|
||||
// Creates instance of Filter and load rules from YAML if it's provided
|
||||
constructor(yaml?: string) {
|
||||
if (yaml) {
|
||||
this.load(yaml)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns dictionary with match result per rules group
|
||||
match(paths: string[]): {[key: string]: boolean} {
|
||||
const result: {[key: string]: boolean} = {}
|
||||
// Load rules from YAML string
|
||||
load(yaml: string): void {
|
||||
if (!yaml) {
|
||||
return
|
||||
}
|
||||
|
||||
const doc = jsyaml.load(yaml) as FilterYaml
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError('Root element is not an object')
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(doc)) {
|
||||
this.rules[key] = this.parseFilterItemYaml(item)
|
||||
}
|
||||
}
|
||||
|
||||
match(files: File[]): FilterResults {
|
||||
const result: FilterResults = {}
|
||||
for (const [key, patterns] of Object.entries(this.rules)) {
|
||||
const match = paths.some(fileName => patterns.some(rule => rule.match(fileName)))
|
||||
result[key] = match
|
||||
result[key] = files.filter(file => this.isMatch(file, patterns))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private throwInvalidFormatError(): never {
|
||||
throw new Error('Invalid filter YAML format: Expected dictionary of string arrays')
|
||||
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
|
||||
return patterns.some(
|
||||
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
|
||||
)
|
||||
}
|
||||
|
||||
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
|
||||
if (Array.isArray(item)) {
|
||||
return flat(item.map(i => this.parseFilterItemYaml(i)))
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
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' && !Array.isArray(pattern))) {
|
||||
this.throwInvalidFormatError(
|
||||
`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
|
||||
)
|
||||
}
|
||||
return {
|
||||
status: key
|
||||
.split('|')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.length > 0)
|
||||
.map(x => x.toLowerCase()) as ChangeStatus[],
|
||||
isMatch: picomatch(pattern, MatchOptions)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`)
|
||||
}
|
||||
|
||||
private throwInvalidFormatError(message: string): never {
|
||||
throw new Error(`Invalid filter YAML format: ${message}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new array with all sub-array elements concatenated
|
||||
// In future could be replaced by Array.prototype.flat (supported on Node.js 11+)
|
||||
function flat<T>(arr: T[][]): T[] {
|
||||
return arr.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
|
||||
271
src/git.ts
Normal file
271
src/git.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import {getExecOutput} from '@actions/exec'
|
||||
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`)
|
||||
let output = ''
|
||||
try {
|
||||
output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
|
||||
} finally {
|
||||
fixStdOutNullTermination()
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
return parseGitDiffOutput(output)
|
||||
}
|
||||
|
||||
export async function getChanges(base: string, head: string): Promise<File[]> {
|
||||
const baseRef = await ensureRefAvailable(base)
|
||||
const headRef = await ensureRefAvailable(head)
|
||||
|
||||
// Get differences between ref and HEAD
|
||||
core.startGroup(`Change detection ${base}..${head}`)
|
||||
let output = ''
|
||||
try {
|
||||
// Two dots '..' change detection - directly compares two versions
|
||||
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
|
||||
.stdout
|
||||
} finally {
|
||||
fixStdOutNullTermination()
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
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 getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
|
||||
} finally {
|
||||
fixStdOutNullTermination()
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
return parseGitDiffOutput(output)
|
||||
}
|
||||
|
||||
export async function getChangesSinceMergeBase(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
|
||||
let baseRef: string | undefined
|
||||
let headRef: string | undefined
|
||||
async function hasMergeBase(): Promise<boolean> {
|
||||
if (baseRef === undefined || headRef === undefined) {
|
||||
return false
|
||||
}
|
||||
return (await getExecOutput('git', ['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
|
||||
}
|
||||
|
||||
let noMergeBase = false
|
||||
core.startGroup(`Searching for merge-base ${base}...${head}`)
|
||||
try {
|
||||
baseRef = await getLocalRef(base)
|
||||
headRef = await getLocalRef(head)
|
||||
if (!(await hasMergeBase())) {
|
||||
await getExecOutput('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
|
||||
if (baseRef === undefined || headRef === undefined) {
|
||||
baseRef = baseRef ?? (await getLocalRef(base))
|
||||
headRef = headRef ?? (await getLocalRef(head))
|
||||
if (baseRef === undefined || headRef === undefined) {
|
||||
await getExecOutput('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], {
|
||||
ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it
|
||||
})
|
||||
baseRef = baseRef ?? (await getLocalRef(base))
|
||||
headRef = headRef ?? (await getLocalRef(head))
|
||||
if (baseRef === undefined) {
|
||||
throw new Error(
|
||||
`Could not determine what is ${base} - fetch works but it's not a branch, tag or commit SHA`
|
||||
)
|
||||
}
|
||||
if (headRef === undefined) {
|
||||
throw new Error(
|
||||
`Could not determine what is ${head} - fetch works but it's not a branch, tag or commit SHA`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let depth = initialFetchDepth
|
||||
let lastCommitCount = await getCommitCount()
|
||||
while (!(await hasMergeBase())) {
|
||||
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER)
|
||||
await getExecOutput('git', ['fetch', `--deepen=${depth}`, 'origin', base, head])
|
||||
const commitCount = await getCommitCount()
|
||||
if (commitCount === lastCommitCount) {
|
||||
core.info('No more commits were fetched')
|
||||
core.info('Last attempt will be to fetch full history')
|
||||
await getExecOutput('git', ['fetch'])
|
||||
if (!(await hasMergeBase())) {
|
||||
noMergeBase = true
|
||||
}
|
||||
break
|
||||
}
|
||||
lastCommitCount = commitCount
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
// Three dots '...' change detection - finds merge-base and compares against it
|
||||
let diffArg = `${baseRef}...${headRef}`
|
||||
if (noMergeBase) {
|
||||
core.warning('No merge base found - change detection will use direct <commit>..<commit> comparison')
|
||||
diffArg = `${baseRef}..${headRef}`
|
||||
}
|
||||
|
||||
// Get changes introduced on ref compared to base
|
||||
core.startGroup(`Change detection ${diffArg}`)
|
||||
let output = ''
|
||||
try {
|
||||
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
|
||||
} finally {
|
||||
fixStdOutNullTermination()
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
return parseGitDiffOutput(output)
|
||||
}
|
||||
|
||||
export function parseGitDiffOutput(output: string): File[] {
|
||||
const tokens = output.split('\u0000').filter(s => s.length > 0)
|
||||
const files: File[] = []
|
||||
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
||||
files.push({
|
||||
status: statusMap[tokens[i]],
|
||||
filename: tokens[i + 1]
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export async function listAllFilesAsAdded(): Promise<File[]> {
|
||||
core.startGroup('Listing all files tracked by git')
|
||||
let output = ''
|
||||
try {
|
||||
output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
|
||||
} finally {
|
||||
fixStdOutNullTermination()
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
return output
|
||||
.split('\u0000')
|
||||
.filter(s => s.length > 0)
|
||||
.map(path => ({
|
||||
status: ChangeStatus.Added,
|
||||
filename: path
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getCurrentRef(): Promise<string> {
|
||||
core.startGroup(`Get current git ref`)
|
||||
try {
|
||||
const branch = (await getExecOutput('git', ['branch', '--show-current'])).stdout.trim()
|
||||
if (branch) {
|
||||
return branch
|
||||
}
|
||||
|
||||
const describe = await getExecOutput('git', ['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
|
||||
if (describe.exitCode === 0) {
|
||||
return describe.stdout.trim()
|
||||
}
|
||||
|
||||
return (await getExecOutput('git', ['rev-parse', HEAD])).stdout.trim()
|
||||
} finally {
|
||||
core.endGroup()
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortName(ref: string): string {
|
||||
if (!ref) return ''
|
||||
|
||||
const heads = 'refs/heads/'
|
||||
const tags = 'refs/tags/'
|
||||
|
||||
if (ref.startsWith(heads)) return ref.slice(heads.length)
|
||||
if (ref.startsWith(tags)) return ref.slice(tags.length)
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
export function isGitSha(ref: string): boolean {
|
||||
return /^[a-z0-9]{40}$/.test(ref)
|
||||
}
|
||||
|
||||
async function hasCommit(ref: string): Promise<boolean> {
|
||||
return (await getExecOutput('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
|
||||
}
|
||||
|
||||
async function getCommitCount(): Promise<number> {
|
||||
const output = (await getExecOutput('git', ['rev-list', '--count', '--all'])).stdout
|
||||
const count = parseInt(output)
|
||||
return isNaN(count) ? 0 : count
|
||||
}
|
||||
|
||||
async function getLocalRef(shortName: string): Promise<string | undefined> {
|
||||
if (isGitSha(shortName)) {
|
||||
return (await hasCommit(shortName)) ? shortName : undefined
|
||||
}
|
||||
|
||||
const output = (await getExecOutput('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout
|
||||
const refs = output
|
||||
.split(/\r?\n/g)
|
||||
.map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/))
|
||||
.filter(match => match !== null && match[1] === shortName)
|
||||
.map(match => match?.[0] ?? '') // match can't be null here but compiler doesn't understand that
|
||||
|
||||
if (refs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const remoteRef = refs.find(ref => ref.startsWith('refs/remotes/origin/'))
|
||||
if (remoteRef) {
|
||||
return remoteRef
|
||||
}
|
||||
|
||||
return refs[0]
|
||||
}
|
||||
|
||||
async function ensureRefAvailable(name: string): Promise<string> {
|
||||
core.startGroup(`Ensuring ${name} is fetched from origin`)
|
||||
try {
|
||||
let ref = await getLocalRef(name)
|
||||
if (ref === undefined) {
|
||||
await getExecOutput('git', ['fetch', '--depth=1', '--no-tags', 'origin', name])
|
||||
ref = await getLocalRef(name)
|
||||
if (ref === undefined) {
|
||||
await getExecOutput('git', ['fetch', '--depth=1', '--tags', 'origin', name])
|
||||
ref = await getLocalRef(name)
|
||||
if (ref === undefined) {
|
||||
throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ref
|
||||
} finally {
|
||||
core.endGroup()
|
||||
}
|
||||
}
|
||||
|
||||
function fixStdOutNullTermination(): void {
|
||||
// Previous command uses NULL as delimiters and output is printed to stdout.
|
||||
// We have to make sure next thing written to stdout will start on new line.
|
||||
// Otherwise things like ::set-output wouldn't work.
|
||||
core.info('')
|
||||
}
|
||||
|
||||
const statusMap: {[char: string]: ChangeStatus} = {
|
||||
A: ChangeStatus.Added,
|
||||
C: ChangeStatus.Copied,
|
||||
D: ChangeStatus.Deleted,
|
||||
M: ChangeStatus.Modified,
|
||||
R: ChangeStatus.Renamed,
|
||||
U: ChangeStatus.Unmerged
|
||||
}
|
||||
16
src/list-format/csv-escape.ts
Normal file
16
src/list-format/csv-escape.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Returns filename escaped for CSV
|
||||
// Wraps file name into "..." only when it contains some potentially unsafe character
|
||||
export function csvEscape(value: string): string {
|
||||
if (value === '') return value
|
||||
|
||||
// Only safe characters
|
||||
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc4180
|
||||
// If double-quotes are used to enclose fields, then a double-quote
|
||||
// appearing inside a field must be escaped by preceding it with
|
||||
// another double quote
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
28
src/list-format/shell-escape.ts
Normal file
28
src/list-format/shell-escape.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Backslash escape every character except small subset of definitely safe characters
|
||||
export function backslashEscape(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}'`
|
||||
}
|
||||
283
src/main.ts
283
src/main.ts
@@ -1,54 +1,271 @@
|
||||
import * as fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import {Webhooks} from '@octokit/webhooks'
|
||||
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
|
||||
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
|
||||
|
||||
import Filter from './filter'
|
||||
import {Filter, FilterResults} from './filter'
|
||||
import {File, ChangeStatus} from './file'
|
||||
import * as git from './git'
|
||||
import {backslashEscape, shellEscape} from './list-format/shell-escape'
|
||||
import {csvEscape} from './list-format/csv-escape'
|
||||
|
||||
type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const token = core.getInput('githubToken', {required: true})
|
||||
const filterYaml = core.getInput('filters', {required: true})
|
||||
const client = new github.GitHub(token)
|
||||
const workingDirectory = core.getInput('working-directory', {required: false})
|
||||
if (workingDirectory) {
|
||||
process.chdir(workingDirectory)
|
||||
}
|
||||
|
||||
if (github.context.eventName !== 'pull_request') {
|
||||
core.setFailed('This action can be triggered only by pull_request event')
|
||||
const token = core.getInput('token', {required: false})
|
||||
const ref = core.getInput('ref', {required: false})
|
||||
const base = core.getInput('base', {required: false})
|
||||
const filtersInput = core.getInput('filters', {required: true})
|
||||
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
|
||||
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
|
||||
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
|
||||
|
||||
if (!isExportFormat(listFiles)) {
|
||||
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
|
||||
return
|
||||
}
|
||||
|
||||
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
|
||||
const filter = new Filter(filterYaml)
|
||||
const files = await getChangedFiles(client, pr)
|
||||
|
||||
const result = filter.match(files)
|
||||
for (const key in result) {
|
||||
core.setOutput(key, String(result[key]))
|
||||
}
|
||||
const filter = new Filter(filtersYaml)
|
||||
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
|
||||
core.info(`Detected ${files.length} changed files`)
|
||||
const results = filter.match(files)
|
||||
exportResults(results, listFiles)
|
||||
} catch (error) {
|
||||
core.setFailed(error.message)
|
||||
core.setFailed(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInput(text: string): boolean {
|
||||
return !(text.includes('\n') || text.includes(':'))
|
||||
}
|
||||
|
||||
function getConfigFileContent(configPath: string): string {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(`Configuration file '${configPath}' not found`)
|
||||
}
|
||||
|
||||
if (!fs.lstatSync(configPath).isFile()) {
|
||||
throw new Error(`'${configPath}' is not a file.`)
|
||||
}
|
||||
|
||||
return fs.readFileSync(configPath, {encoding: 'utf8'})
|
||||
}
|
||||
|
||||
async function getChangedFiles(token: string, base: string, ref: 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) {
|
||||
if (ref) {
|
||||
core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
|
||||
}
|
||||
return await git.getChangesOnHead()
|
||||
}
|
||||
|
||||
const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target']
|
||||
if (prEvents.includes(github.context.eventName)) {
|
||||
if (ref) {
|
||||
core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
|
||||
}
|
||||
if (base) {
|
||||
core.warning(`'base' input parameter is ignored when action is triggered by pull request event`)
|
||||
}
|
||||
const pr = github.context.payload.pull_request as PullRequestEvent
|
||||
if (token) {
|
||||
return await getChangedFilesFromApi(token, pr)
|
||||
}
|
||||
if (github.context.eventName === 'pull_request_target') {
|
||||
// pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
|
||||
// Therefor it's not possible to look at changes in last commit
|
||||
// At the same time we don't want to fetch any code from forked repository
|
||||
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
|
||||
}
|
||||
core.info('Github token is not available - changes will be detected from PRs merge commit')
|
||||
return await git.getChangesInLastCommit()
|
||||
} else {
|
||||
return getChangedFilesFromGit(base, ref, initialFetchDepth)
|
||||
}
|
||||
}
|
||||
|
||||
async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
|
||||
const defaultBranch = github.context.payload.repository?.default_branch
|
||||
|
||||
const beforeSha = github.context.eventName === 'push' ? (github.context.payload as PushEvent).before : null
|
||||
|
||||
const currentRef = await git.getCurrentRef()
|
||||
|
||||
head = git.getShortName(head || github.context.ref || currentRef)
|
||||
base = git.getShortName(base || defaultBranch)
|
||||
|
||||
if (!head) {
|
||||
throw new Error(
|
||||
"This action requires 'head' input to be configured, 'ref' to be set in the event payload or branch/tag checked out in current git repository"
|
||||
)
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload"
|
||||
)
|
||||
}
|
||||
|
||||
const isBaseSha = git.isGitSha(base)
|
||||
const isBaseSameAsHead = base === head
|
||||
|
||||
// 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 (isBaseSha || isBaseSameAsHead) {
|
||||
const baseSha = isBaseSha ? base : beforeSha
|
||||
if (!baseSha) {
|
||||
core.warning(`'before' field is missing in event payload - changes will be detected from last commit`)
|
||||
if (head !== currentRef) {
|
||||
core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
|
||||
}
|
||||
return await git.getChangesInLastCommit()
|
||||
}
|
||||
|
||||
// If there is no previously pushed commit,
|
||||
// we will do comparison against the default branch or return all as added
|
||||
if (baseSha === git.NULL_SHA) {
|
||||
if (defaultBranch && base !== defaultBranch) {
|
||||
core.info(
|
||||
`First push of a branch detected - changes will be detected against the default branch ${defaultBranch}`
|
||||
)
|
||||
return await git.getChangesSinceMergeBase(defaultBranch, head, initialFetchDepth)
|
||||
} else {
|
||||
core.info('Initial push detected - all files will be listed as added')
|
||||
if (head !== currentRef) {
|
||||
core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
|
||||
}
|
||||
return await git.listAllFilesAsAdded()
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Changes will be detected between ${baseSha} and ${head}`)
|
||||
return await git.getChanges(baseSha, head)
|
||||
}
|
||||
|
||||
core.info(`Changes will be detected between ${base} and ${head}`)
|
||||
return await git.getChangesSinceMergeBase(base, head, initialFetchDepth)
|
||||
}
|
||||
|
||||
// Uses github REST api to get list of files changed in PR
|
||||
async function getChangedFiles(
|
||||
client: github.GitHub,
|
||||
pullRequest: Webhooks.WebhookPayloadPullRequestPullRequest
|
||||
): Promise<string[]> {
|
||||
const pageSize = 100
|
||||
const files: string[] = []
|
||||
for (let page = 0; page * pageSize < pullRequest.changed_files; page++) {
|
||||
const response = await client.pulls.listFiles({
|
||||
owner: github.context.repo.owner,
|
||||
repo: github.context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
page,
|
||||
per_page: pageSize
|
||||
})
|
||||
for (const row of response.data) {
|
||||
files.push(row.filename)
|
||||
async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEvent): Promise<File[]> {
|
||||
core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`)
|
||||
try {
|
||||
const client = github.getOctokit(token)
|
||||
const per_page = 100
|
||||
const files: File[] = []
|
||||
|
||||
core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, per_page: ${per_page})`)
|
||||
for await (const response of client.paginate.iterator(
|
||||
client.rest.pulls.listFiles.endpoint.merge({
|
||||
owner: github.context.repo.owner,
|
||||
repo: github.context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page
|
||||
})
|
||||
)) {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Fetching list of changed files from GitHub API failed with error code ${response.status}`)
|
||||
}
|
||||
core.info(`Received ${response.data.length} items`)
|
||||
|
||||
for (const row of response.data as GetResponseDataTypeFromEndpointMethod<typeof client.rest.pulls.listFiles>) {
|
||||
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
|
||||
if (row.status === ChangeStatus.Renamed) {
|
||||
files.push({
|
||||
filename: row.filename,
|
||||
status: ChangeStatus.Added
|
||||
})
|
||||
files.push({
|
||||
// 'previous_filename' for some unknown reason isn't in the type definition or documentation
|
||||
filename: (<any>row).previous_filename as string,
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
} finally {
|
||||
core.endGroup()
|
||||
}
|
||||
}
|
||||
|
||||
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}]`)
|
||||
}
|
||||
} else {
|
||||
core.info('Matching files: none')
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
return files
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
function serializeExport(files: File[], format: ExportFormat): string {
|
||||
const fileNames = files.map(file => file.filename)
|
||||
switch (format) {
|
||||
case 'csv':
|
||||
return fileNames.map(csvEscape).join(',')
|
||||
case 'json':
|
||||
return JSON.stringify(fileNames)
|
||||
case 'escape':
|
||||
return fileNames.map(backslashEscape).join(' ')
|
||||
case 'shell':
|
||||
return fileNames.map(shellEscape).join(' ')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isExportFormat(value: string): value is ExportFormat {
|
||||
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
run()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
"outDir": "./lib", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
|
||||
Reference in New Issue
Block a user