Compare commits

..

22 Commits

Author SHA1 Message Date
Sascha Bratton
fda67ce02f Merge branch 'master' into retry-transient-errors 2026-08-04 11:58:58 -04:00
Pavel Kutáč
4711b7a31b feat: add 'some-with-excludes' predicate quantifier (#322) 2026-07-31 17:44:02 -04:00
Sascha Bratton
65a156e663 fix: retry GitHub API requests on transient errors 2026-07-31 14:49:33 -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
15 changed files with 3098 additions and 194 deletions

View File

@@ -10,10 +10,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
cache: 'npm'
- run: |
npm install
@@ -22,7 +22,7 @@ jobs:
self-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./
id: filter
with:

View File

@@ -10,21 +10,28 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
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@v4
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -45,7 +52,7 @@ jobs:
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -57,7 +64,7 @@ jobs:
test-without-token:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./
id: filter
with:
@@ -67,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@v4
- uses: actions/checkout@v6
with:
path: somewhere
- uses: ./somewhere
@@ -86,7 +150,7 @@ jobs:
test-local-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- run: echo "NEW FILE" > local
- run: git add local
- uses: ./
@@ -103,10 +167,45 @@ 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'
run: echo ${{steps.filter.outputs.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@v4
- 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

View File

@@ -1,5 +1,19 @@
# Changelog
## 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.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)

141
README.md
View File

@@ -27,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:
@@ -46,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@v3
- uses: dorny/paths-filter@v4
id: changes
with:
filters: |
@@ -69,10 +74,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
- New major release `v3` after update to Node 20 [Breaking change]
- 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
@@ -84,7 +95,7 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
## Usage
```yaml
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
with:
# Defines filters applied to detected changed files.
# Each filter has a name and a list of rules.
@@ -104,19 +115,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:
@@ -150,19 +166,24 @@ 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.
# By default files that match at least one pattern defined by the filters will be included.
# This parameter allows to override the "at least one pattern" behavior to make it so that
# all of the patterns have to match or otherwise the file is excluded.
# 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' as the predicate-quantifier parameter. When you do that,
# it will only match the .ts files in the subdirectory as expected.
# 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/**'
@@ -173,10 +194,17 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
## 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.
- 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.
- `changes` - JSON array with names of all filters matching any of the changed files.
@@ -192,8 +220,8 @@ jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -237,7 +265,7 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -252,7 +280,7 @@ jobs:
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- ...
# JOB to build and test frontend code
@@ -261,7 +289,7 @@ jobs:
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- ...
```
@@ -283,7 +311,7 @@ jobs:
packages: ${{ steps.filter.outputs.changes }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -300,7 +328,7 @@ jobs:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- ...
```
@@ -317,15 +345,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@v4
- uses: dorny/paths-filter@v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: ... # Configure your filters
@@ -345,12 +380,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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@v3
- uses: dorny/paths-filter@v4
id: filter
with:
base: develop # Change detection against merge-base with this branch
@@ -373,8 +408,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
# Use context to get the branch where commits were pushed.
@@ -401,14 +436,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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@v3
- uses: dorny/paths-filter@v4
id: filter
with:
base: HEAD
@@ -423,7 +458,7 @@ jobs:
<summary>Define filter rules in own file</summary>
```yaml
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
# Path to file where filters are defined
@@ -436,7 +471,7 @@ jobs:
<summary>Use YAML anchors to reuse path expression(s) inside another rule</summary>
```yaml
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
# &shared is YAML anchor,
@@ -457,7 +492,7 @@ jobs:
<summary>Consider if file was added, modified or deleted</summary>
```yaml
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
# Changed file can be 'added', 'modified', or 'deleted'.
@@ -483,7 +518,7 @@ jobs:
<summary>Detect changes in folder only for some file extensions</summary>
```yaml
- uses: dorny/paths-filter@v3
- 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
@@ -505,13 +540,39 @@ jobs:
</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@v3
- uses: dorny/paths-filter@v4
id: filter
with:
# Enable listing of files matching each filter.
@@ -537,7 +598,7 @@ jobs:
<summary>Passing list of modified files as JSON array to another action</summary>
```yaml
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
# Enable listing of files matching each filter.

View File

@@ -148,6 +148,99 @@ describe('matching tests', () => {
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
@@ -197,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

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

@@ -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: 'node20'
using: 'node24'
main: 'dist/index.js'
branding:
color: blue

2036
dist/index.js vendored

File diff suppressed because it is too large Load Diff

61
package-lock.json generated
View File

@@ -12,13 +12,14 @@
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/github": "6.0.0",
"@octokit/plugin-retry": "^6.1.0",
"picomatch": "^2.3.1"
},
"devDependencies": {
"@octokit/webhooks-types": "^7.3.1",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.6",
"@types/node": "^24.0.0",
"@types/picomatch": "^2.3.3",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
@@ -34,7 +35,7 @@
"typescript": "^5.3.3"
},
"engines": {
"node": ">= 20"
"node": ">= 24"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -1461,6 +1462,38 @@
"@octokit/core": ">=5"
}
},
"node_modules/@octokit/plugin-retry": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.1.0.tgz",
"integrity": "sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==",
"license": "MIT",
"dependencies": {
"@octokit/request-error": "^5.0.0",
"@octokit/types": "^13.0.0",
"bottleneck": "^2.15.3"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@octokit/core": "5"
}
},
"node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz",
"integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==",
"license": "MIT"
},
"node_modules/@octokit/plugin-retry/node_modules/@octokit/types": {
"version": "13.10.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz",
"integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==",
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^24.2.0"
}
},
"node_modules/@octokit/request": {
"version": "8.1.6",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.1.6.tgz",
@@ -1641,12 +1674,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.6.tgz",
"integrity": "sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==",
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~7.16.0"
}
},
"node_modules/@types/picomatch": {
@@ -2292,6 +2326,12 @@
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz",
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="
},
"node_modules/bottleneck": {
"version": "2.19.5",
"resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
"integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -6824,10 +6864,11 @@
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/universal-user-agent": {
"version": "6.0.1",

View File

@@ -2,7 +2,7 @@
"name": "paths-filter",
"version": "1.0.0",
"engines": {
"node": ">= 20"
"node": ">= 24"
},
"private": true,
"description": "Execute your workflow steps only if relevant files are modified.",
@@ -31,13 +31,14 @@
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/github": "6.0.0",
"@octokit/plugin-retry": "^6.1.0",
"picomatch": "^2.3.1"
},
"devDependencies": {
"@octokit/webhooks-types": "^7.3.1",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.6",
"@types/node": "^24.0.0",
"@types/picomatch": "^2.3.3",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",

View File

@@ -21,6 +21,12 @@ 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')
}
/**
@@ -47,7 +53,17 @@ export enum PredicateQuantifier {
* at least one pattern that matches them. This is the default behavior if you don't
* specify anything as a predicate quantifier.
*/
SOME = 'some'
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'
}
/**
@@ -104,13 +120,35 @@ export class Filter {
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return (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)
}
if (this.filterConfig?.predicateQuantifier === 'every') {
return patterns.every(aPredicate)
} else {
return patterns.some(aPredicate)
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)
}
}
@@ -120,7 +158,7 @@ export class Filter {
}
if (typeof item === 'string') {
return [{status: undefined, isMatch: picomatch(item, MatchOptions)}]
return [createRuleItem(item)]
}
if (typeof item === 'object') {
@@ -130,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)
})
}
@@ -154,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

@@ -1,8 +1,9 @@
import * as fs from 'fs'
import * as core from '@actions/core'
import * as github from '@actions/github'
import {retry} from '@octokit/plugin-retry'
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
import {MergeGroupEvent, PullRequest, PushEvent} from '@octokit/webhooks-types'
import {
isPredicateQuantifier,
@@ -14,6 +15,7 @@ import {
} 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'
@@ -55,6 +57,8 @@ async function run(): Promise<void> {
exportResults(results, listFiles)
} catch (error) {
core.setFailed(getErrorMessage(error))
} finally {
await cleanupSafeDirectory()
}
}
@@ -84,32 +88,55 @@ 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 PullRequestEvent
if (token) {
return await getChangedFilesFromApi(token, pr)
}
if (github.context.eventName === 'pull_request_target') {
// pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
// Therefor it's not possible to look at changes in last commit
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
}
core.info('Github token is not available - changes will be detected using git diff')
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)
} else {
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}
async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
@@ -175,10 +202,10 @@ 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, pullRequest: PullRequestEvent): Promise<File[]> {
core.startGroup(`Fetching list of changed files for PR#${pullRequest.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 = github.getOctokit(token)
const client = github.getOctokit(token, undefined, retry)
const per_page = 100
const files: File[] = []

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)
}