diff --git a/.eslintrc.js b/.eslintrc.js index 1a02db6..f555eac 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,5 +22,6 @@ module.exports = { sourceType: 'module', }, rules: { + 'import/extensions': ['error', 'ignorePackages'], }, }; diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2caa2de..4cd8544 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: main: runs-on: ubuntu-latest outputs: - matches: ${{ steps.for-each-4.outputs.matches }} + matches: ${{ steps.for-each.outputs.matches }} steps: - uses: actions/checkout@v4 @@ -44,6 +44,47 @@ jobs: - name: Run colpal/actions-for-each id: for-each-4 uses: ./ + with: + root-patterns: '*/' + filter-patterns: '**/main.*' + - if: steps.for-each-4.outputs.matches != '["dist/"]' + run: exit 1 + + - name: Run colpal/actions-for-each + id: for-each-5 + uses: ./ + with: + root-patterns: | + ./ + **/ + filter-patterns: '**/main.*' + - if: steps.for-each-5.outputs.matches != '["./","dist/"]' + run: exit 1 + + - name: Run colpal/actions-for-each + id: for-each-6 + uses: ./ + with: + root-patterns: | + **/*-b/ + filter-patterns: '**/file.txt' + - if: steps.for-each-6.outputs.matches != '["fixtures/function-b/","fixtures/run-b/"]' + run: exit 1 + + - name: Run colpal/actions-for-each + id: for-each-7 + uses: ./ + with: + root-patterns: | + ./ + filter-patterns: | + **/flie.txt + - if: steps.for-each-7.outputs.matches != '[]' + run: exit 1 + + - name: Run colpal/actions-for-each + id: for-each + uses: ./ with: patterns: 'fixtures/run-*' diff --git a/README.md b/README.md index 7c2f5df..58f9525 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ steps: # ["function-a/file.txt","function-b/file.txt"] ``` -### Parallel +### Static Parallel ```yaml jobs: @@ -75,11 +75,51 @@ steps: - id: for-each uses: colpal/actions-for-each@v0.1 with: - # REQUIRED + # REQUIRED (if `root-patterns` is not specified) # The pattern(s) to be used to find folders/files. More than one pattern # may be supplied by putting each on its own line. - patterns: string + patterns: single-line string | multi-line string + + # OPTIONAL (only valid if `root-patterns` is specified) + # DEFAULT = ** + # The pattern(s) to be used to find files/folders to provide to + # `root-patterns` (see the description of `root-patterns` for more + # details) + filter-patterns: single-line string | multi-line string + + # REQUIRED (if `patterns` is not specified) + # The pattern(s) to be used to "hoist" files/folders matched by + # `filter-patterns`. The process is as follows: + # 1. `filter-patterns` is applied to the filesystem to create a list + # of paths (similar to what `patterns` does when specified alone) + # 2. `root-patterns` is then applied to the paths from the previous + # step. Any paths that match will be added to the `matches` output. + # 3. `dirname` will be applied to all paths from step 2 that DID NOT + # match `root-patterns`, effectively removing the last segment of + # the path. For example: + # `folder-a/subfolder-a/file-a` becomes `folder-a/subfolder-a/` + # `folder-a/subfolder-a/` becomes `folder-a/` + # `folder-a/` becomes `./` + # `./` becomes `./` + # 4. Repeat steps 2 - 4 until the only unmatched paths are "./" + # 5. `matches` is set as an output of the action + root-patterns: single-line string | multi-line string outputs: # An JSON-formatted array of paths that matched the pattern(s) matches: ${{ steps.for-each.outputs.matches }} ``` + +## Recipes + +### Find all folders that contain certain files + +```yaml +- uses: colpal/actions-for-each@v0.1 + with: + root-patterns: | + ./ + **/ + filter-patterns: | + **/main.sh + **/start.sh +``` diff --git a/action.yaml b/action.yaml index 02c84c5..4bba380 100644 --- a/action.yaml +++ b/action.yaml @@ -3,7 +3,14 @@ description: Create "matrix" compliant file/folder lists using patterns inputs: patterns: description: The pattern(s) with which to match files/folders - required: true + root-patterns: + description: > + The pattern(s) to which all files/folders matched by `filter-patterns` + will be hoisted + filter-patterns: + description: > + The pattern(s) that will be used to filter (but not match) files/folders + default: '**' outputs: matches: description: The files/folders that matched the pattern diff --git a/dist/main.js b/dist/main.js index 954fe58..7f52230 100755 --- a/dist/main.js +++ b/dist/main.js @@ -21420,7 +21420,7 @@ var require_micromatch = __commonJS({ var picomatch = require_picomatch2(); var utils = require_utils4(); var isEmptyString = (val) => val === "" || val === "./"; - var micromatch = (list, patterns, options) => { + var micromatch2 = (list, patterns, options) => { patterns = [].concat(patterns); list = [].concat(list); let omit = /* @__PURE__ */ new Set(); @@ -21463,11 +21463,11 @@ var require_micromatch = __commonJS({ } return matches; }; - micromatch.match = micromatch; - micromatch.matcher = (pattern, options) => picomatch(pattern, options); - micromatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); - micromatch.any = micromatch.isMatch; - micromatch.not = (list, patterns, options = {}) => { + micromatch2.match = micromatch2; + micromatch2.matcher = (pattern, options) => picomatch(pattern, options); + micromatch2.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); + micromatch2.any = micromatch2.isMatch; + micromatch2.not = (list, patterns, options = {}) => { patterns = [].concat(patterns).map(String); let result = /* @__PURE__ */ new Set(); let items = []; @@ -21476,7 +21476,7 @@ var require_micromatch = __commonJS({ options.onResult(state); items.push(state.output); }; - let matches = new Set(micromatch(list, patterns, { ...options, onResult })); + let matches = new Set(micromatch2(list, patterns, { ...options, onResult })); for (let item of items) { if (!matches.has(item)) { result.add(item); @@ -21484,12 +21484,12 @@ var require_micromatch = __commonJS({ } return [...result]; }; - micromatch.contains = (str, pattern, options) => { + micromatch2.contains = (str, pattern, options) => { if (typeof str !== "string") { throw new TypeError(`Expected a string: "${util.inspect(str)}"`); } if (Array.isArray(pattern)) { - return pattern.some((p) => micromatch.contains(str, p, options)); + return pattern.some((p) => micromatch2.contains(str, p, options)); } if (typeof pattern === "string") { if (isEmptyString(str) || isEmptyString(pattern)) { @@ -21499,19 +21499,19 @@ var require_micromatch = __commonJS({ return true; } } - return micromatch.isMatch(str, pattern, { ...options, contains: true }); + return micromatch2.isMatch(str, pattern, { ...options, contains: true }); }; - micromatch.matchKeys = (obj, patterns, options) => { + micromatch2.matchKeys = (obj, patterns, options) => { if (!utils.isObject(obj)) { throw new TypeError("Expected the first argument to be an object"); } - let keys = micromatch(Object.keys(obj), patterns, options); + let keys = micromatch2(Object.keys(obj), patterns, options); let res = {}; for (let key of keys) res[key] = obj[key]; return res; }; - micromatch.some = (list, patterns, options) => { + micromatch2.some = (list, patterns, options) => { let items = [].concat(list); for (let pattern of [].concat(patterns)) { let isMatch = picomatch(String(pattern), options); @@ -21521,7 +21521,7 @@ var require_micromatch = __commonJS({ } return false; }; - micromatch.every = (list, patterns, options) => { + micromatch2.every = (list, patterns, options) => { let items = [].concat(list); for (let pattern of [].concat(patterns)) { let isMatch = picomatch(String(pattern), options); @@ -21531,13 +21531,13 @@ var require_micromatch = __commonJS({ } return true; }; - micromatch.all = (str, patterns, options) => { + micromatch2.all = (str, patterns, options) => { if (typeof str !== "string") { throw new TypeError(`Expected a string: "${util.inspect(str)}"`); } return [].concat(patterns).every((p) => picomatch(p, options)(str)); }; - micromatch.capture = (glob, input, options) => { + micromatch2.capture = (glob, input, options) => { let posix = utils.isWindows(options); let regex = picomatch.makeRe(String(glob), { ...options, capture: true }); let match = regex.exec(posix ? utils.toPosixSlashes(input) : input); @@ -21545,9 +21545,9 @@ var require_micromatch = __commonJS({ return match.slice(1).map((v) => v === void 0 ? "" : v); } }; - micromatch.makeRe = (...args) => picomatch.makeRe(...args); - micromatch.scan = (...args) => picomatch.scan(...args); - micromatch.parse = (patterns, options) => { + micromatch2.makeRe = (...args) => picomatch.makeRe(...args); + micromatch2.scan = (...args) => picomatch.scan(...args); + micromatch2.parse = (patterns, options) => { let res = []; for (let pattern of [].concat(patterns || [])) { for (let str of braces(String(pattern), options)) { @@ -21556,7 +21556,7 @@ var require_micromatch = __commonJS({ } return res; }; - micromatch.braces = (pattern, options) => { + micromatch2.braces = (pattern, options) => { if (typeof pattern !== "string") throw new TypeError("Expected a string"); if (options && options.nobrace === true || !/\{.*\}/.test(pattern)) { @@ -21564,12 +21564,12 @@ var require_micromatch = __commonJS({ } return braces(pattern, options); }; - micromatch.braceExpand = (pattern, options) => { + micromatch2.braceExpand = (pattern, options) => { if (typeof pattern !== "string") throw new TypeError("Expected a string"); - return micromatch.braces(pattern, { ...options, expand: true }); + return micromatch2.braces(pattern, { ...options, expand: true }); }; - module2.exports = micromatch; + module2.exports = micromatch2; } }); @@ -21581,7 +21581,7 @@ var require_pattern = __commonJS({ exports.removeDuplicateSlashes = exports.matchAny = exports.convertPatternsToRe = exports.makeRe = exports.getPatternParts = exports.expandBraceExpansion = exports.expandPatternsWithBraceExpansion = exports.isAffectDepthOfReadingPattern = exports.endsWithSlashGlobStar = exports.hasGlobStar = exports.getBaseDirectory = exports.isPatternRelatedToParentDirectory = exports.getPatternsOutsideCurrentDirectory = exports.getPatternsInsideCurrentDirectory = exports.getPositivePatterns = exports.getNegativePatterns = exports.isPositivePattern = exports.isNegativePattern = exports.convertToNegativePattern = exports.convertToPositivePattern = exports.isDynamicPattern = exports.isStaticPattern = void 0; var path2 = require("path"); var globParent = require_glob_parent(); - var micromatch = require_micromatch(); + var micromatch2 = require_micromatch(); var GLOBSTAR = "**"; var ESCAPE_SYMBOL = "\\"; var COMMON_GLOB_SYMBOLS_RE = /[*?]|^!/; @@ -21685,13 +21685,13 @@ var require_pattern = __commonJS({ } exports.expandPatternsWithBraceExpansion = expandPatternsWithBraceExpansion; function expandBraceExpansion(pattern) { - const patterns = micromatch.braces(pattern, { expand: true, nodupes: true, keepEscaping: true }); + const patterns = micromatch2.braces(pattern, { expand: true, nodupes: true, keepEscaping: true }); patterns.sort((a, b) => a.length - b.length); return patterns.filter((pattern2) => pattern2 !== ""); } exports.expandBraceExpansion = expandBraceExpansion; function getPatternParts(pattern, options) { - let { parts } = micromatch.scan(pattern, Object.assign(Object.assign({}, options), { parts: true })); + let { parts } = micromatch2.scan(pattern, Object.assign(Object.assign({}, options), { parts: true })); if (parts.length === 0) { parts = [pattern]; } @@ -21703,7 +21703,7 @@ var require_pattern = __commonJS({ } exports.getPatternParts = getPatternParts; function makeRe(pattern, options) { - return micromatch.makeRe(pattern, options); + return micromatch2.makeRe(pattern, options); } exports.makeRe = makeRe; function convertPatternsToRe(patterns, options) { @@ -21873,10 +21873,10 @@ var require_string = __commonJS({ return typeof input === "string"; } exports.isString = isString; - function isEmpty(input) { + function isEmpty2(input) { return input === ""; } - exports.isEmpty = isEmpty; + exports.isEmpty = isEmpty2; } }); @@ -24332,6 +24332,7 @@ var require_ignore = __commonJS({ }); // main.mjs +var import_path = require("path"); var core = __toESM(require_core(), 1); // node_modules/globby/index.js @@ -24688,16 +24689,70 @@ var generateGlobTasksSync = normalizeArgumentsSync(generateTasksSync); var { convertPathToPattern } = import_fast_glob2.default; // main.mjs -(async () => { - const patterns = core.getMultilineInput("patterns", { required: true }); - const matches = await globby(patterns, { +var import_micromatch = __toESM(require_micromatch(), 1); +function isEmpty(xs) { + return xs.length === 0; +} +function getInputs() { + const patterns = core.getMultilineInput("patterns"); + const rootPatterns = core.getMultilineInput("root-patterns"); + if (isEmpty(patterns) && isEmpty(rootPatterns)) { + throw new Error('Either "patterns" or "root-patterns" must be provided'); + } + if (!isEmpty(patterns) && !isEmpty(rootPatterns)) { + throw new Error('Only one of "patterns" or "root-patterns" can be provided'); + } + const filterPatterns = core.getMultilineInput("filter-patterns", { + required: true + }); + return { patterns, rootPatterns, filterPatterns }; +} +async function fsGlob(patterns) { + return globby(patterns, { expandDirectories: false, gitignore: true, markDirectories: true, onlyFiles: false }); +} +async function directMatch(patterns) { + const matches = await fsGlob(patterns); core.debug(JSON.stringify({ patterns, matches }, null, 2)); core.setOutput("matches", matches); +} +function hoist(rootPatterns, paths) { + const roots = []; + let remaining = [...paths]; + do { + roots.push(...(0, import_micromatch.default)(remaining, rootPatterns)); + remaining = import_micromatch.default.not(remaining, rootPatterns).map((x) => `${(0, import_path.dirname)(x)}/`); + } while (remaining.filter((x) => x !== "./").length > 0); + roots.push(...(0, import_micromatch.default)(remaining, rootPatterns)); + return Array.from(new Set(roots)); +} +async function hoistMatch(rootPatterns, filterPatterns) { + const fromFilters = await fsGlob(filterPatterns); + const matches = hoist(rootPatterns, fromFilters); + core.debug(JSON.stringify({ + rootPatterns, + filterPatterns, + fromFilters, + matches + }, null, 2)); + core.setOutput("matches", matches); +} +(async () => { + const { filterPatterns, patterns, rootPatterns } = getInputs(); + switch (true) { + case !isEmpty(patterns): + directMatch(patterns); + break; + case !isEmpty(rootPatterns): + hoistMatch(rootPatterns, filterPatterns); + break; + default: + throw new Error("Unreachable"); + } })(); /*! Bundled license information: diff --git a/main.mjs b/main.mjs index 346eb29..231cd15 100755 --- a/main.mjs +++ b/main.mjs @@ -1,15 +1,78 @@ #!/usr/bin/env node +import { dirname } from 'path'; import * as core from '@actions/core'; import { globby } from 'globby'; +import micromatch from 'micromatch'; -(async () => { - const patterns = core.getMultilineInput('patterns', { required: true }); - const matches = await globby(patterns, { +function isEmpty(xs) { + return xs.length === 0; +} + +function getInputs() { + const patterns = core.getMultilineInput('patterns'); + const rootPatterns = core.getMultilineInput('root-patterns'); + if (isEmpty(patterns) && isEmpty(rootPatterns)) { + throw new Error('Either "patterns" or "root-patterns" must be provided'); + } + if (!isEmpty(patterns) && !isEmpty(rootPatterns)) { + throw new Error('Only one of "patterns" or "root-patterns" can be provided'); + } + const filterPatterns = core.getMultilineInput('filter-patterns', { + required: true, + }); + return { patterns, rootPatterns, filterPatterns }; +} + +async function fsGlob(patterns) { + return globby(patterns, { expandDirectories: false, gitignore: true, markDirectories: true, onlyFiles: false, }); +} + +async function directMatch(patterns) { + const matches = await fsGlob(patterns); core.debug(JSON.stringify({ patterns, matches }, null, 2)); core.setOutput('matches', matches); +} + +function hoist(rootPatterns, paths) { + const roots = []; + let remaining = [...paths]; + do { + roots.push(...micromatch(remaining, rootPatterns)); + remaining = micromatch + .not(remaining, rootPatterns) + .map((x) => `${dirname(x)}/`); + } while (remaining.filter((x) => x !== './').length > 0); + roots.push(...micromatch(remaining, rootPatterns)); + return Array.from(new Set(roots)); +} + +async function hoistMatch(rootPatterns, filterPatterns) { + const fromFilters = await fsGlob(filterPatterns); + const matches = hoist(rootPatterns, fromFilters); + core.debug(JSON.stringify({ + rootPatterns, + filterPatterns, + fromFilters, + matches, + }, null, 2)); + core.setOutput('matches', matches); +} + +(async () => { + const { filterPatterns, patterns, rootPatterns } = getInputs(); + switch (true) { + case !isEmpty(patterns): + directMatch(patterns); + break; + case !isEmpty(rootPatterns): + hoistMatch(rootPatterns, filterPatterns); + break; + default: + throw new Error('Unreachable'); + } })(); diff --git a/package-lock.json b/package-lock.json index 7d6e67a..b4d4176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { "name": "actions-for-each", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { + "version": "0.1.0", "dependencies": { "@actions/core": "^1.10.1", - "globby": "^14.0.0" + "globby": "^14.0.0", + "micromatch": "^4.0.5" }, "devDependencies": { "esbuild": "^0.19.8", "eslint": "^8.54.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0" - }, - "version": "0.1.0" + } }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", @@ -2937,6 +2939,5 @@ "url": "https://github.com/sponsors/sindresorhus" } } - }, - "version": "0.1.0" + } } diff --git a/package.json b/package.json index 81cd27f..b570bde 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ }, "dependencies": { "@actions/core": "^1.10.1", - "globby": "^14.0.0" + "globby": "^14.0.0", + "micromatch": "^4.0.5" }, "devDependencies": { "esbuild": "^0.19.8",