├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .gitattributes
├── .github
├── filters.yml
└── workflows
│ ├── build.yml
│ └── pull-request-verification.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── .vscode
└── launch.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── __tests__
├── csv-escape.test.ts
├── filter.test.ts
├── git.test.ts
└── shell-escape.test.ts
├── action.yml
├── dist
└── index.js
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── file.ts
├── filter.ts
├── git.ts
├── list-format
│ ├── csv-escape.ts
│ └── shell-escape.ts
└── main.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | trim_trailing_whitespace = true
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
2 | lib/
3 | node_modules/
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["jest", "@typescript-eslint"],
3 | "extends": ["plugin:github/internal"],
4 | "parser": "@typescript-eslint/parser",
5 | "parserOptions": {
6 | "ecmaVersion": 9,
7 | "sourceType": "module",
8 | "project": "./tsconfig.json"
9 | },
10 | "rules": {
11 | "eslint-comments/no-use": "off",
12 | "import/no-namespace": "off",
13 | "no-unused-vars": "off",
14 | "@typescript-eslint/no-unused-vars": "error",
15 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
16 | "@typescript-eslint/no-require-imports": "error",
17 | "@typescript-eslint/array-type": "error",
18 | "@typescript-eslint/await-thenable": "error",
19 | "camelcase": "off",
20 | "@typescript-eslint/camelcase": "off",
21 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
22 | "@typescript-eslint/func-call-spacing": ["error", "never"],
23 | "@typescript-eslint/no-array-constructor": "error",
24 | "@typescript-eslint/no-empty-interface": "error",
25 | "@typescript-eslint/no-explicit-any": "off",
26 | "@typescript-eslint/no-extraneous-class": "error",
27 | "@typescript-eslint/no-for-in-array": "error",
28 | "@typescript-eslint/no-inferrable-types": "error",
29 | "@typescript-eslint/no-misused-new": "error",
30 | "@typescript-eslint/no-namespace": "error",
31 | "@typescript-eslint/no-non-null-assertion": "warn",
32 | "@typescript-eslint/no-unnecessary-qualifier": "error",
33 | "@typescript-eslint/no-unnecessary-type-assertion": "error",
34 | "@typescript-eslint/no-useless-constructor": "error",
35 | "@typescript-eslint/no-var-requires": "error",
36 | "@typescript-eslint/prefer-for-of": "warn",
37 | "@typescript-eslint/prefer-function-type": "warn",
38 | "@typescript-eslint/prefer-includes": "error",
39 | "@typescript-eslint/prefer-string-starts-ends-with": "error",
40 | "@typescript-eslint/promise-function-async": ["error", { "allowAny": true }],
41 | "@typescript-eslint/require-array-sort-compare": "error",
42 | "@typescript-eslint/restrict-plus-operands": "error",
43 | "semi": "off",
44 | "@typescript-eslint/semi": ["error", "never"],
45 | "@typescript-eslint/type-annotation-spacing": "error",
46 | "@typescript-eslint/unbound-method": "error"
47 | },
48 | "env": {
49 | "node": true,
50 | "es6": true,
51 | "jest/globals": true
52 | }
53 | }
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
--------------------------------------------------------------------------------
/.github/filters.yml:
--------------------------------------------------------------------------------
1 | error:
2 | - not_existing_path/**/*
3 | any:
4 | - "**/*"
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: "Build"
2 |
3 | on:
4 | push:
5 | paths-ignore: [ '*.md' ]
6 | branches:
7 | - master
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-node@v4
15 | with:
16 | node-version: 20
17 | cache: 'npm'
18 | - run: |
19 | npm install
20 | npm run all
21 |
22 | self-test:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: ./
27 | id: filter
28 | with:
29 | filters: '.github/filters.yml'
30 | - name: filter-test
31 | if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
32 | run: exit 1
33 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request-verification.yml:
--------------------------------------------------------------------------------
1 | name: "Pull Request Verification"
2 | on:
3 | pull_request:
4 | paths-ignore: [ '*.md' ]
5 | branches:
6 | - master
7 | - '**'
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-node@v4
15 | with:
16 | node-version: 20
17 | cache: 'npm'
18 | - run: |
19 | npm install
20 | npm run all
21 |
22 | test-inline:
23 | runs-on: ubuntu-latest
24 | permissions:
25 | pull-requests: read
26 | steps:
27 | - uses: actions/checkout@v4
28 | - uses: ./
29 | id: filter
30 | with:
31 | filters: |
32 | error:
33 | - not_existing_path/**/*
34 | any:
35 | - "**/*"
36 | - name: filter-test
37 | if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
38 | run: exit 1
39 | - name: changes-test
40 | if: contains(fromJSON(steps.filter.outputs.changes), 'error') || !contains(fromJSON(steps.filter.outputs.changes), 'any')
41 | run: exit 1
42 |
43 | test-external:
44 | runs-on: ubuntu-latest
45 | permissions:
46 | pull-requests: read
47 | steps:
48 | - uses: actions/checkout@v4
49 | - uses: ./
50 | id: filter
51 | with:
52 | filters: '.github/filters.yml'
53 | - name: filter-test
54 | if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
55 | run: exit 1
56 |
57 | test-without-token:
58 | runs-on: ubuntu-latest
59 | steps:
60 | - uses: actions/checkout@v4
61 | - uses: ./
62 | id: filter
63 | with:
64 | token: ''
65 | filters: '.github/filters.yml'
66 | - name: filter-test
67 | if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
68 | run: exit 1
69 |
70 | test-wd-without-token:
71 | runs-on: ubuntu-latest
72 | steps:
73 | - uses: actions/checkout@v4
74 | with:
75 | path: somewhere
76 | - uses: ./somewhere
77 | id: filter
78 | with:
79 | token: ''
80 | working-directory: somewhere
81 | filters: '.github/filters.yml'
82 | - name: filter-test
83 | if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
84 | run: exit 1
85 |
86 | test-local-changes:
87 | runs-on: ubuntu-latest
88 | steps:
89 | - uses: actions/checkout@v4
90 | - run: echo "NEW FILE" > local
91 | - run: git add local
92 | - uses: ./
93 | id: filter
94 | with:
95 | base: HEAD
96 | filters: |
97 | local:
98 | - local
99 | - name: filter-test
100 | if: steps.filter.outputs.local != 'true'
101 | run: exit 1
102 | - name: count-test
103 | if: steps.filter.outputs.local_count != 1
104 | run: exit 1
105 |
106 | test-change-type:
107 | runs-on: ubuntu-latest
108 | steps:
109 | - uses: actions/checkout@v4
110 | - name: configure GIT user
111 | run: git config user.email "john@nowhere.local" && git config user.name "John Doe"
112 | - name: modify working tree
113 | run: touch add.txt && rm README.md && echo "TEST" > LICENSE
114 | - name: commit changes
115 | run: git add -A && git commit -a -m 'testing this action'
116 | - uses: ./
117 | id: filter
118 | with:
119 | token: ''
120 | list-files: shell
121 | filters: |
122 | added:
123 | - added: "add.txt"
124 | deleted:
125 | - deleted: "README.md"
126 | modified:
127 | - modified: "LICENSE"
128 | any:
129 | - added|deleted|modified: "*"
130 | - name: Print 'added_files'
131 | run: echo ${{steps.filter.outputs.added_files}}
132 | - name: Print 'modified_files'
133 | run: echo ${{steps.filter.outputs.modified_files}}
134 | - name: Print 'deleted_files'
135 | run: echo ${{steps.filter.outputs.deleted_files}}
136 | - name: filter-test
137 | if: |
138 | steps.filter.outputs.added != 'true'
139 | || steps.filter.outputs.deleted != 'true'
140 | || steps.filter.outputs.modified != 'true'
141 | || steps.filter.outputs.any != 'true'
142 | || steps.filter.outputs.added_files != 'add.txt'
143 | || steps.filter.outputs.modified_files != 'LICENSE'
144 | || steps.filter.outputs.deleted_files != 'README.md'
145 | run: exit 1
146 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | node_modules
3 |
4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
15 |
16 | # Runtime data
17 | pids
18 | *.pid
19 | *.seed
20 | *.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 | lib-cov
24 |
25 | # Coverage directory used by tools like istanbul
26 | coverage
27 | *.lcov
28 |
29 | # nyc test coverage
30 | .nyc_output
31 |
32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
33 | .grunt
34 |
35 | # Bower dependency directory (https://bower.io/)
36 | bower_components
37 |
38 | # node-waf configuration
39 | .lock-wscript
40 |
41 | # Compiled binary addons (https://nodejs.org/api/addons.html)
42 | build/Release
43 |
44 | # Dependency directories
45 | jspm_packages/
46 |
47 | # TypeScript v1 declaration files
48 | typings/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional REPL history
60 | .node_repl_history
61 |
62 | # Output of 'npm pack'
63 | *.tgz
64 |
65 | # Yarn Integrity file
66 | .yarn-integrity
67 |
68 | # dotenv environment variables file
69 | .env
70 | .env.test
71 |
72 | # parcel-bundler cache (https://parceljs.org/)
73 | .cache
74 |
75 | # next.js build output
76 | .next
77 |
78 | # nuxt.js build output
79 | .nuxt
80 |
81 | # vuepress build output
82 | .vuepress/dist
83 |
84 | # Serverless directories
85 | .serverless/
86 |
87 | # FuseBox cache
88 | .fusebox/
89 |
90 | # DynamoDB Local files
91 | .dynamodb/
92 |
93 | # OS metadata
94 | .DS_Store
95 | Thumbs.db
96 |
97 | # Ignore built ts files
98 | __tests__/runner/*
99 | lib/**/*
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | lib/
3 | node_modules/
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": false,
9 | "arrowParens": "avoid",
10 | "parser": "typescript"
11 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "type": "node",
6 | "request": "launch",
7 | "name": "Jest All",
8 | "program": "${workspaceFolder}/node_modules/.bin/jest",
9 | "args": ["--runInBand"],
10 | "console": "integratedTerminal",
11 | "internalConsoleOptions": "neverOpen",
12 | "disableOptimisticBPs": true,
13 | "windows": {
14 | "program": "${workspaceFolder}/node_modules/jest/bin/jest",
15 | }
16 | },
17 | {
18 | "type": "node",
19 | "request": "launch",
20 | "name": "Jest Current File",
21 | "program": "${workspaceFolder}/node_modules/.bin/jest",
22 | "args": [
23 | "${fileBasenameNoExtension}",
24 | "--config",
25 | "jest.config.js"
26 | ],
27 | "console": "integratedTerminal",
28 | "internalConsoleOptions": "neverOpen",
29 | "disableOptimisticBPs": true,
30 | "windows": {
31 | "program": "${workspaceFolder}/node_modules/jest/bin/jest",
32 | }
33 | }
34 | ]
35 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v3.0.2
4 | - [Add config parameter for predicate quantifier](https://github.com/dorny/paths-filter/pull/224)
5 |
6 | ## v3.0.1
7 | - [Compare base and ref when token is empty](https://github.com/dorny/paths-filter/pull/133)
8 |
9 | ## v3.0.0
10 | - [Update to Node.js 20](https://github.com/dorny/paths-filter/pull/210)
11 | - [Update all dependencies](https://github.com/dorny/paths-filter/pull/215)
12 |
13 | ## v2.11.1
14 | - [Update @actions/core to v1.10.0 - Fixes warning about deprecated set-output](https://github.com/dorny/paths-filter/pull/167)
15 | - [Document need for pull-requests: read permission](https://github.com/dorny/paths-filter/pull/168)
16 | - [Updating to actions/checkout@v3](https://github.com/dorny/paths-filter/pull/164)
17 |
18 | ## v2.11.0
19 | - [Set list-files input parameter as not required](https://github.com/dorny/paths-filter/pull/157)
20 | - [Update Node.js](https://github.com/dorny/paths-filter/pull/161)
21 | - [Fix incorrect handling of Unicode characters in exec()](https://github.com/dorny/paths-filter/pull/162)
22 | - [Use Octokit pagination](https://github.com/dorny/paths-filter/pull/163)
23 | - [Updates real world links](https://github.com/dorny/paths-filter/pull/160)
24 |
25 | ## v2.10.2
26 | - [Fix getLocalRef() returns wrong ref](https://github.com/dorny/paths-filter/pull/91)
27 |
28 | ## v2.10.1
29 | - [Improve robustness of change detection](https://github.com/dorny/paths-filter/pull/85)
30 |
31 | ## v2.10.0
32 | - [Add ref input parameter](https://github.com/dorny/paths-filter/pull/82)
33 | - [Fix change detection in PR when pullRequest.changed_files is incorrect](https://github.com/dorny/paths-filter/pull/83)
34 |
35 | ## v2.9.3
36 | - [Fix change detection when base is a tag](https://github.com/dorny/paths-filter/pull/78)
37 |
38 | ## v2.9.2
39 | - [Fix fetching git history](https://github.com/dorny/paths-filter/pull/75)
40 |
41 | ## v2.9.1
42 | - [Fix fetching git history + fallback to unshallow repo](https://github.com/dorny/paths-filter/pull/74)
43 |
44 | ## v2.9.0
45 | - [Add list-files: csv format](https://github.com/dorny/paths-filter/pull/68)
46 |
47 | ## v2.8.0
48 | - [Add count output variable](https://github.com/dorny/paths-filter/pull/65)
49 | - [Fix log grouping of changes](https://github.com/dorny/paths-filter/pull/61)
50 |
51 | ## v2.7.0
52 | - [Add "changes" output variable to support matrix job configuration](https://github.com/dorny/paths-filter/pull/59)
53 | - [Improved listing of matching files with `list-files: shell` and `list-files: escape` options](https://github.com/dorny/paths-filter/pull/58)
54 |
55 | ## v2.6.0
56 | - [Support local changes](https://github.com/dorny/paths-filter/pull/53)
57 |
58 | ## v2.5.3
59 | - [Fixed mapping of removed/deleted change status from github API](https://github.com/dorny/paths-filter/pull/51)
60 | - [Fixed retrieval of all changes via Github API when there are 100+ changes](https://github.com/dorny/paths-filter/pull/50)
61 |
62 | ## v2.5.2
63 | - [Add support for multiple patterns when using file status](https://github.com/dorny/paths-filter/pull/48)
64 | - [Use picomatch directly instead of micromatch wrapper](https://github.com/dorny/paths-filter/pull/49)
65 |
66 | ## v2.5.1
67 | - [Improved path matching with micromatch](https://github.com/dorny/paths-filter/pull/46)
68 |
69 | ## v2.5.0
70 | - [Support workflows triggered by any event](https://github.com/dorny/paths-filter/pull/44)
71 |
72 | ## v2.4.2
73 | - [Fixed compatibility with older (<2.23) versions of git](https://github.com/dorny/paths-filter/pull/42)
74 |
75 | ## v2.4.0
76 | - [Support pushes of tags or when tag is used as base](https://github.com/dorny/paths-filter/pull/40)
77 | - [Use git log to detect changes from PRs merge commit if token is not available](https://github.com/dorny/paths-filter/pull/40)
78 | - [Support local execution with act](https://github.com/dorny/paths-filter/pull/40)
79 | - [Improved processing of repository initial push](https://github.com/dorny/paths-filter/pull/40)
80 | - [Improved processing of first push of new branch](https://github.com/dorny/paths-filter/pull/40)
81 |
82 |
83 | ## v2.3.0
84 | - [Improved documentation](https://github.com/dorny/paths-filter/pull/37)
85 | - [Change detection using git "three dot" diff](https://github.com/dorny/paths-filter/pull/35)
86 | - [Export files matching filter](https://github.com/dorny/paths-filter/pull/32)
87 | - [Extend filter syntax with optional specification of file status: add, modified, deleted](https://github.com/dorny/paths-filter/pull/22)
88 | - [Add working-directory input](https://github.com/dorny/paths-filter/pull/21)
89 |
90 | ## v2.2.1
91 | - [Add support for pull_request_target](https://github.com/dorny/paths-filter/pull/29)
92 |
93 | ## v2.2.0
94 | - [Improve change detection for feature branches](https://github.com/dorny/paths-filter/pull/16)
95 |
96 | ## v2.1.0
97 | - [Support reusable paths blocks with yaml anchors](https://github.com/dorny/paths-filter/pull/13)
98 |
99 | ## v2.0.0
100 | - [Added support for workflows triggered by push events](https://github.com/dorny/paths-filter/pull/10)
101 | - Action and repository renamed to paths-filter - original name doesn't make sense anymore
102 |
103 | ## v1.1.0
104 | - [Allows filters to be specified in own .yml file](https://github.com/dorny/paths-filter/pull/8)
105 | - [Adds alternative change detection using git fetch and git diff-index](https://github.com/dorny/paths-filter/pull/9)
106 |
107 | ## v1.0.1
108 | Updated dependencies - fixes github security alert
109 |
110 | ## v1.0.0
111 | First official release uploaded to marketplace.
112 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2020 Michal Dorner and contributors
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Paths Changes Filter
2 |
3 | [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
4 | branch, or by the recently pushed commits.
5 |
6 | Run slow tasks like integration tests or deployments only for changed components. It saves time and resources, especially in monorepo setups.
7 | GitHub workflows built-in [path filters](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths)
8 | don't allow this because they don't work on a level of individual jobs or steps.
9 |
10 | **Real world usage examples:**
11 |
12 | - [sentry.io](https://sentry.io/) - [backend.yml](https://github.com/getsentry/sentry/blob/2ebe01feab863d89aa7564e6d243b6d80c230ddc/.github/workflows/backend.yml#L36)
13 | - [GoogleChrome/web.dev](https://web.dev/) - [lint-workflow.yml](https://github.com/GoogleChrome/web.dev/blob/3a57b721e7df6fc52172f676ca68d16153bda6a3/.github/workflows/lint-workflow.yml#L26)
14 | - [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)
15 |
16 | ## Supported workflows
17 |
18 | - **Pull requests:**
19 | - Workflow triggered by **[pull_request](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request)**
20 | or **[pull_request_target](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target)** event
21 | - Changes are detected against the pull request base branch
22 | - Uses GitHub REST API to fetch a list of modified files
23 | - Requires [pull-requests: read](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs) permission
24 | - **Feature branches:**
25 | - Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)**
26 | or any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
27 | - The `base` input parameter must not be the same as the branch that triggered the workflow
28 | - Changes are detected against the merge-base with the configured base branch or the default branch
29 | - Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
30 | - **Master, Release, or other long-lived branches:**
31 | - Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)** event
32 | when `base` input parameter is the same as the branch that triggered the workflow:
33 | - Changes are detected against the most recent commit on the same branch before the push
34 | - Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
35 | when `base` input parameter is commit SHA:
36 | - Changes are detected against the provided `base` commit
37 | - Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)**
38 | when `base` input parameter is the same as the branch that triggered the workflow:
39 | - Changes are detected from the last commit
40 | - Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
41 | - **Local changes**
42 | - Workflow triggered by any event when `base` input parameter is set to `HEAD`
43 | - Changes are detected against the current HEAD
44 | - Untracked files are ignored
45 |
46 | ## Example
47 |
48 | ```yaml
49 | - uses: dorny/paths-filter@v3
50 | id: changes
51 | with:
52 | filters: |
53 | src:
54 | - 'src/**'
55 |
56 | # run only if some file in 'src' folder was changed
57 | - if: steps.changes.outputs.src == 'true'
58 | run: ...
59 | ```
60 |
61 | For more scenarios see [examples](#examples) section.
62 |
63 | ## Notes
64 |
65 | - Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
66 | Documentation for path expression format can be found on the project GitHub page.
67 | - Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
68 | Globbing will also match paths where file or folder name starts with a dot.
69 | - It's recommended to quote your path expressions with `'` or `"`. Otherwise, you will get an error if it starts with `*`.
70 | - Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary.
71 | - Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04`
72 |
73 | ## What's New
74 |
75 | - New major release `v3` after update to Node 20 [Breaking change]
76 | - Add `ref` input parameter
77 | - Add `list-files: csv` format
78 | - Configure matrix job to run for each folder with changes using `changes` output
79 | - Improved listing of matching files with `list-files: shell` and `list-files: escape` options
80 | - Paths expressions are now evaluated using [picomatch](https://github.com/micromatch/picomatch) library
81 |
82 | For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
83 |
84 | ## Usage
85 |
86 | ```yaml
87 | - uses: dorny/paths-filter@v3
88 | with:
89 | # Defines filters applied to detected changed files.
90 | # Each filter has a name and a list of rules.
91 | # Rule is a glob expression - paths of all changed
92 | # files are matched against it.
93 | # Rule can optionally specify if the file
94 | # should be added, modified, or deleted.
95 | # For each filter, there will be a corresponding output variable to
96 | # indicate if there's a changed file matching any of the rules.
97 | # Optionally, there can be a second output variable
98 | # set to list of all files matching the filter.
99 | # Filters can be provided inline as a string (containing valid YAML document),
100 | # or as a relative path to a file (e.g.: .github/filters.yaml).
101 | # Filters syntax is documented by example - see examples section.
102 | filters: ''
103 |
104 | # Branch, tag, or commit SHA against which the changes will be detected.
105 | # If it references the same branch it was pushed to,
106 | # changes are detected against the most recent commit before the push.
107 | # Otherwise, it uses git merge-base to find the best common ancestor between
108 | # current branch (HEAD) and base.
109 | # When merge-base is found, it's used for change detection - only changes
110 | # introduced by the current branch are considered.
111 | # All files are considered as added if there is no common ancestor with
112 | # base branch or no previous commit.
113 | # This option is ignored if action is triggered by pull_request event.
114 | # Default: repository default branch (e.g. master)
115 | base: ''
116 |
117 | # Git reference (e.g. branch name) from which the changes will be detected.
118 | # Useful when workflow can be triggered only on the default branch (e.g. repository_dispatch event)
119 | # but you want to get changes on a different branch.
120 | # This option is ignored if action is triggered by pull_request event.
121 | # default: ${{ github.ref }}
122 | ref:
123 |
124 | # How many commits are initially fetched from the base branch.
125 | # If needed, each subsequent fetch doubles the
126 | # previously requested number of commits until the merge-base
127 | # is found, or there are no more commits in the history.
128 | # This option takes effect only when changes are detected
129 | # using git against base branch (feature branch workflow).
130 | # Default: 100
131 | initial-fetch-depth: ''
132 |
133 | # Enables listing of files matching the filter:
134 | # 'none' - Disables listing of matching files (default).
135 | # 'csv' - Coma separated list of filenames.
136 | # If needed, it uses double quotes to wrap filename with unsafe characters.
137 | # 'json' - File paths are formatted as JSON array.
138 | # 'shell' - Space delimited list usable as command-line argument list in Linux shell.
139 | # If needed, it uses single or double quotes to wrap filename with unsafe characters.
140 | # 'escape'- Space delimited list usable as command-line argument list in Linux shell.
141 | # Backslash escapes every potentially unsafe character.
142 | # Default: none
143 | list-files: ''
144 |
145 | # Relative path under $GITHUB_WORKSPACE where the repository was checked out.
146 | working-directory: ''
147 |
148 | # Personal access token used to fetch a list of changed files
149 | # from GitHub REST API.
150 | # It's only used if action is triggered by a pull request event.
151 | # GitHub token from workflow context is used as default value.
152 | # If an empty string is provided, the action falls back to detect
153 | # changes using git commands.
154 | # Default: ${{ github.token }}
155 | token: ''
156 |
157 | # Optional parameter to override the default behavior of file matching algorithm.
158 | # By default files that match at least one pattern defined by the filters will be included.
159 | # This parameter allows to override the "at least one pattern" behavior to make it so that
160 | # all of the patterns have to match or otherwise the file is excluded.
161 | # An example scenario where this is useful if you would like to match all
162 | # .ts files in a sub-directory but not .md files.
163 | # The filters below will match markdown files despite the exclusion syntax UNLESS
164 | # you specify 'every' as the predicate-quantifier parameter. When you do that,
165 | # it will only match the .ts files in the subdirectory as expected.
166 | #
167 | # backend:
168 | # - 'pkg/a/b/c/**'
169 | # - '!**/*.jpeg'
170 | # - '!**/*.md'
171 | predicate-quantifier: 'some'
172 | ```
173 |
174 | ## Outputs
175 |
176 | - For each filter, it sets output variable named by the filter to the text:
177 | - `'true'` - if **any** of changed files matches any of filter rules
178 | - `'false'` - if **none** of changed files matches any of filter rules
179 | - For each filter, it sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
180 | - 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.
181 | - `changes` - JSON array with names of all filters matching any of the changed files.
182 |
183 | ## Examples
184 |
185 | ### Conditional execution
186 |
187 |
188 | Execute step in a workflow job only if some file in a subfolder is changed
189 |
190 | ```yaml
191 | jobs:
192 | tests:
193 | runs-on: ubuntu-latest
194 | steps:
195 | - uses: actions/checkout@v4
196 | - uses: dorny/paths-filter@v3
197 | id: filter
198 | with:
199 | filters: |
200 | backend:
201 | - 'backend/**'
202 | frontend:
203 | - 'frontend/**'
204 |
205 | # run only if 'backend' files were changed
206 | - name: backend tests
207 | if: steps.filter.outputs.backend == 'true'
208 | run: ...
209 |
210 | # run only if 'frontend' files were changed
211 | - name: frontend tests
212 | if: steps.filter.outputs.frontend == 'true'
213 | run: ...
214 |
215 | # run if 'backend' or 'frontend' files were changed
216 | - name: e2e tests
217 | if: steps.filter.outputs.backend == 'true' || steps.filter.outputs.frontend == 'true'
218 | run: ...
219 | ```
220 |
221 |
222 |
223 |
224 | Execute job in a workflow only if some file in a subfolder is changed
225 |
226 | ```yml
227 | jobs:
228 | # JOB to run change detection
229 | changes:
230 | runs-on: ubuntu-latest
231 | # Required permissions
232 | permissions:
233 | pull-requests: read
234 | # Set job outputs to values from filter step
235 | outputs:
236 | backend: ${{ steps.filter.outputs.backend }}
237 | frontend: ${{ steps.filter.outputs.frontend }}
238 | steps:
239 | # For pull requests it's not necessary to checkout the code
240 | - uses: dorny/paths-filter@v3
241 | id: filter
242 | with:
243 | filters: |
244 | backend:
245 | - 'backend/**'
246 | frontend:
247 | - 'frontend/**'
248 |
249 | # JOB to build and test backend code
250 | backend:
251 | needs: changes
252 | if: ${{ needs.changes.outputs.backend == 'true' }}
253 | runs-on: ubuntu-latest
254 | steps:
255 | - uses: actions/checkout@v4
256 | - ...
257 |
258 | # JOB to build and test frontend code
259 | frontend:
260 | needs: changes
261 | if: ${{ needs.changes.outputs.frontend == 'true' }}
262 | runs-on: ubuntu-latest
263 | steps:
264 | - uses: actions/checkout@v4
265 | - ...
266 | ```
267 |
268 |
269 |
270 |
271 | Use change detection to configure matrix job
272 |
273 | ```yaml
274 | jobs:
275 | # JOB to run change detection
276 | changes:
277 | runs-on: ubuntu-latest
278 | # Required permissions
279 | permissions:
280 | pull-requests: read
281 | outputs:
282 | # Expose matched filters as job 'packages' output variable
283 | packages: ${{ steps.filter.outputs.changes }}
284 | steps:
285 | # For pull requests it's not necessary to checkout the code
286 | - uses: dorny/paths-filter@v3
287 | id: filter
288 | with:
289 | filters: |
290 | package1: src/package1
291 | package2: src/package2
292 |
293 | # JOB to build and test each of modified packages
294 | build:
295 | needs: changes
296 | strategy:
297 | matrix:
298 | # Parse JSON array containing names of all filters matching any of changed files
299 | # e.g. ['package1', 'package2'] if both package folders contains changes
300 | package: ${{ fromJSON(needs.changes.outputs.packages) }}
301 | runs-on: ubuntu-latest
302 | steps:
303 | - uses: actions/checkout@v4
304 | - ...
305 | ```
306 |
307 |
308 |
309 | ### Change detection workflows
310 |
311 |
312 | Pull requests: Detect changes against PR base branch
313 |
314 | ```yaml
315 | on:
316 | pull_request:
317 | branches: # PRs to the following branches will trigger the workflow
318 | - master
319 | - develop
320 | jobs:
321 | build:
322 | runs-on: ubuntu-latest
323 | # Required permissions
324 | permissions:
325 | pull-requests: read
326 | steps:
327 | - uses: actions/checkout@v4
328 | - uses: dorny/paths-filter@v3
329 | id: filter
330 | with:
331 | filters: ... # Configure your filters
332 | ```
333 |
334 |
335 |
336 |
337 | Feature branch: Detect changes against configured base branch
338 |
339 | ```yaml
340 | on:
341 | push:
342 | branches: # Push to following branches will trigger the workflow
343 | - feature/**
344 | jobs:
345 | build:
346 | runs-on: ubuntu-latest
347 | steps:
348 | - uses: actions/checkout@v4
349 | with:
350 | # This may save additional git fetch roundtrip if
351 | # merge-base is found within latest 20 commits
352 | fetch-depth: 20
353 | - uses: dorny/paths-filter@v3
354 | id: filter
355 | with:
356 | base: develop # Change detection against merge-base with this branch
357 | filters: ... # Configure your filters
358 | ```
359 |
360 |
361 |
362 |
363 | Long lived branches: Detect changes against the most recent commit on the same branch before the push
364 |
365 | ```yaml
366 | on:
367 | push:
368 | branches: # Push to the following branches will trigger the workflow
369 | - master
370 | - develop
371 | - release/**
372 | jobs:
373 | build:
374 | runs-on: ubuntu-latest
375 | steps:
376 | - uses: actions/checkout@v4
377 | - uses: dorny/paths-filter@v3
378 | id: filter
379 | with:
380 | # Use context to get the branch where commits were pushed.
381 | # If there is only one long-lived branch (e.g. master),
382 | # you can specify it directly.
383 | # If it's not configured, the repository default branch is used.
384 | base: ${{ github.ref }}
385 | filters: ... # Configure your filters
386 | ```
387 |
388 |
389 |
390 |
391 | Local changes: Detect staged and unstaged local changes
392 |
393 | ```yaml
394 | on:
395 | push:
396 | branches: # Push to following branches will trigger the workflow
397 | - master
398 | - develop
399 | - release/**
400 | jobs:
401 | build:
402 | runs-on: ubuntu-latest
403 | steps:
404 | - uses: actions/checkout@v4
405 |
406 | # Some action that modifies files tracked by git (e.g. code linter)
407 | - uses: johndoe/some-action@v1
408 |
409 | # Filter to detect which files were modified
410 | # Changes could be, for example, automatically committed
411 | - uses: dorny/paths-filter@v3
412 | id: filter
413 | with:
414 | base: HEAD
415 | filters: ... # Configure your filters
416 | ```
417 |
418 |
419 |
420 | ### Advanced options
421 |
422 |
423 | Define filter rules in own file
424 |
425 | ```yaml
426 | - uses: dorny/paths-filter@v3
427 | id: filter
428 | with:
429 | # Path to file where filters are defined
430 | filters: .github/filters.yaml
431 | ```
432 |
433 |
434 |
435 |
436 | Use YAML anchors to reuse path expression(s) inside another rule
437 |
438 | ```yaml
439 | - uses: dorny/paths-filter@v3
440 | id: filter
441 | with:
442 | # &shared is YAML anchor,
443 | # *shared references previously defined anchor
444 | # src filter will match any path under common, config and src folders
445 | filters: |
446 | shared: &shared
447 | - common/**
448 | - config/**
449 | src:
450 | - *shared
451 | - src/**
452 | ```
453 |
454 |
455 |
456 |
457 | Consider if file was added, modified or deleted
458 |
459 | ```yaml
460 | - uses: dorny/paths-filter@v3
461 | id: filter
462 | with:
463 | # Changed file can be 'added', 'modified', or 'deleted'.
464 | # By default, the type of change is not considered.
465 | # Optionally, it's possible to specify it using nested
466 | # dictionary, where the type of change composes the key.
467 | # Multiple change types can be specified using `|` as the delimiter.
468 | filters: |
469 | shared: &shared
470 | - common/**
471 | - config/**
472 | addedOrModified:
473 | - added|modified: '**'
474 | allChanges:
475 | - added|deleted|modified: '**'
476 | addedOrModifiedAnchors:
477 | - added|modified: *shared
478 | ```
479 |
480 |
481 |
482 |
483 | Detect changes in folder only for some file extensions
484 |
485 | ```yaml
486 | - uses: dorny/paths-filter@v3
487 | id: filter
488 | with:
489 | # This makes it so that all the patterns have to match a file for it to be
490 | # considered changed. Because we have the exclusions for .jpeg and .md files
491 | # the end result is that if those files are changed they will be ignored
492 | # because they don't match the respective rules excluding them.
493 | #
494 | # This can be leveraged to ensure that you only build & test software changes
495 | # that have real impact on the behavior of the code, e.g. you can set up your
496 | # build to run when Typescript/Rust/etc. files are changed but markdown
497 | # changes in the diff will be ignored and you consume less resources to build.
498 | predicate-quantifier: 'every'
499 | filters: |
500 | backend:
501 | - 'pkg/a/b/c/**'
502 | - '!**/*.jpeg'
503 | - '!**/*.md'
504 | ```
505 |
506 |
507 |
508 | ### Custom processing of changed files
509 |
510 |
511 | Passing list of modified files as command line args in Linux shell
512 |
513 | ```yaml
514 | - uses: dorny/paths-filter@v3
515 | id: filter
516 | with:
517 | # Enable listing of files matching each filter.
518 | # Paths to files will be available in `${FILTER_NAME}_files` output variable.
519 | # Paths will be escaped and space-delimited.
520 | # Output is usable as command-line argument list in Linux shell
521 | list-files: shell
522 |
523 | # In this example changed files will be checked by linter.
524 | # It doesn't make sense to lint deleted files.
525 | # Therefore we specify we are only interested in added or modified files.
526 | filters: |
527 | markdown:
528 | - added|modified: '*.md'
529 | - name: Lint Markdown
530 | if: ${{ steps.filter.outputs.markdown == 'true' }}
531 | run: npx textlint ${{ steps.filter.outputs.markdown_files }}
532 | ```
533 |
534 |
535 |
536 |
537 | Passing list of modified files as JSON array to another action
538 |
539 | ```yaml
540 | - uses: dorny/paths-filter@v3
541 | id: filter
542 | with:
543 | # Enable listing of files matching each filter.
544 | # Paths to files will be available in `${FILTER_NAME}_files` output variable.
545 | # Paths will be formatted as JSON array
546 | list-files: json
547 |
548 | # In this example all changed files are passed to the following action to do
549 | # some custom processing.
550 | filters: |
551 | changed:
552 | - '**'
553 | - name: Lint Markdown
554 | uses: johndoe/some-action@v1
555 | with:
556 | files: ${{ steps.filter.outputs.changed_files }}
557 | ```
558 |
559 |
560 |
561 | ## See also
562 |
563 | - [test-reporter](https://github.com/dorny/test-reporter) - Displays test results from popular testing frameworks directly in GitHub
564 |
565 | ## License
566 |
567 | The scripts and documentation in this project are released under the [MIT License](https://github.com/dorny/paths-filter/blob/master/LICENSE)
568 |
--------------------------------------------------------------------------------
/__tests__/csv-escape.test.ts:
--------------------------------------------------------------------------------
1 | import {csvEscape} from '../src/list-format/csv-escape'
2 |
3 | describe('csvEscape() backslash escapes every character except subset of definitely safe characters', () => {
4 | test('simple filename should not be modified', () => {
5 | expect(csvEscape('file.txt')).toBe('file.txt')
6 | })
7 |
8 | test('directory separator should be preserved and not escaped', () => {
9 | expect(csvEscape('path/to/file.txt')).toBe('path/to/file.txt')
10 | })
11 |
12 | test('filename with spaces should be quoted', () => {
13 | expect(csvEscape('file with space')).toBe('"file with space"')
14 | })
15 |
16 | test('filename with "," should be quoted', () => {
17 | expect(csvEscape('file, with coma')).toBe('"file, with coma"')
18 | })
19 |
20 | test('Double quote should be escaped by another double quote', () => {
21 | expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/__tests__/filter.test.ts:
--------------------------------------------------------------------------------
1 | import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter'
2 | import {File, ChangeStatus} from '../src/file'
3 |
4 | describe('yaml filter parsing tests', () => {
5 | test('throws if yaml is not a dictionary', () => {
6 | const yaml = 'not a dictionary'
7 | const t = () => new Filter(yaml)
8 | expect(t).toThrow(/^Invalid filter.*/)
9 | })
10 | test('throws if pattern is not a string', () => {
11 | const yaml = `
12 | src:
13 | - src/**/*.js
14 | - dict:
15 | some: value
16 | `
17 | const t = () => new Filter(yaml)
18 | expect(t).toThrow(/^Invalid filter.*/)
19 | })
20 | })
21 |
22 | describe('matching tests', () => {
23 | test('matches single inline rule', () => {
24 | const yaml = `
25 | src: "src/**/*.js"
26 | `
27 | let filter = new Filter(yaml)
28 | const files = modified(['src/app/module/file.js'])
29 | const match = filter.match(files)
30 | expect(match.src).toEqual(files)
31 | })
32 | test('matches single rule in single group', () => {
33 | const yaml = `
34 | src:
35 | - src/**/*.js
36 | `
37 | const filter = new Filter(yaml)
38 | const files = modified(['src/app/module/file.js'])
39 | const match = filter.match(files)
40 | expect(match.src).toEqual(files)
41 | })
42 |
43 | test('no match when file is in different folder', () => {
44 | const yaml = `
45 | src:
46 | - src/**/*.js
47 | `
48 | const filter = new Filter(yaml)
49 | const match = filter.match(modified(['not_src/other_file.js']))
50 | expect(match.src).toEqual([])
51 | })
52 |
53 | test('match only within second groups ', () => {
54 | const yaml = `
55 | src:
56 | - src/**/*.js
57 | test:
58 | - test/**/*.js
59 | `
60 | const filter = new Filter(yaml)
61 | const files = modified(['test/test.js'])
62 | const match = filter.match(files)
63 | expect(match.src).toEqual([])
64 | expect(match.test).toEqual(files)
65 | })
66 |
67 | test('match only withing second rule of single group', () => {
68 | const yaml = `
69 | src:
70 | - src/**/*.js
71 | - test/**/*.js
72 | `
73 | const filter = new Filter(yaml)
74 | const files = modified(['test/test.js'])
75 | const match = filter.match(files)
76 | expect(match.src).toEqual(files)
77 | })
78 |
79 | test('matches anything', () => {
80 | const yaml = `
81 | any:
82 | - "**"
83 | `
84 | const filter = new Filter(yaml)
85 | const files = modified(['test/test.js'])
86 | const match = filter.match(files)
87 | expect(match.any).toEqual(files)
88 | })
89 |
90 | test('globbing matches path where file or folder name starts with dot', () => {
91 | const yaml = `
92 | dot:
93 | - "**/*.js"
94 | `
95 | const filter = new Filter(yaml)
96 | const files = modified(['.test/.test.js'])
97 | const match = filter.match(files)
98 | expect(match.dot).toEqual(files)
99 | })
100 |
101 | test('matches all except tsx and less files (negate a group with or-ed parts)', () => {
102 | const yaml = `
103 | backend:
104 | - '!(**/*.tsx|**/*.less)'
105 | `
106 | const filter = new Filter(yaml)
107 | const tsxFiles = modified(['src/ui.tsx'])
108 | const lessFiles = modified(['src/ui.less'])
109 | const pyFiles = modified(['src/server.py'])
110 |
111 | const tsxMatch = filter.match(tsxFiles)
112 | const lessMatch = filter.match(lessFiles)
113 | const pyMatch = filter.match(pyFiles)
114 |
115 | expect(tsxMatch.backend).toEqual([])
116 | expect(lessMatch.backend).toEqual([])
117 | expect(pyMatch.backend).toEqual(pyFiles)
118 | })
119 |
120 | test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => {
121 | const yaml = `
122 | backend:
123 | - 'pkg/a/b/c/**'
124 | - '!**/*.jpeg'
125 | - '!**/*.md'
126 | `
127 | const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY}
128 | const filter = new Filter(yaml, filterConfig)
129 |
130 | const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts'])
131 | const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts'])
132 | const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg'])
133 | const docsFiles = modified([
134 | 'pkg/a/b/c/some-pics.jpeg',
135 | 'pkg/a/b/c/src/main/jpeg/some-pic.jpeg',
136 | 'pkg/a/b/c/src/main/some-docs.md',
137 | 'pkg/a/b/c/some-docs.md'
138 | ])
139 |
140 | const typescriptMatch = filter.match(typescriptFiles)
141 | const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles)
142 | const docsMatch = filter.match(docsFiles)
143 | const otherPkgJpegMatch = filter.match(otherPkgJpegFiles)
144 |
145 | expect(typescriptMatch.backend).toEqual(typescriptFiles)
146 | expect(otherPkgTypescriptMatch.backend).toEqual([])
147 | expect(docsMatch.backend).toEqual([])
148 | expect(otherPkgJpegMatch.backend).toEqual([])
149 | })
150 |
151 | test('matches path based on rules included using YAML anchor', () => {
152 | const yaml = `
153 | shared: &shared
154 | - common/**/*
155 | - config/**/*
156 | src:
157 | - *shared
158 | - src/**/*
159 | `
160 | const filter = new Filter(yaml)
161 | const files = modified(['config/settings.yml'])
162 | const match = filter.match(files)
163 | expect(match.src).toEqual(files)
164 | })
165 | })
166 |
167 | describe('matching specific change status', () => {
168 | test('does not match modified file as added', () => {
169 | const yaml = `
170 | add:
171 | - added: "**/*"
172 | `
173 | let filter = new Filter(yaml)
174 | const match = filter.match(modified(['file.js']))
175 | expect(match.add).toEqual([])
176 | })
177 |
178 | test('match added file as added', () => {
179 | const yaml = `
180 | add:
181 | - added: "**/*"
182 | `
183 | let filter = new Filter(yaml)
184 | const files = [{status: ChangeStatus.Added, filename: 'file.js'}]
185 | const match = filter.match(files)
186 | expect(match.add).toEqual(files)
187 | })
188 |
189 | test('matches when multiple statuses are configured', () => {
190 | const yaml = `
191 | addOrModify:
192 | - added|modified: "**/*"
193 | `
194 | let filter = new Filter(yaml)
195 | const files = [{status: ChangeStatus.Modified, filename: 'file.js'}]
196 | const match = filter.match(files)
197 | expect(match.addOrModify).toEqual(files)
198 | })
199 |
200 | test('matches when using an anchor', () => {
201 | const yaml = `
202 | shared: &shared
203 | - common/**/*
204 | - config/**/*
205 | src:
206 | - modified: *shared
207 | `
208 | let filter = new Filter(yaml)
209 | const files = modified(['config/file.js', 'common/anotherFile.js'])
210 | const match = filter.match(files)
211 | expect(match.src).toEqual(files)
212 | })
213 | })
214 |
215 | function modified(paths: string[]): File[] {
216 | return paths.map(filename => {
217 | return {filename, status: ChangeStatus.Modified}
218 | })
219 | }
220 |
221 | function renamed(paths: string[]): File[] {
222 | return paths.map(filename => {
223 | return {filename, status: ChangeStatus.Renamed}
224 | })
225 | }
226 |
--------------------------------------------------------------------------------
/__tests__/git.test.ts:
--------------------------------------------------------------------------------
1 | import * as git from '../src/git'
2 | import {ChangeStatus} from '../src/file'
3 |
4 | describe('parsing output of the git diff command', () => {
5 | test('parseGitDiffOutput returns files with correct change status', async () => {
6 | const files = git.parseGitDiffOutput(
7 | 'A\u0000LICENSE\u0000' + 'M\u0000src/index.ts\u0000' + 'D\u0000src/main.ts\u0000'
8 | )
9 | expect(files.length).toBe(3)
10 | expect(files[0].filename).toBe('LICENSE')
11 | expect(files[0].status).toBe(ChangeStatus.Added)
12 | expect(files[1].filename).toBe('src/index.ts')
13 | expect(files[1].status).toBe(ChangeStatus.Modified)
14 | expect(files[2].filename).toBe('src/main.ts')
15 | expect(files[2].status).toBe(ChangeStatus.Deleted)
16 | })
17 | })
18 |
19 | describe('git utility function tests (those not invoking git)', () => {
20 | test('Trims "refs/" and "heads/" from ref', () => {
21 | expect(git.getShortName('refs/heads/master')).toBe('master')
22 | expect(git.getShortName('heads/master')).toBe('heads/master')
23 | expect(git.getShortName('master')).toBe('master')
24 |
25 | expect(git.getShortName('refs/tags/v1')).toBe('v1')
26 | expect(git.getShortName('tags/v1')).toBe('tags/v1')
27 | expect(git.getShortName('v1')).toBe('v1')
28 | })
29 |
30 | test('isGitSha(ref) returns true only for 40 characters of a-z and 0-9', () => {
31 | expect(git.isGitSha('8b399ed1681b9efd6b1e048ca1c5cba47edf3855')).toBeTruthy()
32 | expect(git.isGitSha('This_is_very_long_name_for_a_branch_1111')).toBeFalsy()
33 | expect(git.isGitSha('master')).toBeFalsy()
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/__tests__/shell-escape.test.ts:
--------------------------------------------------------------------------------
1 | import {backslashEscape, shellEscape} from '../src/list-format/shell-escape'
2 |
3 | describe('escape() backslash escapes every character except subset of definitely safe characters', () => {
4 | test('simple filename should not be modified', () => {
5 | expect(backslashEscape('file.txt')).toBe('file.txt')
6 | })
7 |
8 | test('directory separator should be preserved and not escaped', () => {
9 | expect(backslashEscape('path/to/file.txt')).toBe('path/to/file.txt')
10 | })
11 |
12 | test('spaces should be escaped with backslash', () => {
13 | expect(backslashEscape('file with space')).toBe('file\\ with\\ space')
14 | })
15 |
16 | test('quotes should be escaped with backslash', () => {
17 | expect(backslashEscape('file\'with quote"')).toBe('file\\\'with\\ quote\\"')
18 | })
19 |
20 | test('$variables should be escaped', () => {
21 | expect(backslashEscape('$var')).toBe('\\$var')
22 | })
23 | })
24 |
25 | describe('shellEscape() returns human readable filenames with as few escaping applied as possible', () => {
26 | test('simple filename should not be modified', () => {
27 | expect(shellEscape('file.txt')).toBe('file.txt')
28 | })
29 |
30 | test('directory separator should be preserved and not escaped', () => {
31 | expect(shellEscape('path/to/file.txt')).toBe('path/to/file.txt')
32 | })
33 |
34 | test('filename with spaces should be quoted', () => {
35 | expect(shellEscape('file with space')).toBe("'file with space'")
36 | })
37 |
38 | test('filename with spaces should be quoted', () => {
39 | expect(shellEscape('file with space')).toBe("'file with space'")
40 | })
41 |
42 | test('filename with $ should be quoted', () => {
43 | expect(shellEscape('$var')).toBe("'$var'")
44 | })
45 |
46 | test('filename with " should be quoted', () => {
47 | expect(shellEscape('file"name')).toBe("'file\"name'")
48 | })
49 |
50 | test('filename with single quote should be wrapped in double quotes', () => {
51 | expect(shellEscape("file'with quote")).toBe('"file\'with quote"')
52 | })
53 |
54 | test('filename with single quote and special characters is split and quoted/escaped as needed', () => {
55 | expect(shellEscape("file'with $quote")).toBe("file\\''with $quote'")
56 | })
57 | })
58 |
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | name: 'Paths Changes Filter'
2 | description: 'Execute your workflow steps only if relevant files are modified.'
3 | author: 'Michal Dorner '
4 | inputs:
5 | token:
6 | description: 'GitHub Access Token'
7 | required: false
8 | default: ${{ github.token }}
9 | working-directory:
10 | description: 'Relative path under $GITHUB_WORKSPACE where the repository was checked out.'
11 | required: false
12 | ref:
13 | description: |
14 | Git reference (e.g. branch name) from which the changes will be detected.
15 | This option is ignored if action is triggered by pull_request event.
16 | required: false
17 | base:
18 | description: |
19 | Git reference (e.g. branch name) against which the changes will be detected. Defaults to repository default branch (e.g. master).
20 | If it references same branch it was pushed to, changes are detected against the most recent commit before the push.
21 | This option is ignored if action is triggered by pull_request event.
22 | required: false
23 | filters:
24 | description: 'Path to the configuration file or YAML string with filters definition'
25 | required: true
26 | list-files:
27 | description: |
28 | Enables listing of files matching the filter:
29 | 'none' - Disables listing of matching files (default).
30 | 'csv' - Coma separated list of filenames.
31 | If needed it uses double quotes to wrap filename with unsafe characters.
32 | 'json' - Serialized as JSON array.
33 | 'shell' - Space delimited list usable as command line argument list in linux shell.
34 | If needed it uses single or double quotes to wrap filename with unsafe characters.
35 | 'escape'- Space delimited list usable as command line argument list in linux shell.
36 | Backslash escapes every potentially unsafe character.
37 | required: false
38 | default: none
39 | initial-fetch-depth:
40 | description: |
41 | How many commits are initially fetched from base branch.
42 | If needed, each subsequent fetch doubles the previously requested number of commits
43 | until the merge-base is found or there are no more commits in the history.
44 | This option takes effect only when changes are detected using git against different base branch.
45 | required: false
46 | default: '100'
47 | outputs:
48 | changes:
49 | description: JSON array with names of all filters matching any of changed files
50 | runs:
51 | using: 'node20'
52 | main: 'dist/index.js'
53 | branding:
54 | color: blue
55 | icon: filter
56 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | clearMocks: true,
3 | moduleFileExtensions: ['js', 'ts'],
4 | testEnvironment: 'node',
5 | testMatch: ['**/*.test.ts'],
6 | testRunner: 'jest-circus/runner',
7 | transform: {
8 | '^.+\\.ts$': 'ts-jest'
9 | },
10 | verbose: true
11 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "paths-filter",
3 | "version": "1.0.0",
4 | "engines": {
5 | "node": ">= 20"
6 | },
7 | "private": true,
8 | "description": "Execute your workflow steps only if relevant files are modified.",
9 | "main": "lib/main.js",
10 | "scripts": {
11 | "build": "tsc",
12 | "format": "prettier --write **/*.ts",
13 | "format-check": "prettier --check **/*.ts",
14 | "lint": "eslint src/**/*.ts",
15 | "pack": "ncc build",
16 | "test": "jest",
17 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm test"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/actions/typescript-action.git"
22 | },
23 | "keywords": [
24 | "actions",
25 | "node",
26 | "setup"
27 | ],
28 | "author": "YourNameOrOrganization",
29 | "license": "MIT",
30 | "dependencies": {
31 | "@actions/core": "^1.10.0",
32 | "@actions/exec": "^1.1.1",
33 | "@actions/github": "6.0.0",
34 | "picomatch": "^2.3.1"
35 | },
36 | "devDependencies": {
37 | "@octokit/webhooks-types": "^7.3.1",
38 | "@types/jest": "^29.5.11",
39 | "@types/js-yaml": "^4.0.9",
40 | "@types/node": "^20.11.6",
41 | "@types/picomatch": "^2.3.3",
42 | "@typescript-eslint/eslint-plugin": "^6.19.1",
43 | "@typescript-eslint/parser": "^6.19.1",
44 | "@vercel/ncc": "^0.38.1",
45 | "eslint": "^8.56.0",
46 | "eslint-plugin-github": "^4.10.1",
47 | "eslint-plugin-jest": "^27.6.3",
48 | "jest": "^29.7.0",
49 | "jest-circus": "^29.7.0",
50 | "js-yaml": "^4.1.0",
51 | "prettier": "^2.8.8",
52 | "ts-jest": "^29.1.2",
53 | "typescript": "^5.3.3"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/file.ts:
--------------------------------------------------------------------------------
1 | export interface File {
2 | filename: string
3 | status: ChangeStatus
4 | }
5 |
6 | export enum ChangeStatus {
7 | Added = 'added',
8 | Copied = 'copied',
9 | Deleted = 'deleted',
10 | Modified = 'modified',
11 | Renamed = 'renamed',
12 | Unmerged = 'unmerged'
13 | }
14 |
--------------------------------------------------------------------------------
/src/filter.ts:
--------------------------------------------------------------------------------
1 | import * as jsyaml from 'js-yaml'
2 | import picomatch from 'picomatch'
3 | import {File, ChangeStatus} from './file'
4 |
5 | // Type definition of object we expect to load from YAML
6 | interface FilterYaml {
7 | [name: string]: FilterItemYaml
8 | }
9 | type FilterItemYaml =
10 | | string // Filename pattern, e.g. "path/to/*.js"
11 | | {[changeTypes: string]: string | string[]} // Change status and filename, e.g. added|modified: "path/to/*.js"
12 | | FilterItemYaml[] // Supports referencing another rule via YAML anchor
13 |
14 | // Minimatch options used in all matchers
15 | const MatchOptions = {
16 | dot: true
17 | }
18 |
19 | // Internal representation of one item in named filter rule
20 | // Created as simplified form of data in FilterItemYaml
21 | interface FilterRuleItem {
22 | status?: ChangeStatus[] // Required change status of the matched files
23 | isMatch: (str: string) => boolean // Matches the filename
24 | }
25 |
26 | /**
27 | * Enumerates the possible logic quantifiers that can be used when determining
28 | * if a file is a match or not with multiple patterns.
29 | *
30 | * The YAML configuration property that is parsed into one of these values is
31 | * 'predicate-quantifier' on the top level of the configuration object of the
32 | * action.
33 | *
34 | * The default is to use 'some' which used to be the hardcoded behavior prior to
35 | * the introduction of the new mechanism.
36 | *
37 | * @see https://en.wikipedia.org/wiki/Quantifier_(logic)
38 | */
39 | export enum PredicateQuantifier {
40 | /**
41 | * When choosing 'every' in the config it means that files will only get matched
42 | * if all the patterns are satisfied by the path of the file, not just at least one of them.
43 | */
44 | EVERY = 'every',
45 | /**
46 | * When choosing 'some' in the config it means that files will get matched as long as there is
47 | * at least one pattern that matches them. This is the default behavior if you don't
48 | * specify anything as a predicate quantifier.
49 | */
50 | SOME = 'some'
51 | }
52 |
53 | /**
54 | * Used to define customizations for how the file filtering should work at runtime.
55 | */
56 | export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier}
57 |
58 | /**
59 | * An array of strings (at runtime) that contains the valid/accepted values for
60 | * the configuration parameter 'predicate-quantifier'.
61 | */
62 | export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier)
63 |
64 | export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier {
65 | return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier)
66 | }
67 |
68 | export interface FilterResults {
69 | [key: string]: File[]
70 | }
71 |
72 | export class Filter {
73 | rules: {[key: string]: FilterRuleItem[]} = {}
74 |
75 | // Creates instance of Filter and load rules from YAML if it's provided
76 | constructor(yaml?: string, readonly filterConfig?: FilterConfig) {
77 | if (yaml) {
78 | this.load(yaml)
79 | }
80 | }
81 |
82 | // Load rules from YAML string
83 | load(yaml: string): void {
84 | if (!yaml) {
85 | return
86 | }
87 |
88 | const doc = jsyaml.load(yaml) as FilterYaml
89 | if (typeof doc !== 'object') {
90 | this.throwInvalidFormatError('Root element is not an object')
91 | }
92 |
93 | for (const [key, item] of Object.entries(doc)) {
94 | this.rules[key] = this.parseFilterItemYaml(item)
95 | }
96 | }
97 |
98 | match(files: File[]): FilterResults {
99 | const result: FilterResults = {}
100 | for (const [key, patterns] of Object.entries(this.rules)) {
101 | result[key] = files.filter(file => this.isMatch(file, patterns))
102 | }
103 | return result
104 | }
105 |
106 | private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
107 | const aPredicate = (rule: Readonly): boolean => {
108 | return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
109 | }
110 | if (this.filterConfig?.predicateQuantifier === 'every') {
111 | return patterns.every(aPredicate)
112 | } else {
113 | return patterns.some(aPredicate)
114 | }
115 | }
116 |
117 | private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
118 | if (Array.isArray(item)) {
119 | return flat(item.map(i => this.parseFilterItemYaml(i)))
120 | }
121 |
122 | if (typeof item === 'string') {
123 | return [{status: undefined, isMatch: picomatch(item, MatchOptions)}]
124 | }
125 |
126 | if (typeof item === 'object') {
127 | return Object.entries(item).map(([key, pattern]) => {
128 | if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) {
129 | this.throwInvalidFormatError(
130 | `Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
131 | )
132 | }
133 | return {
134 | status: key
135 | .split('|')
136 | .map(x => x.trim())
137 | .filter(x => x.length > 0)
138 | .map(x => x.toLowerCase()) as ChangeStatus[],
139 | isMatch: picomatch(pattern, MatchOptions)
140 | }
141 | })
142 | }
143 |
144 | this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`)
145 | }
146 |
147 | private throwInvalidFormatError(message: string): never {
148 | throw new Error(`Invalid filter YAML format: ${message}.`)
149 | }
150 | }
151 |
152 | // Creates a new array with all sub-array elements concatenated
153 | // In future could be replaced by Array.prototype.flat (supported on Node.js 11+)
154 | function flat(arr: T[][]): T[] {
155 | return arr.reduce((acc, val) => acc.concat(val), [])
156 | }
157 |
--------------------------------------------------------------------------------
/src/git.ts:
--------------------------------------------------------------------------------
1 | import {getExecOutput} from '@actions/exec'
2 | import * as core from '@actions/core'
3 | import {File, ChangeStatus} from './file'
4 |
5 | export const NULL_SHA = '0000000000000000000000000000000000000000'
6 | export const HEAD = 'HEAD'
7 |
8 | export async function getChangesInLastCommit(): Promise {
9 | core.startGroup(`Change detection in last commit`)
10 | let output = ''
11 | try {
12 | output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
13 | } finally {
14 | fixStdOutNullTermination()
15 | core.endGroup()
16 | }
17 |
18 | return parseGitDiffOutput(output)
19 | }
20 |
21 | export async function getChanges(base: string, head: string): Promise {
22 | const baseRef = await ensureRefAvailable(base)
23 | const headRef = await ensureRefAvailable(head)
24 |
25 | // Get differences between ref and HEAD
26 | core.startGroup(`Change detection ${base}..${head}`)
27 | let output = ''
28 | try {
29 | // Two dots '..' change detection - directly compares two versions
30 | output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
31 | .stdout
32 | } finally {
33 | fixStdOutNullTermination()
34 | core.endGroup()
35 | }
36 |
37 | return parseGitDiffOutput(output)
38 | }
39 |
40 | export async function getChangesOnHead(): Promise {
41 | // Get current changes - both staged and unstaged
42 | core.startGroup(`Change detection on HEAD`)
43 | let output = ''
44 | try {
45 | output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
46 | } finally {
47 | fixStdOutNullTermination()
48 | core.endGroup()
49 | }
50 |
51 | return parseGitDiffOutput(output)
52 | }
53 |
54 | export async function getChangesSinceMergeBase(base: string, head: string, initialFetchDepth: number): Promise {
55 | let baseRef: string | undefined
56 | let headRef: string | undefined
57 | async function hasMergeBase(): Promise {
58 | if (baseRef === undefined || headRef === undefined) {
59 | return false
60 | }
61 | return (await getExecOutput('git', ['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
62 | }
63 |
64 | let noMergeBase = false
65 | core.startGroup(`Searching for merge-base ${base}...${head}`)
66 | try {
67 | baseRef = await getLocalRef(base)
68 | headRef = await getLocalRef(head)
69 | if (!(await hasMergeBase())) {
70 | await getExecOutput('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
71 | if (baseRef === undefined || headRef === undefined) {
72 | baseRef = baseRef ?? (await getLocalRef(base))
73 | headRef = headRef ?? (await getLocalRef(head))
74 | if (baseRef === undefined || headRef === undefined) {
75 | await getExecOutput('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], {
76 | ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it
77 | })
78 | baseRef = baseRef ?? (await getLocalRef(base))
79 | headRef = headRef ?? (await getLocalRef(head))
80 | if (baseRef === undefined) {
81 | throw new Error(
82 | `Could not determine what is ${base} - fetch works but it's not a branch, tag or commit SHA`
83 | )
84 | }
85 | if (headRef === undefined) {
86 | throw new Error(
87 | `Could not determine what is ${head} - fetch works but it's not a branch, tag or commit SHA`
88 | )
89 | }
90 | }
91 | }
92 |
93 | let depth = initialFetchDepth
94 | let lastCommitCount = await getCommitCount()
95 | while (!(await hasMergeBase())) {
96 | depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER)
97 | await getExecOutput('git', ['fetch', `--deepen=${depth}`, 'origin', base, head])
98 | const commitCount = await getCommitCount()
99 | if (commitCount === lastCommitCount) {
100 | core.info('No more commits were fetched')
101 | core.info('Last attempt will be to fetch full history')
102 | await getExecOutput('git', ['fetch'])
103 | if (!(await hasMergeBase())) {
104 | noMergeBase = true
105 | }
106 | break
107 | }
108 | lastCommitCount = commitCount
109 | }
110 | }
111 | } finally {
112 | core.endGroup()
113 | }
114 |
115 | // Three dots '...' change detection - finds merge-base and compares against it
116 | let diffArg = `${baseRef}...${headRef}`
117 | if (noMergeBase) {
118 | core.warning('No merge base found - change detection will use direct .. comparison')
119 | diffArg = `${baseRef}..${headRef}`
120 | }
121 |
122 | // Get changes introduced on ref compared to base
123 | core.startGroup(`Change detection ${diffArg}`)
124 | let output = ''
125 | try {
126 | output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
127 | } finally {
128 | fixStdOutNullTermination()
129 | core.endGroup()
130 | }
131 |
132 | return parseGitDiffOutput(output)
133 | }
134 |
135 | export function parseGitDiffOutput(output: string): File[] {
136 | const tokens = output.split('\u0000').filter(s => s.length > 0)
137 | const files: File[] = []
138 | for (let i = 0; i + 1 < tokens.length; i += 2) {
139 | files.push({
140 | status: statusMap[tokens[i]],
141 | filename: tokens[i + 1]
142 | })
143 | }
144 | return files
145 | }
146 |
147 | export async function listAllFilesAsAdded(): Promise {
148 | core.startGroup('Listing all files tracked by git')
149 | let output = ''
150 | try {
151 | output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
152 | } finally {
153 | fixStdOutNullTermination()
154 | core.endGroup()
155 | }
156 |
157 | return output
158 | .split('\u0000')
159 | .filter(s => s.length > 0)
160 | .map(path => ({
161 | status: ChangeStatus.Added,
162 | filename: path
163 | }))
164 | }
165 |
166 | export async function getCurrentRef(): Promise {
167 | core.startGroup(`Get current git ref`)
168 | try {
169 | const branch = (await getExecOutput('git', ['branch', '--show-current'])).stdout.trim()
170 | if (branch) {
171 | return branch
172 | }
173 |
174 | const describe = await getExecOutput('git', ['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
175 | if (describe.exitCode === 0) {
176 | return describe.stdout.trim()
177 | }
178 |
179 | return (await getExecOutput('git', ['rev-parse', HEAD])).stdout.trim()
180 | } finally {
181 | core.endGroup()
182 | }
183 | }
184 |
185 | export function getShortName(ref: string): string {
186 | if (!ref) return ''
187 |
188 | const heads = 'refs/heads/'
189 | const tags = 'refs/tags/'
190 |
191 | if (ref.startsWith(heads)) return ref.slice(heads.length)
192 | if (ref.startsWith(tags)) return ref.slice(tags.length)
193 |
194 | return ref
195 | }
196 |
197 | export function isGitSha(ref: string): boolean {
198 | return /^[a-z0-9]{40}$/.test(ref)
199 | }
200 |
201 | async function hasCommit(ref: string): Promise {
202 | return (await getExecOutput('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
203 | }
204 |
205 | async function getCommitCount(): Promise {
206 | const output = (await getExecOutput('git', ['rev-list', '--count', '--all'])).stdout
207 | const count = parseInt(output)
208 | return isNaN(count) ? 0 : count
209 | }
210 |
211 | async function getLocalRef(shortName: string): Promise {
212 | if (isGitSha(shortName)) {
213 | return (await hasCommit(shortName)) ? shortName : undefined
214 | }
215 |
216 | const output = (await getExecOutput('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout
217 | const refs = output
218 | .split(/\r?\n/g)
219 | .map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/))
220 | .filter(match => match !== null && match[1] === shortName)
221 | .map(match => match?.[0] ?? '') // match can't be null here but compiler doesn't understand that
222 |
223 | if (refs.length === 0) {
224 | return undefined
225 | }
226 |
227 | const remoteRef = refs.find(ref => ref.startsWith('refs/remotes/origin/'))
228 | if (remoteRef) {
229 | return remoteRef
230 | }
231 |
232 | return refs[0]
233 | }
234 |
235 | async function ensureRefAvailable(name: string): Promise {
236 | core.startGroup(`Ensuring ${name} is fetched from origin`)
237 | try {
238 | let ref = await getLocalRef(name)
239 | if (ref === undefined) {
240 | await getExecOutput('git', ['fetch', '--depth=1', '--no-tags', 'origin', name])
241 | ref = await getLocalRef(name)
242 | if (ref === undefined) {
243 | await getExecOutput('git', ['fetch', '--depth=1', '--tags', 'origin', name])
244 | ref = await getLocalRef(name)
245 | if (ref === undefined) {
246 | throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`)
247 | }
248 | }
249 | }
250 |
251 | return ref
252 | } finally {
253 | core.endGroup()
254 | }
255 | }
256 |
257 | function fixStdOutNullTermination(): void {
258 | // Previous command uses NULL as delimiters and output is printed to stdout.
259 | // We have to make sure next thing written to stdout will start on new line.
260 | // Otherwise things like ::set-output wouldn't work.
261 | core.info('')
262 | }
263 |
264 | const statusMap: {[char: string]: ChangeStatus} = {
265 | A: ChangeStatus.Added,
266 | C: ChangeStatus.Copied,
267 | D: ChangeStatus.Deleted,
268 | M: ChangeStatus.Modified,
269 | R: ChangeStatus.Renamed,
270 | U: ChangeStatus.Unmerged
271 | }
272 |
--------------------------------------------------------------------------------
/src/list-format/csv-escape.ts:
--------------------------------------------------------------------------------
1 | // Returns filename escaped for CSV
2 | // Wraps file name into "..." only when it contains some potentially unsafe character
3 | export function csvEscape(value: string): string {
4 | if (value === '') return value
5 |
6 | // Only safe characters
7 | if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
8 | return value
9 | }
10 |
11 | // https://tools.ietf.org/html/rfc4180
12 | // If double-quotes are used to enclose fields, then a double-quote
13 | // appearing inside a field must be escaped by preceding it with
14 | // another double quote
15 | return `"${value.replace(/"/g, '""')}"`
16 | }
17 |
--------------------------------------------------------------------------------
/src/list-format/shell-escape.ts:
--------------------------------------------------------------------------------
1 | // Backslash escape every character except small subset of definitely safe characters
2 | export function backslashEscape(value: string): string {
3 | return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1')
4 | }
5 |
6 | // Returns filename escaped for usage as shell argument.
7 | // Applies "human readable" approach with as few escaping applied as possible
8 | export function shellEscape(value: string): string {
9 | if (value === '') return value
10 |
11 | // Only safe characters
12 | if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
13 | return value
14 | }
15 |
16 | if (value.includes("'")) {
17 | // Only safe characters, single quotes and white-spaces
18 | if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
19 | return `"${value}"`
20 | }
21 |
22 | // Split by single quote and apply escaping recursively
23 | return value.split("'").map(shellEscape).join("\\'")
24 | }
25 |
26 | // Contains some unsafe characters but no single quote
27 | return `'${value}'`
28 | }
29 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 | import * as core from '@actions/core'
3 | import * as github from '@actions/github'
4 | import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
5 | import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
6 |
7 | import {
8 | isPredicateQuantifier,
9 | Filter,
10 | FilterConfig,
11 | FilterResults,
12 | PredicateQuantifier,
13 | SUPPORTED_PREDICATE_QUANTIFIERS
14 | } from './filter'
15 | import {File, ChangeStatus} from './file'
16 | import * as git from './git'
17 | import {backslashEscape, shellEscape} from './list-format/shell-escape'
18 | import {csvEscape} from './list-format/csv-escape'
19 |
20 | type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
21 |
22 | async function run(): Promise {
23 | try {
24 | const workingDirectory = core.getInput('working-directory', {required: false})
25 | if (workingDirectory) {
26 | process.chdir(workingDirectory)
27 | }
28 |
29 | const token = core.getInput('token', {required: false})
30 | const ref = core.getInput('ref', {required: false})
31 | const base = core.getInput('base', {required: false})
32 | const filtersInput = core.getInput('filters', {required: true})
33 | const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
34 | const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
35 | const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
36 | const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME
37 |
38 | if (!isExportFormat(listFiles)) {
39 | core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
40 | return
41 | }
42 |
43 | if (!isPredicateQuantifier(predicateQuantifier)) {
44 | const predicateQuantifierInvalidErrorMsg =
45 | `Input parameter 'predicate-quantifier' is set to invalid value ` +
46 | `'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
47 | throw new Error(predicateQuantifierInvalidErrorMsg)
48 | }
49 | const filterConfig: FilterConfig = {predicateQuantifier}
50 |
51 | const filter = new Filter(filtersYaml, filterConfig)
52 | const files = await getChangedFiles(token, base, ref, initialFetchDepth)
53 | core.info(`Detected ${files.length} changed files`)
54 | const results = filter.match(files)
55 | exportResults(results, listFiles)
56 | } catch (error) {
57 | core.setFailed(getErrorMessage(error))
58 | }
59 | }
60 |
61 | function isPathInput(text: string): boolean {
62 | return !(text.includes('\n') || text.includes(':'))
63 | }
64 |
65 | function getConfigFileContent(configPath: string): string {
66 | if (!fs.existsSync(configPath)) {
67 | throw new Error(`Configuration file '${configPath}' not found`)
68 | }
69 |
70 | if (!fs.lstatSync(configPath).isFile()) {
71 | throw new Error(`'${configPath}' is not a file.`)
72 | }
73 |
74 | return fs.readFileSync(configPath, {encoding: 'utf8'})
75 | }
76 |
77 | async function getChangedFiles(token: string, base: string, ref: string, initialFetchDepth: number): Promise {
78 | // if base is 'HEAD' only local uncommitted changes will be detected
79 | // This is the simplest case as we don't need to fetch more commits or evaluate current/before refs
80 | if (base === git.HEAD) {
81 | if (ref) {
82 | core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
83 | }
84 | return await git.getChangesOnHead()
85 | }
86 |
87 | const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target']
88 | if (prEvents.includes(github.context.eventName)) {
89 | if (ref) {
90 | core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
91 | }
92 | if (base) {
93 | core.warning(`'base' input parameter is ignored when action is triggered by pull request event`)
94 | }
95 | const pr = github.context.payload.pull_request as PullRequestEvent
96 | if (token) {
97 | return await getChangedFilesFromApi(token, pr)
98 | }
99 | if (github.context.eventName === 'pull_request_target') {
100 | // pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
101 | // Therefor it's not possible to look at changes in last commit
102 | // At the same time we don't want to fetch any code from forked repository
103 | throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
104 | }
105 | core.info('Github token is not available - changes will be detected using git diff')
106 | const baseSha = github.context.payload.pull_request?.base.sha
107 | const defaultBranch = github.context.payload.repository?.default_branch
108 | const currentRef = await git.getCurrentRef()
109 | return await git.getChanges(base || baseSha || defaultBranch, currentRef)
110 | } else {
111 | return getChangedFilesFromGit(base, ref, initialFetchDepth)
112 | }
113 | }
114 |
115 | async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise {
116 | const defaultBranch = github.context.payload.repository?.default_branch
117 |
118 | const beforeSha = github.context.eventName === 'push' ? (github.context.payload as PushEvent).before : null
119 |
120 | const currentRef = await git.getCurrentRef()
121 |
122 | head = git.getShortName(head || github.context.ref || currentRef)
123 | base = git.getShortName(base || defaultBranch)
124 |
125 | if (!head) {
126 | throw new Error(
127 | "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"
128 | )
129 | }
130 |
131 | if (!base) {
132 | throw new Error(
133 | "This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload"
134 | )
135 | }
136 |
137 | const isBaseSha = git.isGitSha(base)
138 | const isBaseSameAsHead = base === head
139 |
140 | // If base is commit SHA we will do comparison against the referenced commit
141 | // Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit
142 | if (isBaseSha || isBaseSameAsHead) {
143 | const baseSha = isBaseSha ? base : beforeSha
144 | if (!baseSha) {
145 | core.warning(`'before' field is missing in event payload - changes will be detected from last commit`)
146 | if (head !== currentRef) {
147 | core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
148 | }
149 | return await git.getChangesInLastCommit()
150 | }
151 |
152 | // If there is no previously pushed commit,
153 | // we will do comparison against the default branch or return all as added
154 | if (baseSha === git.NULL_SHA) {
155 | if (defaultBranch && base !== defaultBranch) {
156 | core.info(
157 | `First push of a branch detected - changes will be detected against the default branch ${defaultBranch}`
158 | )
159 | return await git.getChangesSinceMergeBase(defaultBranch, head, initialFetchDepth)
160 | } else {
161 | core.info('Initial push detected - all files will be listed as added')
162 | if (head !== currentRef) {
163 | core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
164 | }
165 | return await git.listAllFilesAsAdded()
166 | }
167 | }
168 |
169 | core.info(`Changes will be detected between ${baseSha} and ${head}`)
170 | return await git.getChanges(baseSha, head)
171 | }
172 |
173 | core.info(`Changes will be detected between ${base} and ${head}`)
174 | return await git.getChangesSinceMergeBase(base, head, initialFetchDepth)
175 | }
176 |
177 | // Uses github REST api to get list of files changed in PR
178 | async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEvent): Promise {
179 | core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`)
180 | try {
181 | const client = github.getOctokit(token)
182 | const per_page = 100
183 | const files: File[] = []
184 |
185 | core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, per_page: ${per_page})`)
186 | for await (const response of client.paginate.iterator(
187 | client.rest.pulls.listFiles.endpoint.merge({
188 | owner: github.context.repo.owner,
189 | repo: github.context.repo.repo,
190 | pull_number: pullRequest.number,
191 | per_page
192 | })
193 | )) {
194 | if (response.status !== 200) {
195 | throw new Error(`Fetching list of changed files from GitHub API failed with error code ${response.status}`)
196 | }
197 | core.info(`Received ${response.data.length} items`)
198 |
199 | for (const row of response.data as GetResponseDataTypeFromEndpointMethod) {
200 | core.info(`[${row.status}] ${row.filename}`)
201 | // There's no obvious use-case for detection of renames
202 | // Therefore we treat it as if rename detection in git diff was turned off.
203 | // Rename is replaced by delete of original filename and add of new filename
204 | if (row.status === ChangeStatus.Renamed) {
205 | files.push({
206 | filename: row.filename,
207 | status: ChangeStatus.Added
208 | })
209 | files.push({
210 | // 'previous_filename' for some unknown reason isn't in the type definition or documentation
211 | filename: (row).previous_filename as string,
212 | status: ChangeStatus.Deleted
213 | })
214 | } else {
215 | // Github status and git status variants are same except for deleted files
216 | const status = row.status === 'removed' ? ChangeStatus.Deleted : (row.status as ChangeStatus)
217 | files.push({
218 | filename: row.filename,
219 | status
220 | })
221 | }
222 | }
223 | }
224 |
225 | return files
226 | } finally {
227 | core.endGroup()
228 | }
229 | }
230 |
231 | function exportResults(results: FilterResults, format: ExportFormat): void {
232 | core.info('Results:')
233 | const changes = []
234 | for (const [key, files] of Object.entries(results)) {
235 | const value = files.length > 0
236 | core.startGroup(`Filter ${key} = ${value}`)
237 | if (files.length > 0) {
238 | changes.push(key)
239 | core.info('Matching files:')
240 | for (const file of files) {
241 | core.info(`${file.filename} [${file.status}]`)
242 | }
243 | } else {
244 | core.info('Matching files: none')
245 | }
246 |
247 | core.setOutput(key, value)
248 | core.setOutput(`${key}_count`, files.length)
249 | if (format !== 'none') {
250 | const filesValue = serializeExport(files, format)
251 | core.setOutput(`${key}_files`, filesValue)
252 | }
253 | core.endGroup()
254 | }
255 |
256 | if (results['changes'] === undefined) {
257 | const changesJson = JSON.stringify(changes)
258 | core.info(`Changes output set to ${changesJson}`)
259 | core.setOutput('changes', changesJson)
260 | } else {
261 | core.info('Cannot set changes output variable - name already used by filter output')
262 | }
263 | }
264 |
265 | function serializeExport(files: File[], format: ExportFormat): string {
266 | const fileNames = files.map(file => file.filename)
267 | switch (format) {
268 | case 'csv':
269 | return fileNames.map(csvEscape).join(',')
270 | case 'json':
271 | return JSON.stringify(fileNames)
272 | case 'escape':
273 | return fileNames.map(backslashEscape).join(' ')
274 | case 'shell':
275 | return fileNames.map(shellEscape).join(' ')
276 | default:
277 | return ''
278 | }
279 | }
280 |
281 | function isExportFormat(value: string): value is ExportFormat {
282 | return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
283 | }
284 |
285 | function getErrorMessage(error: unknown): string {
286 | if (error instanceof Error) return error.message
287 | return String(error)
288 | }
289 |
290 | run()
291 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
5 | "outDir": "./lib", /* Redirect output structure to the directory. */
6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
7 | "strict": true, /* Enable all strict type-checking options. */
8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
10 | },
11 | "exclude": ["node_modules", "**/*.test.ts"]
12 | }
13 |
--------------------------------------------------------------------------------