mirror of
https://gitea.com/actions/dorny-paths-filter.git
synced 2026-08-21 16:13:13 +01:00
Compare commits
44 Commits
v2
...
65a156e663
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a156e663 | ||
|
|
b41dfa943b | ||
|
|
9af6e5a9d0 | ||
|
|
cae9006b65 | ||
|
|
7b450fff21 | ||
|
|
928037783a | ||
|
|
f3ceefdc7e | ||
|
|
61f87a10cd | ||
|
|
b82ff81ffb | ||
|
|
fbd0ab8f3e | ||
|
|
efb1da7ce8 | ||
|
|
d8f7b061b2 | ||
|
|
addbc147a9 | ||
|
|
9d7afb8d21 | ||
|
|
782470c5d9 | ||
|
|
d1c1ffe024 | ||
|
|
ce10459c8b | ||
|
|
5f40380c54 | ||
|
|
668c092af3 | ||
|
|
209e61402d | ||
|
|
de90cc6fb3 | ||
|
|
cf89abdbae | ||
|
|
f90d5265d6 | ||
|
|
ebc4d7e9eb | ||
|
|
45f16f1875 | ||
|
|
5da0e4c086 | ||
|
|
1441771bbf | ||
|
|
0bc4621a31 | ||
|
|
7267a8516b | ||
|
|
e36f1124bf | ||
|
|
2f74457227 | ||
|
|
67617953b4 | ||
|
|
a35d8d6a33 | ||
|
|
b5a5203f8b | ||
|
|
3c49e64ca2 | ||
|
|
8ec7be4734 | ||
|
|
100a1198b2 | ||
|
|
96be2b61c4 | ||
|
|
f5071954e8 | ||
|
|
4067d88573 | ||
|
|
fc3b4e8a61 | ||
|
|
fbb4d78dec | ||
|
|
245527a2ef | ||
|
|
5266f0ac59 |
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
82
.github/workflows/pull-request-verification.yml
vendored
82
.github/workflows/pull-request-verification.yml
vendored
@@ -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: ./
|
||||
@@ -102,7 +170,7 @@ jobs:
|
||||
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
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
||||
# 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)
|
||||
|
||||
## 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)
|
||||
|
||||
130
README.md
130
README.md
@@ -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: |
|
||||
@@ -68,9 +74,15 @@ 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
|
||||
|
||||
- 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 +94,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 +114,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,17 +165,39 @@ 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.
|
||||
#
|
||||
# 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.
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
@@ -174,8 +213,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 +258,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 +273,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 +282,7 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- ...
|
||||
```
|
||||
|
||||
@@ -265,7 +304,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 +321,7 @@ jobs:
|
||||
package: ${{ fromJSON(needs.changes.outputs.packages) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- ...
|
||||
```
|
||||
|
||||
@@ -299,15 +338,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 +373,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 +401,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 +429,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 +451,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 +464,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 +485,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 +507,39 @@ 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>
|
||||
|
||||
### 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.
|
||||
@@ -493,7 +565,7 @@ jobs:
|
||||
<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.
|
||||
|
||||
@@ -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,37 @@ 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('matches path based on rules included using YAML anchor', () => {
|
||||
const yaml = `
|
||||
shared: &shared
|
||||
@@ -186,3 +217,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
113
__tests__/git-exec.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
256
__tests__/safe-directory.test.ts
Normal file
256
__tests__/safe-directory.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -44,11 +44,16 @@ 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 to make it so that all of the patterns have to match or otherwise the file is excluded.
|
||||
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
|
||||
|
||||
57825
dist/index.js
vendored
57825
dist/index.js
vendored
File diff suppressed because one or more lines are too long
14395
package-lock.json
generated
14395
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -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,27 @@
|
||||
"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",
|
||||
"@octokit/plugin-retry": "^6.1.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,48 @@ interface FilterRuleItem {
|
||||
isMatch: (str: string) => boolean // Matches the filename
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
[key: string]: File[]
|
||||
}
|
||||
@@ -31,7 +73,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 +85,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 +104,14 @@ 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 aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
|
||||
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
|
||||
}
|
||||
if (this.filterConfig?.predicateQuantifier === 'every') {
|
||||
return patterns.every(aPredicate)
|
||||
} else {
|
||||
return patterns.some(aPredicate)
|
||||
}
|
||||
}
|
||||
|
||||
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
|
||||
|
||||
76
src/git.ts
76
src/git.ts
@@ -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`)
|
||||
|
||||
123
src/main.ts
123
src/main.ts
@@ -1,12 +1,21 @@
|
||||
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 {retry} from '@octokit/plugin-retry'
|
||||
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 +35,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 +88,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 +202,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, undefined, retry)
|
||||
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 +309,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
135
src/safe-directory.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user