mirror of
https://gitea.com/actions/dorny-paths-filter.git
synced 2026-08-21 16:13:13 +01:00
Compare commits
10 Commits
allow-over
...
ceb8a2b8f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb8a2b8f2 | ||
|
|
ef09b88f3e | ||
|
|
44adc5b06d | ||
|
|
4711b7a31b | ||
|
|
93c889f9e5 | ||
|
|
b41dfa943b | ||
|
|
9af6e5a9d0 | ||
|
|
cae9006b65 | ||
|
|
7b450fff21 | ||
|
|
928037783a |
106
.github/workflows/pull-request-verification.yml
vendored
106
.github/workflows/pull-request-verification.yml
vendored
@@ -74,6 +74,63 @@ 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:
|
||||
@@ -110,6 +167,43 @@ jobs:
|
||||
if: steps.filter.outputs.local_count != 1
|
||||
run: exit 1
|
||||
|
||||
test-predicate-quantifier-some-with-excludes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: modify working tree
|
||||
run: |
|
||||
mkdir -p mobile/.config
|
||||
echo "TEST" > mobile/main.kt
|
||||
echo "TEST" > mobile/README.md
|
||||
echo "TEST" > mobile/.config/lint.json
|
||||
echo "TEST" > backend.go
|
||||
git add -A
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
base: HEAD
|
||||
list-files: shell
|
||||
predicate-quantifier: 'some-with-excludes'
|
||||
filters: |
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- '!mobile/**/*.md'
|
||||
- '!mobile/.config/**'
|
||||
excludesOnly:
|
||||
- '!**/*.md'
|
||||
- name: Print 'mobile_files'
|
||||
env:
|
||||
MOBILE_FILES: ${{ steps.filter.outputs.mobile_files }}
|
||||
run: echo "$MOBILE_FILES"
|
||||
- name: filter-test
|
||||
if: |
|
||||
steps.filter.outputs.mobile != 'true'
|
||||
|| steps.filter.outputs.mobile_count != 1
|
||||
|| steps.filter.outputs.mobile_files != 'mobile/main.kt'
|
||||
|| steps.filter.outputs.excludesOnly != 'false'
|
||||
run: exit 1
|
||||
|
||||
test-change-type:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -135,11 +229,17 @@ jobs:
|
||||
any:
|
||||
- added|deleted|modified: "*"
|
||||
- name: Print 'added_files'
|
||||
run: echo ${{steps.filter.outputs.added_files}}
|
||||
env:
|
||||
ADDED_FILES: ${{ steps.filter.outputs.added_files }}
|
||||
run: echo "$ADDED_FILES"
|
||||
- name: Print 'modified_files'
|
||||
run: echo ${{steps.filter.outputs.modified_files}}
|
||||
env:
|
||||
MODIFIED_FILES: ${{ steps.filter.outputs.modified_files }}
|
||||
run: echo "$MODIFIED_FILES"
|
||||
- name: Print 'deleted_files'
|
||||
run: echo ${{steps.filter.outputs.deleted_files}}
|
||||
env:
|
||||
DELETED_FILES: ${{ steps.filter.outputs.deleted_files }}
|
||||
run: echo "$DELETED_FILES"
|
||||
- name: filter-test
|
||||
if: |
|
||||
steps.filter.outputs.added != 'true'
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,11 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v4.1.0
|
||||
- [Allow base/ref override on pull_request events](https://github.com/dorny/paths-filter/pull/NNN)
|
||||
## v4.0.3
|
||||
- [Document safe handling of file list outputs in workflows](https://github.com/dorny/paths-filter/pull/326)
|
||||
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
|
||||
- [Add 'some-with-excludes' predicate quantifier](https://github.com/dorny/paths-filter/pull/322)
|
||||
- [Add contents permission to PR example](https://github.com/dorny/paths-filter/pull/248)
|
||||
- [Scope base-ignored warning to API path](https://github.com/dorny/paths-filter/pull/319)
|
||||
- [Update outputs in readme to account for the 'every' predicate-quantifier](https://github.com/dorny/paths-filter/pull/247)
|
||||
|
||||
## v4.0.2
|
||||
- [Work around git dubious ownership errors in container jobs](https://github.com/dorny/paths-filter/pull/317)
|
||||
- [Use rev-parse instead of branch --show-current for older git compat](https://github.com/dorny/paths-filter/pull/303)
|
||||
- [Fix warning message](https://github.com/dorny/paths-filter/pull/282)
|
||||
|
||||
## v4.0.1
|
||||
- [Support merge queue](https://github.com/dorny/paths-filter/pull/255)
|
||||
|
||||
## v4.0.0
|
||||
- [Update action runtime to node24](https://github.com/dorny/paths-filter/pull/294)
|
||||
|
||||
## v3.0.4
|
||||
- [Escape multi-line filenames in list-files shell and csv output](https://github.com/advisories/GHSA-7hc6-8hq5-9q2m)
|
||||
|
||||
## v3.0.3
|
||||
- [Add missing predicate-quantifier](https://github.com/dorny/paths-filter/pull/279)
|
||||
|
||||
|
||||
94
README.md
94
README.md
@@ -67,6 +67,10 @@ For more scenarios see [examples](#examples) section.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Security:** `${FILTER_NAME}_files` outputs contain filenames that may be attacker-influenced on pull requests.
|
||||
Do not interpolate them directly into a `run:` script with `${{ ... }}`.
|
||||
Pass the value through `env:` and reference the variable from the shell instead.
|
||||
See [Custom processing of changed files](#custom-processing-of-changed-files).
|
||||
- Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library.
|
||||
Documentation for path expression format can be found on the project GitHub page.
|
||||
- Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true.
|
||||
@@ -74,9 +78,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
|
||||
|
||||
- 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
|
||||
@@ -117,7 +127,8 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
|
||||
# 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, unless 'allow-override-on-pr' is set to true.
|
||||
# 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: ''
|
||||
|
||||
@@ -126,7 +137,7 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
|
||||
# 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, unless 'allow-override-on-pr' is set to true.
|
||||
# This option is ignored if action is triggered by pull_request event.
|
||||
# default: ${{ github.ref }}
|
||||
ref:
|
||||
|
||||
@@ -159,42 +170,46 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
|
||||
# It's only used if action is triggered by a pull request event.
|
||||
# GitHub token from workflow context is used as default value.
|
||||
# If an empty string is provided, the action falls back to detect
|
||||
# changes using git commands.
|
||||
# changes using git commands. In that case, on pull request events
|
||||
# the 'base' input overrides the pull request base - e.g. set
|
||||
# base: ${{ github.event.before }} to detect changes since the last push.
|
||||
# Default: ${{ github.token }}
|
||||
token: ''
|
||||
|
||||
# Optional parameter to override the default behavior of file matching algorithm.
|
||||
# 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.
|
||||
# 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' as the predicate-quantifier parameter. When you do that,
|
||||
# it will only match the .ts files in the subdirectory as expected.
|
||||
# you specify 'every' or 'some-with-excludes' as the predicate-quantifier parameter.
|
||||
# When you do that, it will only match the .ts files in the subdirectory as expected.
|
||||
#
|
||||
# backend:
|
||||
# - 'pkg/a/b/c/**'
|
||||
# - '!**/*.jpeg'
|
||||
# - '!**/*.md'
|
||||
predicate-quantifier: 'some'
|
||||
|
||||
# When true, the user-provided `base` and/or `ref` inputs are honored even if
|
||||
# the action is triggered by a pull_request, pull_request_review,
|
||||
# pull_request_review_comment, or pull_request_target event. In that case the
|
||||
# action skips the GitHub API path and uses git diff against the provided
|
||||
# base/ref. Has no effect if `base` and `ref` are both empty.
|
||||
# Default: false
|
||||
allow-override-on-pr: 'false'
|
||||
```
|
||||
|
||||
## Outputs
|
||||
|
||||
- For each filter, it sets output variable named by the filter to the text:
|
||||
- `'true'` - if **any** of changed files matches any of filter rules
|
||||
- `'false'` - if **none** of changed files matches any of filter rules
|
||||
- For each filter, it sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
|
||||
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter.
|
||||
- Each filter sets an output variable, named after the filter, whose text value depends on the `predicate-quantifier` setting:
|
||||
- With `predicate-quantifier: 'some'` (default):
|
||||
- `'true'` - if **any** changed file matches **at least one** of the filter's rules
|
||||
- `'false'` - if **no** changed file matches **at least one** of the filter's rules
|
||||
- With `predicate-quantifier: 'every'`:
|
||||
- `'true'` - if **any** changed file matches **all** of the filter's rules
|
||||
- `'false'` - if **no** changed file matches **all** of the filter's rules
|
||||
- With `predicate-quantifier: 'some-with-excludes'`:
|
||||
- `'true'` - if **any** changed file matches **at least one** of the filter's rules and **none** of its negated rules
|
||||
- `'false'` - if **no** changed file matches **at least one** of the filter's rules and **none** of its negated rules
|
||||
- Each filter sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files.
|
||||
- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter. Treat these values as untrusted when filenames can come from pull requests.
|
||||
- `changes` - JSON array with names of all filters matching any of the changed files.
|
||||
|
||||
## Examples
|
||||
@@ -345,7 +360,8 @@ jobs:
|
||||
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@v6
|
||||
- uses: dorny/paths-filter@v4
|
||||
@@ -528,6 +544,32 @@ 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>
|
||||
@@ -551,9 +593,13 @@ jobs:
|
||||
- added|modified: '*.md'
|
||||
- name: Lint Markdown
|
||||
if: ${{ steps.filter.outputs.markdown == 'true' }}
|
||||
run: npx textlint ${{ steps.filter.outputs.markdown_files }}
|
||||
env:
|
||||
MARKDOWN_FILES: ${{ steps.filter.outputs.markdown_files }}
|
||||
run: npx textlint $MARKDOWN_FILES
|
||||
```
|
||||
|
||||
When passing file lists to shell commands, use `env:` as shown above. Do not write `${{ steps.filter.outputs.markdown_files }}` directly inside the `run:` script.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -579,6 +625,8 @@ jobs:
|
||||
files: ${{ steps.filter.outputs.changed_files }}
|
||||
```
|
||||
|
||||
The `json` and `csv` formats are intended as structured data for scripts, programs, or other actions. Passing them to an action input as above is fine. Do not interpolate `json` or `csv` outputs directly into a `run:` script.
|
||||
|
||||
</details>
|
||||
|
||||
## See also
|
||||
|
||||
@@ -20,4 +20,12 @@ describe('csvEscape() backslash escapes every character except subset of definit
|
||||
test('Double quote should be escaped by another double quote', () => {
|
||||
expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
|
||||
})
|
||||
|
||||
test('filename with LF should be quoted per RFC 4180', () => {
|
||||
expect(csvEscape('a\nb')).toBe('"a\nb"')
|
||||
})
|
||||
|
||||
test('filename with CRLF should be quoted per RFC 4180', () => {
|
||||
expect(csvEscape('a\r\nb')).toBe('"a\r\nb"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
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')
|
||||
})
|
||||
})
|
||||
@@ -54,4 +54,16 @@ describe('shellEscape() returns human readable filenames with as few escaping ap
|
||||
test('filename with single quote and special characters is split and quoted/escaped as needed', () => {
|
||||
expect(shellEscape("file'with $quote")).toBe("file\\''with $quote'")
|
||||
})
|
||||
|
||||
test('filename with LF should be single-quoted', () => {
|
||||
expect(shellEscape('x\ntouch pwned.md')).toBe("'x\ntouch pwned.md'")
|
||||
})
|
||||
|
||||
test('filename with CRLF should be single-quoted', () => {
|
||||
expect(shellEscape('x\r\ntouch pwned.md')).toBe("'x\r\ntouch pwned.md'")
|
||||
})
|
||||
|
||||
test('filename with CR should be single-quoted', () => {
|
||||
expect(shellEscape('a\rb')).toBe("'a\rb'")
|
||||
})
|
||||
})
|
||||
|
||||
14
action.yml
14
action.yml
@@ -46,17 +46,13 @@ inputs:
|
||||
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.
|
||||
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'
|
||||
allow-override-on-pr:
|
||||
description: |
|
||||
When true, the user-provided `base` and/or `ref` inputs are honored even if the action
|
||||
is triggered by a pull_request, pull_request_review, pull_request_review_comment, or
|
||||
pull_request_target event. In that case the action skips the GitHub API path and uses
|
||||
git diff against the provided base/ref. Has no effect if `base` and `ref` are both empty.
|
||||
required: false
|
||||
default: 'false'
|
||||
outputs:
|
||||
changes:
|
||||
description: JSON array with names of all filters matching any of changed files
|
||||
|
||||
334
dist/index.js
vendored
334
dist/index.js
vendored
@@ -86,6 +86,16 @@ var PredicateQuantifier;
|
||||
* specify anything as a predicate quantifier.
|
||||
*/
|
||||
PredicateQuantifier["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.
|
||||
*/
|
||||
PredicateQuantifier["SOME_WITH_EXCLUDES"] = "some-with-excludes";
|
||||
})(PredicateQuantifier || (exports.PredicateQuantifier = PredicateQuantifier = {}));
|
||||
/**
|
||||
* An array of strings (at runtime) that contains the valid/accepted values for
|
||||
@@ -126,15 +136,35 @@ class Filter {
|
||||
return result;
|
||||
}
|
||||
isMatch(file, patterns) {
|
||||
var _a;
|
||||
const aPredicate = (rule) => {
|
||||
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename);
|
||||
var _a, _b, _c;
|
||||
const isStatusMatch = (rule) => {
|
||||
return rule.status === undefined || rule.status.includes(file.status);
|
||||
};
|
||||
if (((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === 'every') {
|
||||
return patterns.every(aPredicate);
|
||||
}
|
||||
else {
|
||||
return patterns.some(aPredicate);
|
||||
const aPredicate = (rule) => {
|
||||
return isStatusMatch(rule) && rule.isMatch(file.filename);
|
||||
};
|
||||
switch ((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.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 ((_b = rule.isExclude) === null || _b === void 0 ? void 0 : _b.call(rule, file.filename)) {
|
||||
return false;
|
||||
}
|
||||
if (!isIncluded && ((_c = rule.isInclude) === null || _c === void 0 ? void 0 : _c.call(rule, file.filename))) {
|
||||
isIncluded = true;
|
||||
}
|
||||
}
|
||||
return isIncluded;
|
||||
}
|
||||
default:
|
||||
return patterns.some(aPredicate);
|
||||
}
|
||||
}
|
||||
parseFilterItemYaml(item) {
|
||||
@@ -142,21 +172,19 @@ class Filter {
|
||||
return flat(item.map(i => this.parseFilterItemYaml(i)));
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
return [{ status: undefined, isMatch: (0, picomatch_1.default)(item, MatchOptions) }];
|
||||
return [createRuleItem(item)];
|
||||
}
|
||||
if (typeof item === 'object') {
|
||||
return Object.entries(item).map(([key, pattern]) => {
|
||||
if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) {
|
||||
this.throwInvalidFormatError(`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()),
|
||||
isMatch: (0, picomatch_1.default)(pattern, MatchOptions)
|
||||
};
|
||||
const status = key
|
||||
.split('|')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.length > 0)
|
||||
.map(x => x.toLowerCase());
|
||||
return createRuleItem(pattern, status);
|
||||
});
|
||||
}
|
||||
this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`);
|
||||
@@ -171,6 +199,24 @@ exports.Filter = Filter;
|
||||
function flat(arr) {
|
||||
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, status) {
|
||||
const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern => (0, picomatch_1.default)(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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/***/ }),
|
||||
@@ -204,17 +250,46 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.HEAD = exports.NULL_SHA = void 0;
|
||||
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.gitExec = exports.HEAD = exports.NULL_SHA = void 0;
|
||||
const exec_1 = __nccwpck_require__(1514);
|
||||
const core = __importStar(__nccwpck_require__(2186));
|
||||
const file_1 = __nccwpck_require__(4014);
|
||||
const safe_directory_1 = __nccwpck_require__(2126);
|
||||
exports.NULL_SHA = '0000000000000000000000000000000000000000';
|
||||
exports.HEAD = 'HEAD';
|
||||
async function gitExec(args, options) {
|
||||
var _a;
|
||||
// ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below
|
||||
const execute = async () => (0, exec_1.getExecOutput)('git', args, { ...options, ignoreReturnCode: true, env: (0, safe_directory_1.getGitEnv)() });
|
||||
let result = await execute();
|
||||
if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) {
|
||||
if (await (0, safe_directory_1.ensureSafeDirectory)(result.stderr)) {
|
||||
result = await execute();
|
||||
}
|
||||
if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) {
|
||||
const firstLine = (_a = result.stderr
|
||||
.split(/\r?\n/)
|
||||
.find(line => line.trim().length > 0)) === null || _a === void 0 ? void 0 : _a.trim();
|
||||
throw new Error(`${firstLine !== null && firstLine !== void 0 ? 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 === null || options === void 0 ? void 0 : options.ignoreReturnCode)) {
|
||||
throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
exports.gitExec = gitExec;
|
||||
async function getChangesInLastCommit() {
|
||||
core.startGroup(`Change detection in last commit`);
|
||||
let output = '';
|
||||
try {
|
||||
output = (await (0, exec_1.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();
|
||||
@@ -231,8 +306,7 @@ async function getChanges(base, head) {
|
||||
let output = '';
|
||||
try {
|
||||
// Two dots '..' change detection - directly compares two versions
|
||||
output = (await (0, exec_1.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();
|
||||
@@ -246,7 +320,7 @@ async function getChangesOnHead() {
|
||||
core.startGroup(`Change detection on HEAD`);
|
||||
let output = '';
|
||||
try {
|
||||
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout;
|
||||
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout;
|
||||
}
|
||||
finally {
|
||||
fixStdOutNullTermination();
|
||||
@@ -262,7 +336,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
|
||||
if (baseRef === undefined || headRef === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (await (0, exec_1.getExecOutput)('git', ['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0;
|
||||
return (await gitExec(['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0;
|
||||
}
|
||||
let noMergeBase = false;
|
||||
core.startGroup(`Searching for merge-base ${base}...${head}`);
|
||||
@@ -270,12 +344,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
|
||||
baseRef = await getLocalRef(base);
|
||||
headRef = await getLocalRef(head);
|
||||
if (!(await hasMergeBase())) {
|
||||
await (0, exec_1.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 !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base));
|
||||
headRef = headRef !== null && headRef !== void 0 ? headRef : (await getLocalRef(head));
|
||||
if (baseRef === undefined || headRef === undefined) {
|
||||
await (0, exec_1.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 !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base));
|
||||
@@ -292,12 +366,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
|
||||
let lastCommitCount = await getCommitCount();
|
||||
while (!(await hasMergeBase())) {
|
||||
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER);
|
||||
await (0, exec_1.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 (0, exec_1.getExecOutput)('git', ['fetch']);
|
||||
await gitExec(['fetch']);
|
||||
if (!(await hasMergeBase())) {
|
||||
noMergeBase = true;
|
||||
}
|
||||
@@ -320,7 +394,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
|
||||
core.startGroup(`Change detection ${diffArg}`);
|
||||
let output = '';
|
||||
try {
|
||||
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout;
|
||||
output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout;
|
||||
}
|
||||
finally {
|
||||
fixStdOutNullTermination();
|
||||
@@ -345,7 +419,7 @@ async function listAllFilesAsAdded() {
|
||||
core.startGroup('Listing all files tracked by git');
|
||||
let output = '';
|
||||
try {
|
||||
output = (await (0, exec_1.getExecOutput)('git', ['ls-files', '-z'])).stdout;
|
||||
output = (await gitExec(['ls-files', '-z'])).stdout;
|
||||
}
|
||||
finally {
|
||||
fixStdOutNullTermination();
|
||||
@@ -363,15 +437,15 @@ exports.listAllFilesAsAdded = listAllFilesAsAdded;
|
||||
async function getCurrentRef() {
|
||||
core.startGroup(`Get current git ref`);
|
||||
try {
|
||||
const branch = (await (0, exec_1.getExecOutput)('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
||||
const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
||||
if (branch && branch !== 'HEAD') {
|
||||
return branch;
|
||||
}
|
||||
const describe = await (0, exec_1.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 (0, exec_1.getExecOutput)('git', ['rev-parse', exports.HEAD])).stdout.trim();
|
||||
return (await gitExec(['rev-parse', exports.HEAD])).stdout.trim();
|
||||
}
|
||||
finally {
|
||||
core.endGroup();
|
||||
@@ -395,10 +469,10 @@ function isGitSha(ref) {
|
||||
}
|
||||
exports.isGitSha = isGitSha;
|
||||
async function hasCommit(ref) {
|
||||
return (await (0, exec_1.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() {
|
||||
const output = (await (0, exec_1.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;
|
||||
}
|
||||
@@ -406,7 +480,7 @@ async function getLocalRef(shortName) {
|
||||
if (isGitSha(shortName)) {
|
||||
return (await hasCommit(shortName)) ? shortName : undefined;
|
||||
}
|
||||
const output = (await (0, exec_1.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))\/(.*)$/))
|
||||
@@ -426,10 +500,10 @@ async function ensureRefAvailable(name) {
|
||||
try {
|
||||
let ref = await getLocalRef(name);
|
||||
if (ref === undefined) {
|
||||
await (0, exec_1.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 (0, exec_1.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`);
|
||||
@@ -473,7 +547,7 @@ function csvEscape(value) {
|
||||
if (value === '')
|
||||
return value;
|
||||
// Only safe characters
|
||||
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9._+:@%/-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
// https://tools.ietf.org/html/rfc4180
|
||||
@@ -505,12 +579,12 @@ function shellEscape(value) {
|
||||
if (value === '')
|
||||
return value;
|
||||
// Only safe characters
|
||||
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9,._+:@%/-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value.includes("'")) {
|
||||
// Only safe characters, single quotes and white-spaces
|
||||
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/.test(value)) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
// Split by single quote and apply escaping recursively
|
||||
@@ -559,6 +633,7 @@ const github = __importStar(__nccwpck_require__(5438));
|
||||
const filter_1 = __nccwpck_require__(3707);
|
||||
const file_1 = __nccwpck_require__(4014);
|
||||
const git = __importStar(__nccwpck_require__(3374));
|
||||
const safe_directory_1 = __nccwpck_require__(2126);
|
||||
const shell_escape_1 = __nccwpck_require__(4613);
|
||||
const csv_escape_1 = __nccwpck_require__(7402);
|
||||
async function run() {
|
||||
@@ -575,7 +650,6 @@ async function run() {
|
||||
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 }) || filter_1.PredicateQuantifier.SOME;
|
||||
const allowOverrideOnPr = core.getBooleanInput('allow-override-on-pr', { required: false });
|
||||
if (!isExportFormat(listFiles)) {
|
||||
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`);
|
||||
return;
|
||||
@@ -587,7 +661,7 @@ async function run() {
|
||||
}
|
||||
const filterConfig = { predicateQuantifier };
|
||||
const filter = new filter_1.Filter(filtersYaml, filterConfig);
|
||||
const files = await getChangedFiles(token, base, ref, initialFetchDepth, allowOverrideOnPr);
|
||||
const files = await getChangedFiles(token, base, ref, initialFetchDepth);
|
||||
core.info(`Detected ${files.length} changed files`);
|
||||
const results = filter.match(files);
|
||||
exportResults(results, listFiles);
|
||||
@@ -595,6 +669,9 @@ async function run() {
|
||||
catch (error) {
|
||||
core.setFailed(getErrorMessage(error));
|
||||
}
|
||||
finally {
|
||||
await (0, safe_directory_1.cleanup)();
|
||||
}
|
||||
}
|
||||
function isPathInput(text) {
|
||||
return !(text.includes('\n') || text.includes(':'));
|
||||
@@ -608,7 +685,7 @@ function getConfigFileContent(configPath) {
|
||||
}
|
||||
return fs.readFileSync(configPath, { encoding: 'utf8' });
|
||||
}
|
||||
async function getChangedFiles(token, base, ref, initialFetchDepth, allowOverrideOnPr) {
|
||||
async function getChangedFiles(token, base, ref, initialFetchDepth) {
|
||||
var _a, _b;
|
||||
// if base is 'HEAD' only local uncommitted changes will be detected
|
||||
// This is the simplest case as we don't need to fetch more commits or evaluate current/before refs
|
||||
@@ -625,18 +702,14 @@ async function getChangedFiles(token, base, ref, initialFetchDepth, allowOverrid
|
||||
case 'pull_request_review':
|
||||
case 'pull_request_review_comment':
|
||||
case 'pull_request_target': {
|
||||
if (allowOverrideOnPr && (base || ref)) {
|
||||
core.info(`'allow-override-on-pr' is enabled and base/ref were provided — skipping PR API and using git diff`);
|
||||
return getChangedFilesFromGit(base, ref, initialFetchDepth);
|
||||
}
|
||||
if (ref) {
|
||||
core.warning(`'ref' input parameter is ignored when action is triggered by pull request event`);
|
||||
}
|
||||
if (base) {
|
||||
core.warning(`'base' input parameter is ignored when action is triggered by pull request event`);
|
||||
}
|
||||
const pr = github.context.payload.pull_request;
|
||||
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') {
|
||||
@@ -646,6 +719,9 @@ async function getChangedFiles(token, base, ref, initialFetchDepth, allowOverrid
|
||||
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 = (_a = github.context.payload.pull_request) === null || _a === void 0 ? void 0 : _a.base.sha;
|
||||
const defaultBranch = (_b = github.context.payload.repository) === null || _b === void 0 ? void 0 : _b.default_branch;
|
||||
const currentRef = await git.getCurrentRef();
|
||||
@@ -822,6 +898,164 @@ function getErrorMessage(error) {
|
||||
run();
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 2126:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.resolveTempBaseDir = exports.buildGitEnv = exports.createTempGitHome = exports.cleanup = exports.ensureSafeDirectory = exports.getGitEnv = exports.parseRepositoryPath = exports.isDubiousOwnershipError = void 0;
|
||||
const fs = __importStar(__nccwpck_require__(7147));
|
||||
const os = __importStar(__nccwpck_require__(2037));
|
||||
const path = __importStar(__nccwpck_require__(1017));
|
||||
const core = __importStar(__nccwpck_require__(2186));
|
||||
const exec_1 = __nccwpck_require__(1514);
|
||||
// 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;
|
||||
let gitEnv;
|
||||
const safeDirectories = new Set();
|
||||
function isDubiousOwnershipError(exitCode, stderr) {
|
||||
return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr);
|
||||
}
|
||||
exports.isDubiousOwnershipError = isDubiousOwnershipError;
|
||||
function parseRepositoryPath(stderr) {
|
||||
var _a;
|
||||
return (_a = stderr.match(REPOSITORY_PATH_PATTERN)) === null || _a === void 0 ? void 0 : _a[1];
|
||||
}
|
||||
exports.parseRepositoryPath = parseRepositoryPath;
|
||||
// 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.
|
||||
function getGitEnv() {
|
||||
return { ...(gitEnv !== null && gitEnv !== void 0 ? gitEnv : cloneDefinedEnv(process.env)), LC_ALL: 'C' };
|
||||
}
|
||||
exports.getGitEnv = getGitEnv;
|
||||
// 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.
|
||||
async function ensureSafeDirectory(stderr) {
|
||||
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 (0, exec_1.exec)('git', ['config', '--global', '--add', 'safe.directory', dir], { env: getGitEnv() });
|
||||
safeDirectories.add(dir);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
exports.ensureSafeDirectory = ensureSafeDirectory;
|
||||
async function cleanup() {
|
||||
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();
|
||||
}
|
||||
exports.cleanup = cleanup;
|
||||
// Exported for tests
|
||||
async function createTempGitHome(baseTempDir, env) {
|
||||
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;
|
||||
}
|
||||
exports.createTempGitHome = createTempGitHome;
|
||||
// Exported for tests
|
||||
function buildGitEnv(tempHome, env) {
|
||||
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;
|
||||
}
|
||||
exports.buildGitEnv = buildGitEnv;
|
||||
// Exported for tests
|
||||
function resolveTempBaseDir(env) {
|
||||
return env.RUNNER_TEMP || os.tmpdir();
|
||||
}
|
||||
exports.resolveTempBaseDir = resolveTempBaseDir;
|
||||
function cloneDefinedEnv(env) {
|
||||
const newEnv = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined) {
|
||||
newEnv[key] = value;
|
||||
}
|
||||
}
|
||||
return newEnv;
|
||||
}
|
||||
async function copyFileIfExists(source, destination) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 7351:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
74
src/git.ts
74
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', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
|
||||
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`)
|
||||
|
||||
@@ -4,7 +4,7 @@ export function csvEscape(value: string): string {
|
||||
if (value === '') return value
|
||||
|
||||
// Only safe characters
|
||||
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9._+:@%/-]+$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ export function shellEscape(value: string): string {
|
||||
if (value === '') return value
|
||||
|
||||
// Only safe characters
|
||||
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9,._+:@%/-]+$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value.includes("'")) {
|
||||
// Only safe characters, single quotes and white-spaces
|
||||
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
|
||||
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/.test(value)) {
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
|
||||
29
src/main.ts
29
src/main.ts
@@ -14,6 +14,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'
|
||||
|
||||
@@ -34,7 +35,6 @@ async function run(): Promise<void> {
|
||||
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
|
||||
const allowOverrideOnPr = core.getBooleanInput('allow-override-on-pr', {required: false})
|
||||
|
||||
if (!isExportFormat(listFiles)) {
|
||||
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
|
||||
@@ -50,12 +50,14 @@ async function run(): Promise<void> {
|
||||
const filterConfig: FilterConfig = {predicateQuantifier}
|
||||
|
||||
const filter = new Filter(filtersYaml, filterConfig)
|
||||
const files = await getChangedFiles(token, base, ref, initialFetchDepth, allowOverrideOnPr)
|
||||
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(getErrorMessage(error))
|
||||
} finally {
|
||||
await cleanupSafeDirectory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +77,7 @@ function getConfigFileContent(configPath: string): string {
|
||||
return fs.readFileSync(configPath, {encoding: 'utf8'})
|
||||
}
|
||||
|
||||
async function getChangedFiles(
|
||||
token: string,
|
||||
base: string,
|
||||
ref: string,
|
||||
initialFetchDepth: number,
|
||||
allowOverrideOnPr: boolean
|
||||
): Promise<File[]> {
|
||||
async function getChangedFiles(token: string, base: string, ref: string, initialFetchDepth: number): Promise<File[]> {
|
||||
// if base is 'HEAD' only local uncommitted changes will be detected
|
||||
// This is the simplest case as we don't need to fetch more commits or evaluate current/before refs
|
||||
if (base === git.HEAD) {
|
||||
@@ -98,18 +94,16 @@ async function getChangedFiles(
|
||||
case 'pull_request_review':
|
||||
case 'pull_request_review_comment':
|
||||
case 'pull_request_target': {
|
||||
if (allowOverrideOnPr && (base || ref)) {
|
||||
core.info(`'allow-override-on-pr' is enabled and base/ref were provided — skipping PR API and using git diff`)
|
||||
return getChangedFilesFromGit(base, ref, initialFetchDepth)
|
||||
}
|
||||
if (ref) {
|
||||
core.warning(`'ref' input parameter is ignored when action is triggered by pull request event`)
|
||||
}
|
||||
if (base) {
|
||||
core.warning(`'base' 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') {
|
||||
@@ -119,6 +113,9 @@ async function getChangedFiles(
|
||||
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()
|
||||
|
||||
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