Compare commits

..

48 Commits

Author SHA1 Message Date
Michal Dorner
ceb8a2b8f2 Update CHANGELOG.md for v4.0.3 and v3.0.4 (#327) 2026-08-05 14:05:15 +02:00
Michal Dorner
ef09b88f3e Document safe handling of file list outputs in workflows (#326)
Recommend passing *_files values through env: instead of interpolating them directly into run: scripts, and update README examples and CI workflows to follow that pattern.

Credits: https://github.com/tjswlsgg
2026-08-05 13:45:25 +02:00
Michal Dorner
44adc5b06d Merge commit from fork
fix: escape multi-line filenames in list-files shell and csv output
2026-08-05 12:02:19 +02:00
Pavel Kutáč
4711b7a31b feat: add 'some-with-excludes' predicate quantifier (#322) 2026-07-31 17:44:02 -04:00
Ken Matsui
93c889f9e5 fix: escape multi-line filenames in list-files shell and csv output
Fixes GHSA-7hc6-8hq5-9q2m
2026-07-30 23:48:19 -04:00
JiHwan Oh
b41dfa943b docs: add contents permission to PR example (#248)
Co-authored-by: 오지환 (Cooper) <cooper@querypie.com>
2026-07-09 17:57:09 -04:00
Sascha Bratton
9af6e5a9d0 fix: scope base-ignored warning to API path (#319) 2026-07-02 21:33:01 -04:00
Michael Hinton
cae9006b65 docs: update outputs in readme to account for the 'every' predicate-quantifier (#247)
Co-authored-by: Sascha Bratton <sascha@queen.one>
2026-07-02 15:50:34 -04:00
Sascha Bratton
7b450fff21 docs: update changelog for v4.0.2 (#318) 2026-07-02 13:45:19 -04:00
Sascha Bratton
928037783a fix: work around git dubious ownership errors in container jobs (#317) 2026-07-02 13:41:07 -04:00
Sascha Bratton
f3ceefdc7e fix: use rev-parse instead of branch --show-current for older git compat (#303) 2026-04-10 17:44:48 -04:00
Lucas Servén Marín
61f87a10cd chore: fix GitHub spelling in logs (#278)
* chore: fix GitHub spelling in logs

* fix: rebuild dist

---------

Co-authored-by: Sascha Bratton <sascha@queen.one>
2026-03-25 12:27:15 -04:00
Carly Gundy
b82ff81ffb fix warning message (#282)
* fix warning message

* fix: rebuild dist

---------

Co-authored-by: Sascha Bratton <sascha@queen.one>
2026-03-25 12:19:27 -04:00
Masaru Iritani
fbd0ab8f3e feat: add merge_group event support
* Detect commit hashes from merge_group event

* Apply suggestion from @masaru-iritani

Co-authored-by: Masaru Iritani <25241373+masaru-iritani@users.noreply.github.com>

* refactor: update PullRequest type usage in getChangedFilesFromApi and related functions

* Run `npm run pack`

---------

Co-authored-by: Sascha Bratton <sascha@brattonbratton.com>
2026-03-13 21:50:09 -04:00
Sascha Bratton
efb1da7ce8 feat: add dist/ freshness check to PR workflow 2026-03-13 21:45:08 -04:00
Michal Dorner
d8f7b061b2 Merge pull request #302 from dorny/issue-299
Update README for v4
2026-03-13 20:16:52 +01:00
Michal Dorner
addbc147a9 Update README for v4 2026-03-13 20:14:22 +01:00
Michal Dorner
9d7afb8d21 Update CHANGELOG for v4.0.0 2026-03-12 23:21:58 +01:00
Michal Dorner
782470c5d9 Merge branch 'releases/v3' 2026-03-12 23:17:21 +01:00
Michal Dorner
d1c1ffe024 Update CHANGELOG for v3.0.3 2026-03-12 22:44:02 +01:00
Michal Dorner
ce10459c8b Merge pull request #294 from saschabratton/master
feat: update action runtime to node24
2026-03-11 20:07:23 +01:00
Sascha Bratton
5f40380c54 feat: update action runtime to node24 2026-03-11 11:32:58 -04:00
Michal Dorner
668c092af3 Merge pull request #279 from wardpeet/patch-1
Add missing predicate-quantifier
2025-11-25 21:31:09 +01:00
Ward Peeters
209e61402d Add missing predicate-quantifier 2025-09-12 22:58:41 +02:00
Michal Dorner
de90cc6fb3 Update dist and CHANGELOG for v3.0.2 2024-03-02 23:11:12 +01:00
Michal Dorner
cf89abdbae Merge pull request #224 from petermetz/feat-filter-predicate-quantifier
feat: add config parameter for predicate quantifier
2024-03-02 22:28:32 +01:00
Peter Somogyvari
f90d5265d6 feat: add config parameter for predicate quantifier
Setting the new 'predicate-quantifier' configuration parameter to 'every'
makes it so that all the patterns have to match a file for it to be
considered changed.

This can be leveraged to ensure that you only build & test software changes
that have real impact on the behavior of the code, e.g. you can set up your
build to run when Typescript/Rust/etc. files are changed but markdown
changes in the diff will be ignored and you consume less resources to build.

The default behavior does not change by the introduction of this feature
so upgrading can be done safely knowing that existing workflows will not
break.

Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
2024-02-22 12:48:55 -08:00
Michal Dorner
ebc4d7e9eb Update CHANGELOG for v3.0.1 2024-02-15 09:20:42 +01:00
Michal Dorner
45f16f1875 Merge pull request #133 from frouioui/main
Compare base and ref when token is empty
2024-02-15 09:15:48 +01:00
Michal Dorner
5da0e4c086 Merge branch 'master' 2024-02-15 09:13:51 +01:00
Michal Dorner
1441771bbf Update README.md
Add info about v3 release to What's New section
2024-01-25 08:13:18 +01:00
Michal Dorner
0bc4621a31 Bump major version to v3
Node version has been updated to 20 which might be a breaking change.
2024-01-25 07:48:07 +01:00
Michal Dorner
7267a8516b Update CHANGELOG for v2.12.0 2024-01-24 22:53:50 +01:00
Michal Dorner
e36f1124bf Merge pull request #215 from dorny/update-dependencies
Update all dependencies
2024-01-24 22:52:20 +01:00
Michal Dorner
2f74457227 Update all dependencies 2024-01-24 22:50:18 +01:00
Michal Dorner
67617953b4 Update examples in README to use checkout@v4 2024-01-24 15:51:01 +01:00
Michal Dorner
a35d8d6a33 Merge pull request #211 from chenrui333/node-20
chore(deps): bump checkout action to v4 and use setup-node to setup node and cache npm deps
2024-01-24 15:35:19 +01:00
Rui Chen
b5a5203f8b chore(deps): bump checkout action to v4 and use setup-node to setup node and cache npm deps
Signed-off-by: Rui Chen <rui@chenrui.dev>
2023-12-08 20:07:04 -05:00
Michal Dorner
3c49e64ca2 Merge pull request #210 from dorny/use-nodejs-20
Update to nodejs 20
2023-12-04 21:00:07 +01:00
Michal Dorner
8ec7be4734 Update to nodejs 20 2023-12-04 20:54:44 +01:00
Michal Dorner
100a1198b2 Revert back to node16 2023-12-04 20:42:41 +01:00
Michal Dorner
96be2b61c4 Merge pull request #206 from danielhjacobs/patch-1
Update Node.js to version 20
2023-12-04 20:37:19 +01:00
Daniel Jacobs
f5071954e8 Update Node.js to version 20 2023-10-25 13:21:33 -04:00
Michal Dorner
4067d88573 Merge pull request #178 from iamtodor/patch-1
Update README.md: added real world usage example
2022-12-11 11:00:00 +01:00
iamtodor
fc3b4e8a61 added py_linter.yaml link to demo repo 2022-12-03 13:03:55 +00:00
iamtodor
fbb4d78dec Update README.md
added blog post about this plugin's configuration and usage
2022-12-03 13:55:58 +01:00
Florent Poinsard
245527a2ef Merge remote-tracking branch 'upstream/master' 2022-10-26 15:40:33 +02:00
Florent Poinsard
5266f0ac59 Compare base and ref when token is empty
Signed-off-by: Florent Poinsard <florent.poinsard@outlook.fr>
2022-04-19 14:29:04 +02:00
19 changed files with 33412 additions and 38660 deletions

View File

@@ -1,4 +1,5 @@
name: "Build"
on:
push:
paths-ignore: [ '*.md' ]
@@ -9,7 +10,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
- run: |
npm install
npm run all
@@ -17,7 +22,7 @@ jobs:
self-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: ./
id: filter
with:

View File

@@ -10,17 +10,28 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
- run: |
npm install
npm run all
- name: Check dist is up to date
run: |
if [ -n "$(git diff --name-only dist/)" ]; then
echo "::error::dist/index.js is out of date. Run 'npm run all' and commit the result."
git diff --stat dist/
exit 1
fi
test-inline:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -41,7 +52,7 @@ jobs:
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -53,7 +64,7 @@ jobs:
test-without-token:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -63,10 +74,67 @@ jobs:
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
test-container-without-token:
runs-on: ubuntu-latest
strategy:
matrix:
# bullseye: git 2.30 + Debian CVE-2022-24765 backport, old "unsafe repository" wording,
# pre-2.32 -> ignores GIT_CONFIG_GLOBAL -> exercises the HOME-only path
# bookworm: git 2.39, "dubious ownership" wording, honors GIT_CONFIG_GLOBAL
container: ['node:24-bullseye', 'node:24-bookworm']
locale: ['']
include:
# zh_CN: git translates the dubious-ownership message via gettext - proves
# detection works on non-English stderr regardless of the container's locale.
# A CJK locale is the most adversarial probe (multibyte, non-Latin) whose
# catalog actually translates this message (ja does not exist, ko lacks it)
- container: 'node:24-bookworm'
locale: 'zh_CN.UTF-8'
container: ${{ matrix.container }}
steps:
- uses: actions/checkout@v6
- name: Generate locale
if: matrix.locale != ''
run: |
apt-get update
apt-get install -y locales
echo '${{ matrix.locale }} UTF-8' >> /etc/locale.gen
locale-gen
- name: Verify dubious ownership is reproduced
run: |
if git status; then
echo "::error::git succeeded - environment no longer reproduces dubious ownership"
exit 1
fi
- name: Verify git message is localized
if: matrix.locale != ''
env:
LC_ALL: ${{ matrix.locale }}
run: |
if stderr=$(git status 2>&1 >/dev/null); then
echo "::error::git succeeded - environment no longer reproduces dubious ownership"
exit 1
fi
echo "$stderr"
if echo "$stderr" | grep -qE 'dubious ownership|unsafe repository'; then
echo "::error::git message is not translated - the locale variant would not test anything"
exit 1
fi
- uses: ./
id: filter
env:
LC_ALL: ${{ matrix.locale }}
with:
token: ''
filters: '.github/filters.yml'
- name: filter-test
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
run: exit 1
test-wd-without-token:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
path: somewhere
- uses: ./somewhere
@@ -82,7 +150,7 @@ jobs:
test-local-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- run: echo "NEW FILE" > local
- run: git add local
- uses: ./
@@ -99,10 +167,47 @@ jobs:
if: steps.filter.outputs.local_count != 1
run: exit 1
test-predicate-quantifier-some-with-excludes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: modify working tree
run: |
mkdir -p mobile/.config
echo "TEST" > mobile/main.kt
echo "TEST" > mobile/README.md
echo "TEST" > mobile/.config/lint.json
echo "TEST" > backend.go
git add -A
- uses: ./
id: filter
with:
base: HEAD
list-files: shell
predicate-quantifier: 'some-with-excludes'
filters: |
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
excludesOnly:
- '!**/*.md'
- name: Print 'mobile_files'
env:
MOBILE_FILES: ${{ steps.filter.outputs.mobile_files }}
run: echo "$MOBILE_FILES"
- name: filter-test
if: |
steps.filter.outputs.mobile != 'true'
|| steps.filter.outputs.mobile_count != 1
|| steps.filter.outputs.mobile_files != 'mobile/main.kt'
|| steps.filter.outputs.excludesOnly != 'false'
run: exit 1
test-change-type:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: configure GIT user
run: git config user.email "john@nowhere.local" && git config user.name "John Doe"
- name: modify working tree
@@ -124,11 +229,17 @@ jobs:
any:
- added|deleted|modified: "*"
- name: Print 'added_files'
run: echo ${{steps.filter.outputs.added_files}}
env:
ADDED_FILES: ${{ steps.filter.outputs.added_files }}
run: echo "$ADDED_FILES"
- name: Print 'modified_files'
run: echo ${{steps.filter.outputs.modified_files}}
env:
MODIFIED_FILES: ${{ steps.filter.outputs.modified_files }}
run: echo "$MODIFIED_FILES"
- name: Print 'deleted_files'
run: echo ${{steps.filter.outputs.deleted_files}}
env:
DELETED_FILES: ${{ steps.filter.outputs.deleted_files }}
run: echo "$DELETED_FILES"
- name: filter-test
if: |
steps.filter.outputs.added != 'true'

View File

@@ -1,5 +1,40 @@
# Changelog
## v4.0.3
- [Document safe handling of file list outputs in workflows](https://github.com/dorny/paths-filter/pull/326)
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
- [Add 'some-with-excludes' predicate quantifier](https://github.com/dorny/paths-filter/pull/322)
- [Add contents permission to PR example](https://github.com/dorny/paths-filter/pull/248)
- [Scope base-ignored warning to API path](https://github.com/dorny/paths-filter/pull/319)
- [Update outputs in readme to account for the 'every' predicate-quantifier](https://github.com/dorny/paths-filter/pull/247)
## v4.0.2
- [Work around git dubious ownership errors in container jobs](https://github.com/dorny/paths-filter/pull/317)
- [Use rev-parse instead of branch --show-current for older git compat](https://github.com/dorny/paths-filter/pull/303)
- [Fix warning message](https://github.com/dorny/paths-filter/pull/282)
## v4.0.1
- [Support merge queue](https://github.com/dorny/paths-filter/pull/255)
## v4.0.0
- [Update action runtime to node24](https://github.com/dorny/paths-filter/pull/294)
## v3.0.4
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
## v3.0.3
- [Add missing predicate-quantifier](https://github.com/dorny/paths-filter/pull/279)
## v3.0.2
- [Add config parameter for predicate quantifier](https://github.com/dorny/paths-filter/pull/224)
## v3.0.1
- [Compare base and ref when token is empty](https://github.com/dorny/paths-filter/pull/133)
## v3.0.0
- [Update to Node.js 20](https://github.com/dorny/paths-filter/pull/210)
- [Update all dependencies](https://github.com/dorny/paths-filter/pull/215)
## v2.11.1
- [Update @actions/core to v1.10.0 - Fixes warning about deprecated set-output](https://github.com/dorny/paths-filter/pull/167)
- [Document need for pull-requests: read permission](https://github.com/dorny/paths-filter/pull/168)

177
README.md
View File

@@ -11,6 +11,7 @@ don't allow this because they don't work on a level of individual jobs or steps.
- [sentry.io](https://sentry.io/) - [backend.yml](https://github.com/getsentry/sentry/blob/2ebe01feab863d89aa7564e6d243b6d80c230ddc/.github/workflows/backend.yml#L36)
- [GoogleChrome/web.dev](https://web.dev/) - [lint-workflow.yml](https://github.com/GoogleChrome/web.dev/blob/3a57b721e7df6fc52172f676ca68d16153bda6a3/.github/workflows/lint-workflow.yml#L26)
- [blog post Configuring python linting to be part of CI/CD using GitHub actions](https://dev.to/freshbooks/configuring-python-linting-to-be-part-of-cicd-using-github-actions-1731#what-files-does-it-run-against) - [py_linter.yml](https://github.com/iamtodor/demo-github-actions-python-linter-configuration/blob/main/.github/workflows/py_linter.yml#L31)
## Supported workflows
@@ -26,6 +27,11 @@ don't allow this because they don't work on a level of individual jobs or steps.
- The `base` input parameter must not be the same as the branch that triggered the workflow
- Changes are detected against the merge-base with the configured base branch or the default branch
- Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
- **[Merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue):**
- Workflow triggered by **[merge_group](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#merge_group)**
- The `base` and `ref` input parameters default to commit hashes from the event
unless explicitly specified.
- Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout)
- **Master, Release, or other long-lived branches:**
- Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)** event
when `base` input parameter is the same as the branch that triggered the workflow:
@@ -45,7 +51,7 @@ don't allow this because they don't work on a level of individual jobs or steps.
## Example
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: changes
with:
filters: |
@@ -61,6 +67,10 @@ For more scenarios see [examples](#examples) section.
## Notes
- **Security:** `${FILTER_NAME}_files` outputs contain filenames that may be attacker-influenced on pull requests.
Do not interpolate them directly into a `run:` script with `${{ ... }}`.
Pass the value through `env:` and reference the variable from the shell instead.
See [Custom processing of changed files](#custom-processing-of-changed-files).
- Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
Documentation for path expression format can be found on the project GitHub page.
- Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
@@ -68,9 +78,16 @@ For more scenarios see [examples](#examples) section.
- It's recommended to quote your path expressions with `'` or `"`. Otherwise, you will get an error if it starts with `*`.
- Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary.
- Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04`
- Git `dubious ownership` errors in [container jobs](https://docs.github.com/en/actions/using-containerized-services/running-jobs-in-a-container) are handled automatically -
the action retries with a temporary `HOME` containing a `safe.directory` entry, the same technique used by [actions/checkout](https://github.com/actions/checkout).
Only if fetching relies on credentials stored in `HOME`-relative files (e.g. `~/.git-credentials` or `~/.netrc`),
mark the repository as safe yourself in a step before this action: `git config --global --add safe.directory "$GITHUB_WORKSPACE"`
## What's New
- Add `some-with-excludes` value of the `predicate-quantifier` input parameter
- Automatic workaround for git `dubious ownership` errors in container jobs
- New major release `v4` after update to Node 24 [Breaking change]
- Add `ref` input parameter
- Add `list-files: csv` format
- Configure matrix job to run for each folder with changes using `changes` output
@@ -82,7 +99,7 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
## Usage
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
with:
# Defines filters applied to detected changed files.
# Each filter has a name and a list of rules.
@@ -102,19 +119,24 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
# Branch, tag, or commit SHA against which the changes will be detected.
# If it references the same branch it was pushed to,
# changes are detected against the most recent commit before the push.
# If it is empty and action is triggered by merge_group event,
# the base commit in the event will be used.
# Otherwise, it uses git merge-base to find the best common ancestor between
# current branch (HEAD) and base.
# When merge-base is found, it's used for change detection - only changes
# introduced by the current branch are considered.
# All files are considered as added if there is no common ancestor with
# base branch or no previous commit.
# This option is ignored if action is triggered by pull_request event.
# This option is ignored if action is triggered by pull_request event,
# unless 'token' is set to an empty string (see the 'token' input below).
# Default: repository default branch (e.g. master)
base: ''
# Git reference (e.g. branch name) from which the changes will be detected.
# Useful when workflow can be triggered only on the default branch (e.g. repository_dispatch event)
# but you want to get changes on a different branch.
# If this is empty and action is triggered by merge_group event,
# the head commit in the event will be used.
# This option is ignored if action is triggered by pull_request event.
# default: ${{ github.ref }}
ref:
@@ -148,18 +170,46 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
# It's only used if action is triggered by a pull request event.
# GitHub token from workflow context is used as default value.
# If an empty string is provided, the action falls back to detect
# changes using git commands.
# changes using git commands. In that case, on pull request events
# the 'base' input overrides the pull request base - e.g. set
# base: ${{ github.event.before }} to detect changes since the last push.
# Default: ${{ github.token }}
token: ''
# Optional parameter to override the default behavior of file matching algorithm.
# Supported values:
# 'some' - File is included if it matches at least one pattern (default).
# 'every' - File is included only if it matches all of the patterns.
# 'some-with-excludes' - File is included if it matches at least one pattern
# and no negated pattern (the ones prefixed with '!').
#
# An example scenario where this is useful if you would like to match all
# .ts files in a sub-directory but not .md files.
# The filters below will match markdown files despite the exclusion syntax UNLESS
# you specify 'every' or 'some-with-excludes' as the predicate-quantifier parameter.
# When you do that, it will only match the .ts files in the subdirectory as expected.
#
# backend:
# - 'pkg/a/b/c/**'
# - '!**/*.jpeg'
# - '!**/*.md'
predicate-quantifier: 'some'
```
## Outputs
- For each filter, it sets output variable named by the filter to the text:
- `'true'` - if **any** of changed files matches any of filter rules
- `'false'` - if **none** of changed files matches any of filter rules
- For each filter, it sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter.
- Each filter sets an output variable, named after the filter, whose text value depends on the `predicate-quantifier` setting:
- With `predicate-quantifier: 'some'` (default):
- `'true'` - if **any** changed file matches **at least one** of the filter's rules
- `'false'` - if **no** changed file matches **at least one** of the filter's rules
- With `predicate-quantifier: 'every'`:
- `'true'` - if **any** changed file matches **all** of the filter's rules
- `'false'` - if **no** changed file matches **all** of the filter's rules
- With `predicate-quantifier: 'some-with-excludes'`:
- `'true'` - if **any** changed file matches **at least one** of the filter's rules and **none** of its negated rules
- `'false'` - if **no** changed file matches **at least one** of the filter's rules and **none** of its negated rules
- Each filter sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter. Treat these values as untrusted when filenames can come from pull requests.
- `changes` - JSON array with names of all filters matching any of the changed files.
## Examples
@@ -174,8 +224,8 @@ jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -219,7 +269,7 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -234,7 +284,7 @@ jobs:
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- ...
# JOB to build and test frontend code
@@ -243,7 +293,7 @@ jobs:
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- ...
```
@@ -265,7 +315,7 @@ jobs:
packages: ${{ steps.filter.outputs.changes }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -282,7 +332,7 @@ jobs:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- ...
```
@@ -299,15 +349,22 @@ on:
branches: # PRs to the following branches will trigger the workflow
- master
- develop
# Optionally you can use the action in the merge queue
# if your repository enables the feature.
merge_group:
branches:
- master
- develop
jobs:
build:
runs-on: ubuntu-latest
# Required permissions
permissions:
pull-requests: read
contents: read # required by actions/checkout
pull-requests: read # required by dorny/paths-filter
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: ... # Configure your filters
@@ -327,12 +384,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
with:
# This may save additional git fetch roundtrip if
# merge-base is found within latest 20 commits
fetch-depth: 20
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
base: develop # Change detection against merge-base with this branch
@@ -355,8 +412,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
# Use context to get the branch where commits were pushed.
@@ -383,14 +440,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
# Some action that modifies files tracked by git (e.g. code linter)
- uses: johndoe/some-action@v1
# Filter to detect which files were modified
# Changes could be, for example, automatically committed
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
base: HEAD
@@ -405,7 +462,7 @@ jobs:
<summary>Define filter rules in own file</summary>
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
# Path to file where filters are defined
@@ -418,7 +475,7 @@ jobs:
<summary>Use YAML anchors to reuse path expression(s) inside another rule</summary>
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
# &shared is YAML anchor,
@@ -439,7 +496,7 @@ jobs:
<summary>Consider if file was added, modified or deleted</summary>
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
# Changed file can be 'added', 'modified', or 'deleted'.
@@ -461,13 +518,65 @@ jobs:
</details>
<details>
<summary>Detect changes in folder only for some file extensions</summary>
```yaml
- uses: dorny/paths-filter@v4
id: filter
with:
# This makes it so that all the patterns have to match a file for it to be
# considered changed. Because we have the exclusions for .jpeg and .md files
# the end result is that if those files are changed they will be ignored
# because they don't match the respective rules excluding them.
#
# This can be leveraged to ensure that you only build & test software changes
# that have real impact on the behavior of the code, e.g. you can set up your
# build to run when Typescript/Rust/etc. files are changed but markdown
# changes in the diff will be ignored and you consume less resources to build.
predicate-quantifier: 'every'
filters: |
backend:
- 'pkg/a/b/c/**'
- '!**/*.jpeg'
- '!**/*.md'
```
</details>
<details>
<summary>Detect changes in multiple unrelated paths and exclude some file extensions</summary>
```yaml
- uses: dorny/paths-filter@v4
id: filter
with:
# With 'some-with-excludes' a file is matched when it matches at least one pattern
# and none of the negated ones. The filter below therefore matches all the files
# in the 'mobile' folder and the workflow file, but never a markdown file or
# anything in 'mobile/.config'.
#
# An exclusion is final - a file excluded by one pattern can't be included back
# by another one. Consequently, a filter consisting of negated patterns only
# never matches anything.
predicate-quantifier: 'some-with-excludes'
filters: |
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
- '.github/workflows/test_mobile.yml'
```
</details>
### Custom processing of changed files
<details>
<summary>Passing list of modified files as command line args in Linux shell</summary>
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
# Enable listing of files matching each filter.
@@ -484,16 +593,20 @@ jobs:
- added|modified: '*.md'
- name: Lint Markdown
if: ${{ steps.filter.outputs.markdown == 'true' }}
run: npx textlint ${{ steps.filter.outputs.markdown_files }}
env:
MARKDOWN_FILES: ${{ steps.filter.outputs.markdown_files }}
run: npx textlint $MARKDOWN_FILES
```
When passing file lists to shell commands, use `env:` as shown above. Do not write `${{ steps.filter.outputs.markdown_files }}` directly inside the `run:` script.
</details>
<details>
<summary>Passing list of modified files as JSON array to another action</summary>
```yaml
- uses: dorny/paths-filter@v2
- uses: dorny/paths-filter@v4
id: filter
with:
# Enable listing of files matching each filter.
@@ -512,6 +625,8 @@ jobs:
files: ${{ steps.filter.outputs.changed_files }}
```
The `json` and `csv` formats are intended as structured data for scripts, programs, or other actions. Passing them to an action input as above is fine. Do not interpolate `json` or `csv` outputs directly into a `run:` script.
</details>
## See also

View File

@@ -20,4 +20,12 @@ describe('csvEscape() backslash escapes every character except subset of definit
test('Double quote should be escaped by another double quote', () => {
expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
})
test('filename with LF should be quoted per RFC 4180', () => {
expect(csvEscape('a\nb')).toBe('"a\nb"')
})
test('filename with CRLF should be quoted per RFC 4180', () => {
expect(csvEscape('a\r\nb')).toBe('"a\r\nb"')
})
})

View File

@@ -1,4 +1,4 @@
import {Filter} from '../src/filter'
import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter'
import {File, ChangeStatus} from '../src/file'
describe('yaml filter parsing tests', () => {
@@ -117,6 +117,130 @@ describe('matching tests', () => {
expect(pyMatch.backend).toEqual(pyFiles)
})
test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => {
const yaml = `
backend:
- 'pkg/a/b/c/**'
- '!**/*.jpeg'
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY}
const filter = new Filter(yaml, filterConfig)
const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts'])
const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts'])
const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg'])
const docsFiles = modified([
'pkg/a/b/c/some-pics.jpeg',
'pkg/a/b/c/src/main/jpeg/some-pic.jpeg',
'pkg/a/b/c/src/main/some-docs.md',
'pkg/a/b/c/some-docs.md'
])
const typescriptMatch = filter.match(typescriptFiles)
const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles)
const docsMatch = filter.match(docsFiles)
const otherPkgJpegMatch = filter.match(otherPkgJpegFiles)
expect(typescriptMatch.backend).toEqual(typescriptFiles)
expect(otherPkgTypescriptMatch.backend).toEqual([])
expect(docsMatch.backend).toEqual([])
expect(otherPkgJpegMatch.backend).toEqual([])
})
test('ignores exclusions when using the default predicate quantifier', () => {
const yaml = `
src:
- 'src/**'
- '!**/*.md'
`
const filter = new Filter(yaml)
// A negated pattern is just another pattern for the 'some' quantifier - a markdown file
// inside 'src' still matches 'src/**' and any other file matches the negated pattern.
const files = modified(['src/README.md', 'other/file.txt'])
expect(filter.match(files).src).toEqual(files)
})
test('matches files of every pattern when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
mobile:
- 'mobile/**'
- '!mobile/**/*.md'
- '!mobile/.config/**'
- '.github/workflows/test_mobile.yml'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const sourceFiles = modified(['mobile/main.kt', 'mobile/src/some/Activity.kt'])
const workflowFiles = modified(['.github/workflows/test_mobile.yml'])
const docsFiles = modified(['mobile/README.md', 'mobile/docs/some/page.md'])
const configFiles = modified(['mobile/.config/lint.json', 'mobile/.config/nested/lint.json'])
const otherFiles = modified(['backend/main.go', '.github/workflows/test_backend.yml'])
expect(filter.match(sourceFiles).mobile).toEqual(sourceFiles)
expect(filter.match(workflowFiles).mobile).toEqual(workflowFiles)
expect(filter.match(docsFiles).mobile).toEqual([])
expect(filter.match(configFiles).mobile).toEqual([])
expect(filter.match(otherFiles).mobile).toEqual([])
})
test('excludes file with PredicateQuantifier.SOME_WITH_EXCLUDES regardless of the pattern order', () => {
const yaml = `
excludeFirst:
- '!**/*.md'
- 'src/**'
excludeLast:
- 'src/**'
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/index.ts', 'src/README.md']))
expect(match.excludeFirst).toEqual(modified(['src/index.ts']))
expect(match.excludeLast).toEqual(modified(['src/index.ts']))
})
test('keeps file excluded with PredicateQuantifier.SOME_WITH_EXCLUDES even if a later pattern includes it', () => {
const yaml = `
src:
- 'src/**'
- '!**/*.md'
- 'src/docs/**'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/docs/guide.md', 'src/docs/logo.png']))
expect(match.src).toEqual(modified(['src/docs/logo.png']))
})
test('matches nothing with PredicateQuantifier.SOME_WITH_EXCLUDES when there is no include pattern', () => {
const yaml = `
src:
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const match = filter.match(modified(['src/index.ts', 'src/README.md']))
expect(match.src).toEqual([])
})
test('treats negated extglob as an include pattern with PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
backend:
- '!(**/*.tsx|**/*.less)'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
expect(filter.match(modified(['src/server.py'])).backend).toEqual(modified(['src/server.py']))
expect(filter.match(modified(['src/ui.tsx'])).backend).toEqual([])
})
test('matches path based on rules included using YAML anchor', () => {
const yaml = `
shared: &shared
@@ -166,6 +290,61 @@ describe('matching specific change status', () => {
expect(match.addOrModify).toEqual(files)
})
test('respects change status of exclude patterns when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
src:
- 'src/**'
- deleted: '!src/generated/**'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const files = [
{status: ChangeStatus.Deleted, filename: 'src/generated/api.ts'},
{status: ChangeStatus.Modified, filename: 'src/generated/api.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[1]])
})
test('matches multiple patterns of single change status when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => {
const yaml = `
docs: &docs
- '!**/*.md'
src:
- added|modified: 'src/**'
- added|modified: *docs
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES}
const filter = new Filter(yaml, filterConfig)
const files = [
{status: ChangeStatus.Added, filename: 'src/index.ts'},
{status: ChangeStatus.Added, filename: 'src/README.md'},
{status: ChangeStatus.Deleted, filename: 'src/legacy.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[0]])
})
test('or-es patterns of single change status when using the default predicate quantifier', () => {
const yaml = `
src:
- added|modified: ['src/**', '!**/*.md']
`
const filter = new Filter(yaml)
// Both patterns are OR-ed into a single rule, therefore a markdown file inside 'src'
// matches through 'src/**' and any other file matches through the negated pattern.
const files = [
{status: ChangeStatus.Added, filename: 'src/README.md'},
{status: ChangeStatus.Added, filename: 'other/file.txt'},
{status: ChangeStatus.Deleted, filename: 'src/index.ts'}
]
const match = filter.match(files)
expect(match.src).toEqual([files[0], files[1]])
})
test('matches when using an anchor', () => {
const yaml = `
shared: &shared
@@ -186,3 +365,9 @@ function modified(paths: string[]): File[] {
return {filename, status: ChangeStatus.Modified}
})
}
function renamed(paths: string[]): File[] {
return paths.map(filename => {
return {filename, status: ChangeStatus.Renamed}
})
}

113
__tests__/git-exec.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import {getExecOutput, ExecOutput} from '@actions/exec'
import {gitExec} from '../src/git'
import {ensureSafeDirectory, getGitEnv} from '../src/safe-directory'
jest.mock('@actions/exec')
jest.mock('../src/safe-directory', () => ({
...jest.requireActual('../src/safe-directory'),
ensureSafeDirectory: jest.fn(),
getGitEnv: jest.fn()
}))
const getExecOutputMock = getExecOutput as jest.MockedFunction<typeof getExecOutput>
const ensureSafeDirectoryMock = ensureSafeDirectory as jest.MockedFunction<typeof ensureSafeDirectory>
const getGitEnvMock = getGitEnv as jest.MockedFunction<typeof getGitEnv>
const SUCCESS_OUTPUT: ExecOutput = {exitCode: 0, stdout: 'ok', stderr: ''}
const DUBIOUS_OUTPUT: ExecOutput = {
exitCode: 128,
stdout: '',
stderr: "fatal: detected dubious ownership in repository at '/github/workspace'"
}
// clearMocks in jest.config.js does not remove queued mockResolvedValueOnce values or implementations
beforeEach(() => {
getExecOutputMock.mockReset()
ensureSafeDirectoryMock.mockReset()
getGitEnvMock.mockReset()
})
describe('gitExec', () => {
test('returns result of successful command without invoking the workaround', async () => {
getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT)
const result = await gitExec(['status'])
expect(result).toBe(SUCCESS_OUTPUT)
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({ignoreReturnCode: true}))
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
test('passes environment from getGitEnv to git', async () => {
const env = {HOME: '/temp/home'}
getGitEnvMock.mockReturnValue(env)
getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT)
await gitExec(['status'])
expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({env}))
})
test('retries once after dubious ownership error is worked around', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const result = await gitExec(['status'])
expect(result).toBe(SUCCESS_OUTPUT)
expect(ensureSafeDirectoryMock).toHaveBeenCalledWith(DUBIOUS_OUTPUT.stderr)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
for (const call of getExecOutputMock.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ignoreReturnCode: true}))
}
})
test('throws actionable error when retry still fails with dubious ownership', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(DUBIOUS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const promise = gitExec(['status'])
await expect(promise).rejects.toThrow(/detected dubious ownership/)
await expect(promise).rejects.toThrow(/safe\.directory/)
await expect(promise).rejects.toThrow(/--user/)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
})
test('throws without retry when workaround adds nothing new', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(false)
await expect(gitExec(['status'])).rejects.toThrow(/safe\.directory/)
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
})
test('returns non-dubious failure when ignoreReturnCode is set', async () => {
const failure: ExecOutput = {exitCode: 1, stdout: '', stderr: 'some error'}
getExecOutputMock.mockResolvedValueOnce(failure)
const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true})
expect(result).toBe(failure)
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
test('retries dubious ownership error even when ignoreReturnCode is set', async () => {
getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT)
ensureSafeDirectoryMock.mockResolvedValueOnce(true)
const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true})
expect(result).toBe(SUCCESS_OUTPUT)
expect(getExecOutputMock).toHaveBeenCalledTimes(2)
})
test('throws on non-dubious failure when ignoreReturnCode is not set', async () => {
getExecOutputMock.mockResolvedValueOnce({exitCode: 1, stdout: '', stderr: 'some error'})
await expect(gitExec(['fetch'])).rejects.toThrow("The process 'git fetch' failed with exit code 1")
expect(getExecOutputMock).toHaveBeenCalledTimes(1)
expect(ensureSafeDirectoryMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,256 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import {exec} from '@actions/exec'
import {
buildGitEnv,
cleanup,
createTempGitHome,
ensureSafeDirectory,
getGitEnv,
isDubiousOwnershipError,
parseRepositoryPath,
resolveTempBaseDir
} from '../src/safe-directory'
jest.mock('@actions/exec')
const execMock = exec as jest.MockedFunction<typeof exec>
const DUBIOUS_STDERR = "fatal: detected dubious ownership in repository at '/github/workspace'"
const UNSAFE_STDERR = "fatal: unsafe repository ('/github/workspace' is owned by someone else)"
describe('detection of dubious ownership errors', () => {
test('detects "detected dubious ownership" wording at exit code 128', () => {
expect(isDubiousOwnershipError(128, DUBIOUS_STDERR)).toBe(true)
})
test('detects older "unsafe repository" wording at exit code 128', () => {
expect(isDubiousOwnershipError(128, UNSAFE_STDERR)).toBe(true)
})
test('does not match other git errors at exit code 128', () => {
expect(isDubiousOwnershipError(128, 'fatal: not a git repository')).toBe(false)
})
test('does not match dubious ownership text at other exit codes', () => {
expect(isDubiousOwnershipError(1, DUBIOUS_STDERR)).toBe(false)
expect(isDubiousOwnershipError(0, DUBIOUS_STDERR)).toBe(false)
})
test('parseRepositoryPath extracts path from both wordings', () => {
expect(parseRepositoryPath(DUBIOUS_STDERR)).toBe('/github/workspace')
expect(parseRepositoryPath(UNSAFE_STDERR)).toBe('/github/workspace')
expect(parseRepositoryPath('fatal: not a git repository')).toBeUndefined()
})
})
describe('createTempGitHome', () => {
const scratchDirs: string[] = []
async function makeScratchDir(): Promise<string> {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-'))
scratchDirs.push(dir)
return dir
}
afterEach(async () => {
for (const dir of scratchDirs.splice(0)) {
await fs.promises.rm(dir, {recursive: true, force: true})
}
})
test('copies file referenced by GIT_CONFIG_GLOBAL and skips XDG fallback', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
const configFile = path.join(home, 'custom-gitconfig')
await fs.promises.writeFile(configFile, 'custom')
await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true})
await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg')
const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: configFile, HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('custom')
expect(fs.existsSync(path.join(tempHome, '.config', 'git', 'config'))).toBe(false)
})
test('does not throw when GIT_CONFIG_GLOBAL references missing file', async () => {
const base = await makeScratchDir()
const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: path.join(base, 'missing-gitconfig')})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('')
})
test('copies $HOME/.gitconfig', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
await fs.promises.writeFile(path.join(home, '.gitconfig'), 'home config')
const tempHome = await createTempGitHome(base, {HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('home config')
})
test('copies XDG fallback config only when XDG_CONFIG_HOME is unset', async () => {
const base = await makeScratchDir()
const home = await makeScratchDir()
await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true})
await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg config')
const tempHome = await createTempGitHome(base, {HOME: home})
scratchDirs.push(tempHome)
expect(await fs.promises.readFile(path.join(tempHome, '.config', 'git', 'config'), 'utf8')).toBe('xdg config')
const tempHomeWithXdg = await createTempGitHome(base, {HOME: home, XDG_CONFIG_HOME: path.join(home, '.config')})
scratchDirs.push(tempHomeWithXdg)
expect(fs.existsSync(path.join(tempHomeWithXdg, '.config', 'git', 'config'))).toBe(false)
})
test('creates an empty .gitconfig even when there is no config to copy', async () => {
const base = await makeScratchDir()
const tempHome = await createTempGitHome(base, {})
scratchDirs.push(tempHome)
expect(await fs.promises.readdir(tempHome)).toEqual(['.gitconfig'])
expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('')
})
})
describe('buildGitEnv', () => {
test('overrides HOME and GIT_CONFIG_GLOBAL, preserves other variables, drops undefined values', () => {
const env = buildGitEnv('/temp/home', {
HOME: '/root',
GIT_CONFIG_GLOBAL: '/root/.gitconfig',
PATH: '/usr/bin',
UNDEFINED_VALUE: undefined
})
expect(env['HOME']).toBe('/temp/home')
expect(env['GIT_CONFIG_GLOBAL']).toBe(path.join('/temp/home', '.gitconfig'))
expect(env['PATH']).toBe('/usr/bin')
expect('UNDEFINED_VALUE' in env).toBe(false)
})
test('leaves GIT_CONFIG_GLOBAL unset when not present in the original environment', () => {
const env = buildGitEnv('/temp/home', {HOME: '/root', PATH: '/usr/bin'})
expect(env['HOME']).toBe('/temp/home')
expect('GIT_CONFIG_GLOBAL' in env).toBe(false)
})
})
describe('resolveTempBaseDir', () => {
test('prefers RUNNER_TEMP and falls back to os.tmpdir()', () => {
expect(resolveTempBaseDir({RUNNER_TEMP: '/runner/temp'})).toBe('/runner/temp')
expect(resolveTempBaseDir({RUNNER_TEMP: ''})).toBe(os.tmpdir())
expect(resolveTempBaseDir({})).toBe(os.tmpdir())
})
})
describe('ensureSafeDirectory', () => {
const envBackup = process.env
let runnerTemp: string
beforeEach(async () => {
runnerTemp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-runner-'))
process.env = {...envBackup}
process.env['RUNNER_TEMP'] = runnerTemp
process.env['HOME'] = runnerTemp
process.env['GITHUB_WORKSPACE'] = process.cwd()
delete process.env['GIT_CONFIG_GLOBAL']
delete process.env['XDG_CONFIG_HOME']
})
afterEach(async () => {
await cleanup()
await fs.promises.rm(runnerTemp, {recursive: true, force: true})
process.env = envBackup
})
test('activates temporary HOME and adds reported directories on first call', async () => {
expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-')
const added = await ensureSafeDirectory(DUBIOUS_STDERR)
expect(added).toBe(true)
expect(getGitEnv()).toEqual(
expect.objectContaining({
HOME: expect.stringContaining('paths-filter-git-home-')
})
)
// GIT_CONFIG_GLOBAL was not set in the original environment, so it must stay unset
expect(getGitEnv()).not.toHaveProperty('GIT_CONFIG_GLOBAL')
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', '/github/workspace'],
expect.objectContaining({
env: expect.objectContaining({HOME: expect.stringContaining('paths-filter-git-home-')})
})
)
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', process.cwd()],
expect.anything()
)
})
test('returns false when repeated stderr adds no new directory', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const callCount = execMock.mock.calls.length
const added = await ensureSafeDirectory(DUBIOUS_STDERR)
expect(added).toBe(false)
expect(execMock.mock.calls.length).toBe(callCount)
})
test('adds directory reported by a later error for a different path', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const added = await ensureSafeDirectory("fatal: detected dubious ownership in repository at '/other/repo'")
expect(added).toBe(true)
expect(execMock).toHaveBeenCalledWith(
'git',
['config', '--global', '--add', 'safe.directory', '/other/repo'],
expect.anything()
)
})
test('cleanup removes the temporary HOME and resets state', async () => {
await ensureSafeDirectory(DUBIOUS_STDERR)
const tempHome = getGitEnv()['HOME']
expect(tempHome).toContain('paths-filter-git-home-')
await cleanup()
expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-')
expect(fs.existsSync(tempHome)).toBe(false)
})
test('getGitEnv mirrors process.env and forces LC_ALL=C before activation', () => {
process.env['SOME_PRESERVED_VARIABLE'] = 'preserved'
process.env['LC_ALL'] = 'de_DE.UTF-8'
const env = getGitEnv()
expect(env['SOME_PRESERVED_VARIABLE']).toBe('preserved')
expect(env['HOME']).toBe(runnerTemp)
expect(env['LC_ALL']).toBe('C')
})
test('getGitEnv contains the temporary HOME and forces LC_ALL=C after activation', async () => {
process.env['LC_ALL'] = 'de_DE.UTF-8'
await ensureSafeDirectory(DUBIOUS_STDERR)
const env = getGitEnv()
expect(env['HOME']).toContain('paths-filter-git-home-')
expect(env['LC_ALL']).toBe('C')
})
})

View File

@@ -54,4 +54,16 @@ describe('shellEscape() returns human readable filenames with as few escaping ap
test('filename with single quote and special characters is split and quoted/escaped as needed', () => {
expect(shellEscape("file'with $quote")).toBe("file\\''with $quote'")
})
test('filename with LF should be single-quoted', () => {
expect(shellEscape('x\ntouch pwned.md')).toBe("'x\ntouch pwned.md'")
})
test('filename with CRLF should be single-quoted', () => {
expect(shellEscape('x\r\ntouch pwned.md')).toBe("'x\r\ntouch pwned.md'")
})
test('filename with CR should be single-quoted', () => {
expect(shellEscape('a\rb')).toBe("'a\rb'")
})
})

View File

@@ -44,11 +44,20 @@ inputs:
This option takes effect only when changes are detected using git against different base branch.
required: false
default: '100'
predicate-quantifier:
description: |
allows to override the "at least one pattern" behavior:
'some' - file is included if it matches at least one pattern (default).
'every' - file is included only if it matches all of the patterns.
'some-with-excludes' - file is included if it matches at least one pattern
and no negated pattern (the ones prefixed with '!').
required: false
default: 'some'
outputs:
changes:
description: JSON array with names of all filters matching any of changed files
runs:
using: 'node16'
using: 'node24'
main: 'dist/index.js'
branding:
color: blue

56248
dist/index.js vendored

File diff suppressed because one or more lines are too long

14371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
{
"name": "paths-filter",
"version": "1.0.0",
"engines": {
"node": ">= 24"
},
"private": true,
"description": "Execute your workflow steps only if relevant files are modified.",
"main": "lib/main.js",
@@ -27,27 +30,26 @@
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/github": "^2.2.0",
"@octokit/webhooks": "^7.6.2",
"picomatch": "^2.2.2"
"@actions/github": "6.0.0",
"picomatch": "^2.3.1"
},
"devDependencies": {
"@types/jest": "^27.4.0",
"@types/js-yaml": "^3.12.4",
"@types/minimatch": "^3.0.3",
"@types/node": "^14.0.5",
"@types/picomatch": "^2.2.1",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"@vercel/ncc": "^0.33.1",
"eslint": "^8.17.0",
"eslint-plugin-github": "^4.3.6",
"eslint-plugin-jest": "^22.21.0",
"jest": "^27.4.7",
"jest-circus": "^27.4.6",
"js-yaml": "^3.14.0",
"prettier": "^2.0.5",
"ts-jest": "^27.1.3",
"typescript": "^3.9.3"
"@octokit/webhooks-types": "^7.3.1",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.0.0",
"@types/picomatch": "^2.3.3",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@vercel/ncc": "^0.38.1",
"eslint": "^8.56.0",
"eslint-plugin-github": "^4.10.1",
"eslint-plugin-jest": "^27.6.3",
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"js-yaml": "^4.1.0",
"prettier": "^2.8.8",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
}

View File

@@ -21,6 +21,64 @@ const MatchOptions = {
interface FilterRuleItem {
status?: ChangeStatus[] // Required change status of the matched files
isMatch: (str: string) => boolean // Matches the filename
// Matchers for the individual polarities of the patterns this item was created from.
// They are used only by the PredicateQuantifier.SOME_WITH_EXCLUDES quantifier,
// which has to tell inclusions and exclusions apart. The other quantifiers keep
// evaluating the item as a whole via 'isMatch'.
isInclude?: (str: string) => boolean // Matches any of the patterns which are not negated
isExclude?: (str: string) => boolean // Matches any of the patterns which are negated (e.g. '!**/*.md')
}
/**
* Enumerates the possible logic quantifiers that can be used when determining
* if a file is a match or not with multiple patterns.
*
* The YAML configuration property that is parsed into one of these values is
* 'predicate-quantifier' on the top level of the configuration object of the
* action.
*
* The default is to use 'some' which used to be the hardcoded behavior prior to
* the introduction of the new mechanism.
*
* @see https://en.wikipedia.org/wiki/Quantifier_(logic)
*/
export enum PredicateQuantifier {
/**
* When choosing 'every' in the config it means that files will only get matched
* if all the patterns are satisfied by the path of the file, not just at least one of them.
*/
EVERY = 'every',
/**
* When choosing 'some' in the config it means that files will get matched as long as there is
* at least one pattern that matches them. This is the default behavior if you don't
* specify anything as a predicate quantifier.
*/
SOME = 'some',
/**
* When choosing 'some-with-excludes' in the config it means that files will get matched if
* at least one of the patterns matches them and none of the negated patterns (the ones
* prefixed with '!') matches them. An exclusion is final - a file excluded by one pattern
* can't be included back by another one.
*
* A filter which consists of negated patterns only never matches anything,
* because there is no pattern which could include a file in the first place.
*/
SOME_WITH_EXCLUDES = 'some-with-excludes'
}
/**
* Used to define customizations for how the file filtering should work at runtime.
*/
export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier}
/**
* An array of strings (at runtime) that contains the valid/accepted values for
* the configuration parameter 'predicate-quantifier'.
*/
export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier)
export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier {
return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier)
}
export interface FilterResults {
@@ -31,7 +89,7 @@ export class Filter {
rules: {[key: string]: FilterRuleItem[]} = {}
// Creates instance of Filter and load rules from YAML if it's provided
constructor(yaml?: string) {
constructor(yaml?: string, readonly filterConfig?: FilterConfig) {
if (yaml) {
this.load(yaml)
}
@@ -43,7 +101,7 @@ export class Filter {
return
}
const doc = jsyaml.safeLoad(yaml) as FilterYaml
const doc = jsyaml.load(yaml) as FilterYaml
if (typeof doc !== 'object') {
this.throwInvalidFormatError('Root element is not an object')
}
@@ -62,9 +120,36 @@ export class Filter {
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
return patterns.some(
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
)
const isStatusMatch = (rule: Readonly<FilterRuleItem>): boolean => {
return rule.status === undefined || rule.status.includes(file.status)
}
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return isStatusMatch(rule) && rule.isMatch(file.filename)
}
switch (this.filterConfig?.predicateQuantifier) {
case PredicateQuantifier.EVERY:
return patterns.every(aPredicate)
case PredicateQuantifier.SOME_WITH_EXCLUDES: {
let isIncluded = false
for (const rule of patterns) {
if (!isStatusMatch(rule)) {
continue
}
// Once a file is excluded it stays excluded - no other pattern can include it back.
// Therefore all the patterns have to be evaluated even if the file is already included.
if (rule.isExclude?.(file.filename)) {
return false
}
if (!isIncluded && rule.isInclude?.(file.filename)) {
isIncluded = true
}
}
return isIncluded
}
default:
return patterns.some(aPredicate)
}
}
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
@@ -73,7 +158,7 @@ export class Filter {
}
if (typeof item === 'string') {
return [{status: undefined, isMatch: picomatch(item, MatchOptions)}]
return [createRuleItem(item)]
}
if (typeof item === 'object') {
@@ -83,14 +168,12 @@ export class Filter {
`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
)
}
return {
status: key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()) as ChangeStatus[],
isMatch: picomatch(pattern, MatchOptions)
}
const status = key
.split('|')
.map(x => x.trim())
.filter(x => x.length > 0)
.map(x => x.toLowerCase()) as ChangeStatus[]
return createRuleItem(pattern, status)
})
}
@@ -107,3 +190,25 @@ export class Filter {
function flat<T>(arr: T[][]): T[] {
return arr.reduce((acc, val) => acc.concat(val), [])
}
// Compiles filename pattern(s) of a single filter rule item into matchers.
// Multiple patterns are OR-ed together, which is how picomatch treats an array of globs.
// Patterns are also split by their polarity, so PredicateQuantifier.SOME_WITH_EXCLUDES
// can tell inclusions from exclusions. Note that only a leading '!' negates the whole
// pattern - the '!(...)' extglob is a regular pattern matching everything it doesn't enumerate.
function createRuleItem(patterns: string | string[], status?: ChangeStatus[]): FilterRuleItem {
const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern =>
picomatch(pattern, MatchOptions, true)
)
// picomatch inverts the result of a matcher created from a negated pattern.
// Inverting it back gives a matcher of the filenames such pattern excludes.
const includes = matchers.filter(matcher => !matcher.state.negated)
const excludes = matchers.filter(matcher => matcher.state.negated)
return {
status,
isMatch: str => matchers.some(matcher => matcher(str)),
isInclude: includes.length > 0 ? str => includes.some(matcher => matcher(str)) : undefined,
isExclude: excludes.length > 0 ? str => excludes.some(matcher => !matcher(str)) : undefined
}
}

View File

@@ -1,15 +1,50 @@
import {getExecOutput} from '@actions/exec'
import {getExecOutput, ExecOutput} from '@actions/exec'
import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
import {ensureSafeDirectory, getGitEnv, isDubiousOwnershipError} from './safe-directory'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const HEAD = 'HEAD'
export async function gitExec(args: string[], options?: {ignoreReturnCode?: boolean}): Promise<ExecOutput> {
// ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below
const execute = async (): Promise<ExecOutput> =>
getExecOutput('git', args, {...options, ignoreReturnCode: true, env: getGitEnv()})
let result = await execute()
if (isDubiousOwnershipError(result.exitCode, result.stderr)) {
if (await ensureSafeDirectory(result.stderr)) {
result = await execute()
}
if (isDubiousOwnershipError(result.exitCode, result.stderr)) {
const firstLine = result.stderr
.split(/\r?\n/)
.find(line => line.trim().length > 0)
?.trim()
throw new Error(
`${firstLine ?? 'Git failed due to dubious repository ownership'}\n` +
'The automatic safe.directory workaround was not sufficient. ' +
'Either run the container with the same user as the runner:\n' +
' container:\n' +
' options: --user 1001\n' +
'or mark the repository as safe in a step before this action:\n' +
' - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"'
)
}
}
if (result.exitCode !== 0 && !options?.ignoreReturnCode) {
throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`)
}
return result
}
export async function getChangesInLastCommit(): Promise<File[]> {
core.startGroup(`Change detection in last commit`)
let output = ''
try {
output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
output = (await gitExec(['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -27,8 +62,7 @@ export async function getChanges(base: string, head: string): Promise<File[]> {
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -42,7 +76,7 @@ export async function getChangesOnHead(): Promise<File[]> {
core.startGroup(`Change detection on HEAD`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -58,7 +92,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
if (baseRef === undefined || headRef === undefined) {
return false
}
return (await getExecOutput('git', ['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
return (await gitExec(['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0
}
let noMergeBase = false
@@ -67,12 +101,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
baseRef = await getLocalRef(base)
headRef = await getLocalRef(head)
if (!(await hasMergeBase())) {
await getExecOutput('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
await gitExec(['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head])
if (baseRef === undefined || headRef === undefined) {
baseRef = baseRef ?? (await getLocalRef(base))
headRef = headRef ?? (await getLocalRef(head))
if (baseRef === undefined || headRef === undefined) {
await getExecOutput('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], {
await gitExec(['fetch', '--tags', '--depth=1', 'origin', base, head], {
ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it
})
baseRef = baseRef ?? (await getLocalRef(base))
@@ -94,12 +128,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
let lastCommitCount = await getCommitCount()
while (!(await hasMergeBase())) {
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER)
await getExecOutput('git', ['fetch', `--deepen=${depth}`, 'origin', base, head])
await gitExec(['fetch', `--deepen=${depth}`, 'origin', base, head])
const commitCount = await getCommitCount()
if (commitCount === lastCommitCount) {
core.info('No more commits were fetched')
core.info('Last attempt will be to fetch full history')
await getExecOutput('git', ['fetch'])
await gitExec(['fetch'])
if (!(await hasMergeBase())) {
noMergeBase = true
}
@@ -123,7 +157,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
core.startGroup(`Change detection ${diffArg}`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -148,7 +182,7 @@ export async function listAllFilesAsAdded(): Promise<File[]> {
core.startGroup('Listing all files tracked by git')
let output = ''
try {
output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
output = (await gitExec(['ls-files', '-z'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -166,17 +200,17 @@ export async function listAllFilesAsAdded(): Promise<File[]> {
export async function getCurrentRef(): Promise<string> {
core.startGroup(`Get current git ref`)
try {
const branch = (await getExecOutput('git', ['branch', '--show-current'])).stdout.trim()
if (branch) {
const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branch && branch !== 'HEAD') {
return branch
}
const describe = await getExecOutput('git', ['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
const describe = await gitExec(['describe', '--tags', '--exact-match'], {ignoreReturnCode: true})
if (describe.exitCode === 0) {
return describe.stdout.trim()
}
return (await getExecOutput('git', ['rev-parse', HEAD])).stdout.trim()
return (await gitExec(['rev-parse', HEAD])).stdout.trim()
} finally {
core.endGroup()
}
@@ -199,11 +233,11 @@ export function isGitSha(ref: string): boolean {
}
async function hasCommit(ref: string): Promise<boolean> {
return (await getExecOutput('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
return (await gitExec(['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0
}
async function getCommitCount(): Promise<number> {
const output = (await getExecOutput('git', ['rev-list', '--count', '--all'])).stdout
const output = (await gitExec(['rev-list', '--count', '--all'])).stdout
const count = parseInt(output)
return isNaN(count) ? 0 : count
}
@@ -213,7 +247,7 @@ async function getLocalRef(shortName: string): Promise<string | undefined> {
return (await hasCommit(shortName)) ? shortName : undefined
}
const output = (await getExecOutput('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout
const output = (await gitExec(['show-ref', shortName], {ignoreReturnCode: true})).stdout
const refs = output
.split(/\r?\n/g)
.map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/))
@@ -237,10 +271,10 @@ async function ensureRefAvailable(name: string): Promise<string> {
try {
let ref = await getLocalRef(name)
if (ref === undefined) {
await getExecOutput('git', ['fetch', '--depth=1', '--no-tags', 'origin', name])
await gitExec(['fetch', '--depth=1', '--no-tags', 'origin', name])
ref = await getLocalRef(name)
if (ref === undefined) {
await getExecOutput('git', ['fetch', '--depth=1', '--tags', 'origin', name])
await gitExec(['fetch', '--depth=1', '--tags', 'origin', name])
ref = await getLocalRef(name)
if (ref === undefined) {
throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`)

View File

@@ -4,7 +4,7 @@ export function csvEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9._+:@%/-]+$/.test(value)) {
return value
}

View File

@@ -9,13 +9,13 @@ export function shellEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/-]+$/.test(value)) {
return value
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/.test(value)) {
return `"${value}"`
}

View File

@@ -1,12 +1,20 @@
import * as fs from 'fs'
import * as core from '@actions/core'
import * as github from '@actions/github'
import type {Octokit} from '@octokit/rest'
import {Webhooks} from '@octokit/webhooks'
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
import {MergeGroupEvent, PullRequest, PushEvent} from '@octokit/webhooks-types'
import {Filter, FilterResults} from './filter'
import {
isPredicateQuantifier,
Filter,
FilterConfig,
FilterResults,
PredicateQuantifier,
SUPPORTED_PREDICATE_QUANTIFIERS
} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import {cleanup as cleanupSafeDirectory} from './safe-directory'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
import {csvEscape} from './list-format/csv-escape'
@@ -26,19 +34,30 @@ async function run(): Promise<void> {
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
return
}
const filter = new Filter(filtersYaml)
if (!isPredicateQuantifier(predicateQuantifier)) {
const predicateQuantifierInvalidErrorMsg =
`Input parameter 'predicate-quantifier' is set to invalid value ` +
`'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
throw new Error(predicateQuantifierInvalidErrorMsg)
}
const filterConfig: FilterConfig = {predicateQuantifier}
const filter = new Filter(filtersYaml, filterConfig)
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
exportResults(results, listFiles)
} catch (error) {
core.setFailed(error.message)
core.setFailed(getErrorMessage(error))
} finally {
await cleanupSafeDirectory()
}
}
@@ -68,36 +87,61 @@ async function getChangedFiles(token: string, base: string, ref: string, initial
return await git.getChangesOnHead()
}
const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target']
if (prEvents.includes(github.context.eventName)) {
if (ref) {
core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
switch (github.context.eventName) {
// To keep backward compatibility, commits in GitHub pull request event
// take precedence over manual inputs.
case 'pull_request':
case 'pull_request_review':
case 'pull_request_review_comment':
case 'pull_request_target': {
if (ref) {
core.warning(`'ref' input parameter is ignored when action is triggered by pull request event`)
}
const pr = github.context.payload.pull_request as PullRequest
if (token) {
if (base) {
core.warning(
`'base' input parameter is ignored when action is triggered by pull request event and 'token' is provided - set token: '' to detect changes using git diff against 'base'`
)
}
return await getChangedFilesFromApi(token, pr)
}
if (github.context.eventName === 'pull_request_target') {
// pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
// Therefore it's not possible to look at changes in last commit
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
}
core.info('GitHub token is not available - changes will be detected using git diff')
if (base) {
core.info(`Using base '${base}' instead of the pull request base`)
}
const baseSha = github.context.payload.pull_request?.base.sha
const defaultBranch = github.context.payload.repository?.default_branch
const currentRef = await git.getCurrentRef()
return await git.getChanges(base || baseSha || defaultBranch, currentRef)
}
if (base) {
core.warning(`'base' input parameter is ignored when action is triggered by pull request event`)
// To keep backward compatibility, manual inputs take precedence over
// commits in GitHub merge queue event.
case 'merge_group': {
const mergeGroup = github.context.payload as MergeGroupEvent
if (!base) {
base = mergeGroup.merge_group.base_sha
}
if (!ref) {
ref = mergeGroup.merge_group.head_sha
}
break
}
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
if (token) {
return await getChangedFilesFromApi(token, pr)
}
if (github.context.eventName === 'pull_request_target') {
// pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
// Therefor it's not possible to look at changes in last commit
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
}
core.info('Github token is not available - changes will be detected from PRs merge commit')
return await git.getChangesInLastCommit()
} else {
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}
async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
const defaultBranch = github.context.payload.repository?.default_branch
const beforeSha =
github.context.eventName === 'push' ? (github.context.payload as Webhooks.WebhookPayloadPush).before : null
const beforeSha = github.context.eventName === 'push' ? (github.context.payload as PushEvent).before : null
const currentRef = await git.getCurrentRef()
@@ -157,31 +201,28 @@ async function getChangedFilesFromGit(base: string, head: string, initialFetchDe
}
// Uses github REST api to get list of files changed in PR
async function getChangedFilesFromApi(
token: string,
prNumber: Webhooks.WebhookPayloadPullRequestPullRequest
): Promise<File[]> {
core.startGroup(`Fetching list of changed files for PR#${prNumber.number} from Github API`)
async function getChangedFilesFromApi(token: string, pullRequest: PullRequest): Promise<File[]> {
core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from GitHub API`)
try {
const client = new github.GitHub(token)
const client = github.getOctokit(token)
const per_page = 100
const files: File[] = []
core.info(`Invoking listFiles(pull_number: ${prNumber.number}, per_page: ${per_page})`)
core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, per_page: ${per_page})`)
for await (const response of client.paginate.iterator(
client.pulls.listFiles.endpoint.merge({
client.rest.pulls.listFiles.endpoint.merge({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: prNumber.number,
pull_number: pullRequest.number,
per_page
})
) as AsyncIterableIterator<Octokit.Response<Octokit.PullsListFilesResponse>>) {
)) {
if (response.status !== 200) {
throw new Error(`Fetching list of changed files from GitHub API failed with error code ${response.status}`)
}
core.info(`Received ${response.data.length} items`)
for (const row of response.data) {
for (const row of response.data as GetResponseDataTypeFromEndpointMethod<typeof client.rest.pulls.listFiles>) {
core.info(`[${row.status}] ${row.filename}`)
// There's no obvious use-case for detection of renames
// Therefore we treat it as if rename detection in git diff was turned off.
@@ -267,4 +308,9 @@ function isExportFormat(value: string): value is ExportFormat {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
run()

135
src/safe-directory.ts Normal file
View File

@@ -0,0 +1,135 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as core from '@actions/core'
import {exec} from '@actions/exec'
// Git >= 2.35.2 and distro backports of CVE-2022-24765 fail with exit code 128 when
// the repository is owned by a different user - typical for container jobs where the
// workspace is bind-mounted from the host. Older backports use the "unsafe repository" wording.
const DUBIOUS_OWNERSHIP_PATTERN = /detected dubious ownership|unsafe repository/
const REPOSITORY_PATH_PATTERN = /(?:repository at|unsafe repository \()\s*'([^']+)'/
let tempHomeDir: string | undefined
let gitEnv: {[key: string]: string} | undefined
const safeDirectories = new Set<string>()
export function isDubiousOwnershipError(exitCode: number, stderr: string): boolean {
return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr)
}
export function parseRepositoryPath(stderr: string): string | undefined {
return stderr.match(REPOSITORY_PATH_PATTERN)?.[1]
}
// Until the workaround is activated this mirrors process.env; afterwards it applies the
// temporary HOME redirect. In both cases LC_ALL=C is forced so git emits untranslated
// messages and isDubiousOwnershipError / parseRepositoryPath match regardless of the
// container's locale.
export function getGitEnv(): {[key: string]: string} {
return {...(gitEnv ?? cloneDefinedEnv(process.env)), LC_ALL: 'C'}
}
// Marks directories reported by git as safe, using a temporary HOME so no configuration
// outside this action is modified - same technique as actions/checkout.
// Returns false if there was no new directory to add.
export async function ensureSafeDirectory(stderr: string): Promise<boolean> {
if (tempHomeDir === undefined) {
tempHomeDir = await createTempGitHome(resolveTempBaseDir(process.env), process.env)
gitEnv = buildGitEnv(tempHomeDir, process.env)
core.info(
'Git reported dubious ownership of the repository - this is typical for container jobs ' +
'where the workspace is owned by a different user. A temporary HOME with a copy of the global ' +
'git config and a safe.directory exception will be used for git commands executed by this action.'
)
}
let added = false
for (const dir of [parseRepositoryPath(stderr), process.env.GITHUB_WORKSPACE, process.cwd()]) {
if (dir && !safeDirectories.has(dir)) {
await exec('git', ['config', '--global', '--add', 'safe.directory', dir], {env: getGitEnv()})
safeDirectories.add(dir)
added = true
}
}
return added
}
export async function cleanup(): Promise<void> {
if (tempHomeDir !== undefined) {
try {
await fs.promises.rm(tempHomeDir, {recursive: true, force: true})
} catch (error) {
// Cleanup failure is not fatal - RUNNER_TEMP is wiped when the job ends
}
}
tempHomeDir = undefined
gitEnv = undefined
safeDirectories.clear()
}
// Exported for tests
export async function createTempGitHome(
baseTempDir: string,
env: {[key: string]: string | undefined}
): Promise<string> {
const tempHome = await fs.promises.mkdtemp(path.join(baseTempDir, 'paths-filter-git-home-'))
const tempConfigPath = path.join(tempHome, '.gitconfig')
// The file must exist even when there is no config to copy - when $HOME/.gitconfig is missing,
// `git config --global` writes to an existing $XDG_CONFIG_HOME/git/config instead
await fs.promises.writeFile(tempConfigPath, '')
if (env.GIT_CONFIG_GLOBAL) {
await copyFileIfExists(env.GIT_CONFIG_GLOBAL, tempConfigPath)
} else if (env.HOME) {
await copyFileIfExists(path.join(env.HOME, '.gitconfig'), tempConfigPath)
if (!env.XDG_CONFIG_HOME) {
// When XDG_CONFIG_HOME is unset, git falls back to $HOME/.config/git/config,
// which would become unreadable under the new HOME
await copyFileIfExists(
path.join(env.HOME, '.config', 'git', 'config'),
path.join(tempHome, '.config', 'git', 'config')
)
}
}
return tempHome
}
// Exported for tests
export function buildGitEnv(tempHome: string, env: {[key: string]: string | undefined}): {[key: string]: string} {
const newEnv = cloneDefinedEnv(env)
// A changed HOME redirects git of any version to the temp config. GIT_CONFIG_GLOBAL is redirected
// only when already set - on git >= 2.32 it replaces both global config files, so setting it
// unconditionally would hide an existing $XDG_CONFIG_HOME/git/config from git
newEnv['HOME'] = tempHome
if (env.GIT_CONFIG_GLOBAL) {
newEnv['GIT_CONFIG_GLOBAL'] = path.join(tempHome, '.gitconfig')
}
return newEnv
}
// Exported for tests
export function resolveTempBaseDir(env: {[key: string]: string | undefined}): string {
return env.RUNNER_TEMP || os.tmpdir()
}
function cloneDefinedEnv(env: {[key: string]: string | undefined}): {[key: string]: string} {
const newEnv: {[key: string]: string} = {}
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
newEnv[key] = value
}
}
return newEnv
}
async function copyFileIfExists(source: string, destination: string): Promise<void> {
try {
await fs.promises.access(source, fs.constants.R_OK)
} catch (error) {
return
}
await fs.promises.mkdir(path.dirname(destination), {recursive: true})
await fs.promises.copyFile(source, destination)
}