├── .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 | --------------------------------------------------------------------------------