diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e59966..7f3a7af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,13 @@
# Changelog
-## v2.12.0
-- [Update to Node.js 20 ](https://github.com/dorny/paths-filter/pull/210)
+## v3.0.2
+- [Add config parameter for predicate quantifier](https://github.com/dorny/paths-filter/pull/224)
+
+## v3.0.1
+- [Compare base and ref when token is empty](https://github.com/dorny/paths-filter/pull/133)
+
+## v3.0.0
+- [Update to Node.js 20](https://github.com/dorny/paths-filter/pull/210)
- [Update all dependencies](https://github.com/dorny/paths-filter/pull/215)
## v2.11.1
diff --git a/README.md b/README.md
index 144a334..b5e0f4c 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ don't allow this because they don't work on a level of individual jobs or steps.
## Example
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
@@ -72,6 +72,7 @@ For more scenarios see [examples](#examples) section.
## What's New
+- New major release `v3` after update to Node 20 [Breaking change]
- Add `ref` input parameter
- Add `list-files: csv` format
- Configure matrix job to run for each folder with changes using `changes` output
@@ -83,7 +84,7 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
## Usage
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
with:
# Defines filters applied to detected changed files.
# Each filter has a name and a list of rules.
@@ -152,6 +153,22 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
# changes using git commands.
# Default: ${{ github.token }}
token: ''
+
+ # Optional parameter to override the default behavior of file matching algorithm.
+ # By default files that match at least one pattern defined by the filters will be included.
+ # This parameter allows to override the "at least one pattern" behavior to make it so that
+ # all of the patterns have to match or otherwise the file is excluded.
+ # An example scenario where this is useful if you would like to match all
+ # .ts files in a sub-directory but not .md files.
+ # The filters below will match markdown files despite the exclusion syntax UNLESS
+ # you specify 'every' as the predicate-quantifier parameter. When you do that,
+ # it will only match the .ts files in the subdirectory as expected.
+ #
+ # backend:
+ # - 'pkg/a/b/c/**'
+ # - '!**/*.jpeg'
+ # - '!**/*.md'
+ predicate-quantifier: 'some'
```
## Outputs
@@ -176,7 +193,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -220,7 +237,7 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
# For pull requests it's not necessary to checkout the code
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -266,7 +283,7 @@ jobs:
packages: ${{ steps.filter.outputs.changes }}
steps:
# For pull requests it's not necessary to checkout the code
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -308,7 +325,7 @@ jobs:
pull-requests: read
steps:
- uses: actions/checkout@v4
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: ... # Configure your filters
@@ -333,7 +350,7 @@ jobs:
# This may save additional git fetch roundtrip if
# merge-base is found within latest 20 commits
fetch-depth: 20
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
base: develop # Change detection against merge-base with this branch
@@ -357,7 +374,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
# Use context to get the branch where commits were pushed.
@@ -391,7 +408,7 @@ jobs:
# Filter to detect which files were modified
# Changes could be, for example, automatically committed
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
base: HEAD
@@ -406,7 +423,7 @@ jobs:
Define filter rules in own file
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: filter
with:
# Path to file where filters are defined
@@ -419,7 +436,7 @@ jobs:
Use YAML anchors to reuse path expression(s) inside another rule
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: filter
with:
# &shared is YAML anchor,
@@ -440,7 +457,7 @@ jobs:
Consider if file was added, modified or deleted
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: filter
with:
# Changed file can be 'added', 'modified', or 'deleted'.
@@ -462,13 +479,39 @@ jobs:
+
+ Detect changes in folder only for some file extensions
+
+```yaml
+- uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ # This makes it so that all the patterns have to match a file for it to be
+ # considered changed. Because we have the exclusions for .jpeg and .md files
+ # the end result is that if those files are changed they will be ignored
+ # because they don't match the respective rules excluding them.
+ #
+ # This can be leveraged to ensure that you only build & test software changes
+ # that have real impact on the behavior of the code, e.g. you can set up your
+ # build to run when Typescript/Rust/etc. files are changed but markdown
+ # changes in the diff will be ignored and you consume less resources to build.
+ predicate-quantifier: 'every'
+ filters: |
+ backend:
+ - 'pkg/a/b/c/**'
+ - '!**/*.jpeg'
+ - '!**/*.md'
+```
+
+
+
### Custom processing of changed files
Passing list of modified files as command line args in Linux shell
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: filter
with:
# Enable listing of files matching each filter.
@@ -494,7 +537,7 @@ jobs:
Passing list of modified files as JSON array to another action
```yaml
-- uses: dorny/paths-filter@v2
+- uses: dorny/paths-filter@v3
id: filter
with:
# Enable listing of files matching each filter.
diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts
index be2a148..7d7da94 100644
--- a/__tests__/filter.test.ts
+++ b/__tests__/filter.test.ts
@@ -1,4 +1,4 @@
-import {Filter} from '../src/filter'
+import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter'
import {File, ChangeStatus} from '../src/file'
describe('yaml filter parsing tests', () => {
@@ -117,6 +117,37 @@ describe('matching tests', () => {
expect(pyMatch.backend).toEqual(pyFiles)
})
+ test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => {
+ const yaml = `
+ backend:
+ - 'pkg/a/b/c/**'
+ - '!**/*.jpeg'
+ - '!**/*.md'
+ `
+ const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY}
+ const filter = new Filter(yaml, filterConfig)
+
+ const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts'])
+ const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts'])
+ const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg'])
+ const docsFiles = modified([
+ 'pkg/a/b/c/some-pics.jpeg',
+ 'pkg/a/b/c/src/main/jpeg/some-pic.jpeg',
+ 'pkg/a/b/c/src/main/some-docs.md',
+ 'pkg/a/b/c/some-docs.md'
+ ])
+
+ const typescriptMatch = filter.match(typescriptFiles)
+ const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles)
+ const docsMatch = filter.match(docsFiles)
+ const otherPkgJpegMatch = filter.match(otherPkgJpegFiles)
+
+ expect(typescriptMatch.backend).toEqual(typescriptFiles)
+ expect(otherPkgTypescriptMatch.backend).toEqual([])
+ expect(docsMatch.backend).toEqual([])
+ expect(otherPkgJpegMatch.backend).toEqual([])
+ })
+
test('matches path based on rules included using YAML anchor', () => {
const yaml = `
shared: &shared
@@ -186,3 +217,9 @@ function modified(paths: string[]): File[] {
return {filename, status: ChangeStatus.Modified}
})
}
+
+function renamed(paths: string[]): File[] {
+ return paths.map(filename => {
+ return {filename, status: ChangeStatus.Renamed}
+ })
+}
diff --git a/action.yml b/action.yml
index e7d24f5..f03ed40 100644
--- a/action.yml
+++ b/action.yml
@@ -44,6 +44,11 @@ inputs:
This option takes effect only when changes are detected using git against different base branch.
required: false
default: '100'
+ predicate-quantifier:
+ description: |
+ allows to override the "at least one pattern" behavior to make it so that all of the patterns have to match or otherwise the file is excluded.
+ required: false
+ default: 'some'
outputs:
changes:
description: JSON array with names of all filters matching any of changed files
diff --git a/dist/index.js b/dist/index.js
index 4a35e5b..cc7d7d4 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -53,16 +53,53 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
-exports.Filter = void 0;
+exports.Filter = exports.isPredicateQuantifier = exports.SUPPORTED_PREDICATE_QUANTIFIERS = exports.PredicateQuantifier = void 0;
const jsyaml = __importStar(__nccwpck_require__(1917));
const picomatch_1 = __importDefault(__nccwpck_require__(8569));
// Minimatch options used in all matchers
const MatchOptions = {
dot: true
};
+/**
+ * Enumerates the possible logic quantifiers that can be used when determining
+ * if a file is a match or not with multiple patterns.
+ *
+ * The YAML configuration property that is parsed into one of these values is
+ * 'predicate-quantifier' on the top level of the configuration object of the
+ * action.
+ *
+ * The default is to use 'some' which used to be the hardcoded behavior prior to
+ * the introduction of the new mechanism.
+ *
+ * @see https://en.wikipedia.org/wiki/Quantifier_(logic)
+ */
+var PredicateQuantifier;
+(function (PredicateQuantifier) {
+ /**
+ * When choosing 'every' in the config it means that files will only get matched
+ * if all the patterns are satisfied by the path of the file, not just at least one of them.
+ */
+ PredicateQuantifier["EVERY"] = "every";
+ /**
+ * When choosing 'some' in the config it means that files will get matched as long as there is
+ * at least one pattern that matches them. This is the default behavior if you don't
+ * specify anything as a predicate quantifier.
+ */
+ PredicateQuantifier["SOME"] = "some";
+})(PredicateQuantifier || (exports.PredicateQuantifier = PredicateQuantifier = {}));
+/**
+ * An array of strings (at runtime) that contains the valid/accepted values for
+ * the configuration parameter 'predicate-quantifier'.
+ */
+exports.SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier);
+function isPredicateQuantifier(x) {
+ return exports.SUPPORTED_PREDICATE_QUANTIFIERS.includes(x);
+}
+exports.isPredicateQuantifier = isPredicateQuantifier;
class Filter {
// Creates instance of Filter and load rules from YAML if it's provided
- constructor(yaml) {
+ constructor(yaml, filterConfig) {
+ this.filterConfig = filterConfig;
this.rules = {};
if (yaml) {
this.load(yaml);
@@ -89,7 +126,16 @@ class Filter {
return result;
}
isMatch(file, patterns) {
- return patterns.some(rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename));
+ var _a;
+ const aPredicate = (rule) => {
+ return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename);
+ };
+ if (((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === 'every') {
+ return patterns.every(aPredicate);
+ }
+ else {
+ return patterns.some(aPredicate);
+ }
}
parseFilterItemYaml(item) {
if (Array.isArray(item)) {
@@ -528,11 +574,18 @@ async function run() {
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput;
const listFiles = core.getInput('list-files', { required: false }).toLowerCase() || 'none';
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', { required: false })) || 10;
+ const predicateQuantifier = core.getInput('predicate-quantifier', { required: false }) || filter_1.PredicateQuantifier.SOME;
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`);
return;
}
- const filter = new filter_1.Filter(filtersYaml);
+ if (!(0, filter_1.isPredicateQuantifier)(predicateQuantifier)) {
+ const predicateQuantifierInvalidErrorMsg = `Input parameter 'predicate-quantifier' is set to invalid value ` +
+ `'${predicateQuantifier}'. Valid values: ${filter_1.SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`;
+ throw new Error(predicateQuantifierInvalidErrorMsg);
+ }
+ const filterConfig = { predicateQuantifier };
+ const filter = new filter_1.Filter(filtersYaml, filterConfig);
const files = await getChangedFiles(token, base, ref, initialFetchDepth);
core.info(`Detected ${files.length} changed files`);
const results = filter.match(files);
@@ -555,6 +608,7 @@ function getConfigFileContent(configPath) {
return fs.readFileSync(configPath, { encoding: 'utf8' });
}
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
if (base === git.HEAD) {
@@ -581,8 +635,11 @@ async function getChangedFiles(token, base, ref, initialFetchDepth) {
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`);
}
- core.info('Github token is not available - changes will be detected from PRs merge commit');
- return await git.getChangesInLastCommit();
+ core.info('Github token is not available - changes will be detected using git diff');
+ 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();
+ return await git.getChanges(base || baseSha || defaultBranch, currentRef);
}
else {
return getChangedFilesFromGit(base, ref, initialFetchDepth);
diff --git a/src/filter.ts b/src/filter.ts
index d0428e4..1947ef8 100644
--- a/src/filter.ts
+++ b/src/filter.ts
@@ -23,6 +23,48 @@ interface FilterRuleItem {
isMatch: (str: string) => boolean // Matches the filename
}
+/**
+ * Enumerates the possible logic quantifiers that can be used when determining
+ * if a file is a match or not with multiple patterns.
+ *
+ * The YAML configuration property that is parsed into one of these values is
+ * 'predicate-quantifier' on the top level of the configuration object of the
+ * action.
+ *
+ * The default is to use 'some' which used to be the hardcoded behavior prior to
+ * the introduction of the new mechanism.
+ *
+ * @see https://en.wikipedia.org/wiki/Quantifier_(logic)
+ */
+export enum PredicateQuantifier {
+ /**
+ * When choosing 'every' in the config it means that files will only get matched
+ * if all the patterns are satisfied by the path of the file, not just at least one of them.
+ */
+ EVERY = 'every',
+ /**
+ * When choosing 'some' in the config it means that files will get matched as long as there is
+ * at least one pattern that matches them. This is the default behavior if you don't
+ * specify anything as a predicate quantifier.
+ */
+ SOME = 'some'
+}
+
+/**
+ * Used to define customizations for how the file filtering should work at runtime.
+ */
+export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier}
+
+/**
+ * An array of strings (at runtime) that contains the valid/accepted values for
+ * the configuration parameter 'predicate-quantifier'.
+ */
+export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier)
+
+export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier {
+ return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier)
+}
+
export interface FilterResults {
[key: string]: File[]
}
@@ -31,7 +73,7 @@ export class Filter {
rules: {[key: string]: FilterRuleItem[]} = {}
// Creates instance of Filter and load rules from YAML if it's provided
- constructor(yaml?: string) {
+ constructor(yaml?: string, readonly filterConfig?: FilterConfig) {
if (yaml) {
this.load(yaml)
}
@@ -62,9 +104,14 @@ export class Filter {
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
- return patterns.some(
- rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
- )
+ const aPredicate = (rule: Readonly): boolean => {
+ return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
+ }
+ if (this.filterConfig?.predicateQuantifier === 'every') {
+ return patterns.every(aPredicate)
+ } else {
+ return patterns.some(aPredicate)
+ }
}
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
diff --git a/src/main.ts b/src/main.ts
index 6f5fe6a..8320287 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,7 +4,14 @@ import * as github from '@actions/github'
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
-import {Filter, FilterResults} from './filter'
+import {
+ isPredicateQuantifier,
+ Filter,
+ FilterConfig,
+ FilterResults,
+ PredicateQuantifier,
+ SUPPORTED_PREDICATE_QUANTIFIERS
+} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
@@ -26,13 +33,22 @@ async function run(): Promise {
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
+ const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
return
}
- const filter = new Filter(filtersYaml)
+ if (!isPredicateQuantifier(predicateQuantifier)) {
+ const predicateQuantifierInvalidErrorMsg =
+ `Input parameter 'predicate-quantifier' is set to invalid value ` +
+ `'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
+ throw new Error(predicateQuantifierInvalidErrorMsg)
+ }
+ const filterConfig: FilterConfig = {predicateQuantifier}
+
+ const filter = new Filter(filtersYaml, filterConfig)
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
@@ -86,8 +102,11 @@ async function getChangedFiles(token: string, base: string, ref: string, initial
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
}
- core.info('Github token is not available - changes will be detected from PRs merge commit')
- return await git.getChangesInLastCommit()
+ core.info('Github token is not available - changes will be detected using git diff')
+ const baseSha = github.context.payload.pull_request?.base.sha
+ const defaultBranch = github.context.payload.repository?.default_branch
+ const currentRef = await git.getCurrentRef()
+ return await git.getChanges(base || baseSha || defaultBranch, currentRef)
} else {
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}