Skip to content

Commit 179d0a4

Browse files
committed
feat(discovery)!: match exclude patterns with picomatch under an owned glob grammar
The exclude discovery filter compiled its own RegExp from each pattern, with "*" the only operator and every other character literal. Each pattern is now compiled once at the input boundary with picomatch (the posix entry, nocase, dot, posix classes, noglobstar, no fast paths, debug), and the boundary owns the grammar: name characters, at most one "/", "*", "?", "[abc]", "[!abc]" or "[^abc]", and one leading "!". Anything outside it is refused when the inputs are parsed, naming the reason and the fix, because picomatch reads braces, parentheses, quotes, backslashes, a leading "./", and "***" as regex syntax through paths no option closes and would otherwise compile a wrong or never-matching filter silently. DiscoveryFilters.exclude carries the compiled ExcludePattern (pattern, scope, regex) instead of strings, so a config built from argv still equals one built from the environment, and compileExcludePattern with ExcludePattern join the library surface. The e2e oracle's independent matcher learns "?" and a leading "!", and two scenarios pin the new forms end to end. BREAKING CHANGE: "?", "[", "]", and a leading "!" in an exclude pattern are glob operators now ("?" one character, "[abc]" a class, "!" negation), and every other non-name character ("{", "(", "|", "+", "\", quotes, spaces) is refused at parse time instead of being matched literally. A pattern that relied on one of those matching literally never matched a GitHub repository, so drop it. A pattern meant as a glob keeps working unchanged.
1 parent 2b5d2ff commit 179d0a4

18 files changed

Lines changed: 351 additions & 41 deletions

‎action.yml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,14 @@ inputs:
146146
default: ""
147147
exclude:
148148
description: >-
149-
Comma- or newline-separated wildcard patterns removing repositories from
150-
repos: "*" discovery.
149+
Comma- or newline-separated glob patterns removing repositories from
150+
repos: "*" discovery, compared case-insensitively against owner/name
151+
when the pattern has a "/" and against the name alone otherwise. A star
152+
matches any run of characters, "?" one character, "[abc]" one of a set,
153+
"[!abc]" or "[^abc]" one outside it, and one leading "!" negates the
154+
pattern. Beyond those operators a pattern holds only letters, digits,
155+
".", "-", and "_", with at most one "/", nothing empty around it, and no
156+
side that is just "." or "..".
151157
required: false
152158
default: ""
153159
topics:

‎bun.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/operate/multi-repo.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ Discovery takes six filter inputs that apply only to `repos: "*"`; setting any o
5757
- `archived` defaults to `skip`, because settings writes fail on archived repositories; `archived: only` is mostly useful with `mode: check`.
5858
- `forks` includes, excludes, or keeps only forks.
5959
- `topics` keeps repositories carrying at least one listed topic, so a single marker topic can opt repositories in. It is unrelated to the `topics` settings section.
60-
- `exclude` takes wildcard patterns where `*` matches anything: a pattern containing `/` is matched against the full `owner/name`, any other against the name alone, case-insensitively.
60+
- `exclude` takes glob patterns, matched case-insensitively: a pattern containing `/` against the full `owner/name`, any other against the name alone.
61+
- `*` matches any run of characters, `?` one character, `[abc]` one of a set, `[!abc]` or `[^abc]` one outside it.
62+
- One leading `!` negates the pattern, so `!svc-*` drops everything except the services.
63+
- Beyond those operators a pattern holds only name characters (letters, digits, `.`, `-`, `_`), at most one `/` with a non-empty glob on each side, and no side that is just `.` or `..`. Anything else (a brace, a parenthesis, a backslash, a quote, a second `/`, a class holding `*`) fails the run before discovery starts, as does a class like `[z-a]`. The matcher is [picomatch](https://github.com/micromatch/picomatch), confined to that grammar.
6164
- `affiliation` selects which relationships to the token's user qualify, passed to GitHub's `/user/repos` listing: `owner` (the default), `collaborator`, or `organization_member`. The list replaces the default, so widening discovery beyond owned repositories takes `owner,collaborator`.
6265

6366
## Fallback for repositories without a settings file

‎docs/reference/inputs.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ A list input (`sections`, `required-sections`, `repos`, `exclude`, `topics`, `af
3838
| `visibility` | <p>Keeps only repositories of this visibility in repos: "*" discovery: all (default), public, private, or internal.</p> | `false` | `""` |
3939
| `archived` | <p>Archived-repository policy for repos: "*" discovery: skip (default), include, or only.</p> | `false` | `""` |
4040
| `forks` | <p>Fork policy for repos: "*" discovery: include (default), exclude, or only.</p> | `false` | `""` |
41-
| `exclude` | <p>Comma- or newline-separated wildcard patterns removing repositories from repos: "*" discovery.</p> | `false` | `""` |
41+
| `exclude` | <p>Comma- or newline-separated glob patterns removing repositories from repos: "*" discovery, compared case-insensitively against owner/name when the pattern has a "/" and against the name alone otherwise. A star matches any run of characters, "?" one character, "[abc]" one of a set, "[!abc]" or "[^abc]" one outside it, and one leading "!" negates the pattern. Beyond those operators a pattern holds only letters, digits, ".", "-", and "_", with at most one "/", nothing empty around it, and no side that is just "." or "..".</p> | `false` | `""` |
4242
| `topics` | <p>Comma- or newline-separated topics, of which repos: "*" discovery keeps the repositories carrying at least one.</p> | `false` | `""` |
4343
| `affiliation` | <p>Comma- or newline-separated affiliations for repos: "*" discovery: owner (default), collaborator, or organization_member.</p> | `false` | `""` |
4444
<!-- action-docs-inputs source="action.yml" -->

‎docs/reference/library.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ const renderExitCode = runRender(
242242
| `discoverRepos` | function | The repositories a token can see, filtered |
243243
| `DiscoveryFilters` | type | The filters: `visibility`, `archived`, `forks`, `affiliation`, `topics`, `exclude` |
244244
| `DEFAULT_DISCOVERY_FILTERS` | const | The action's defaults |
245+
| `compileExcludePattern` | function | One `exclude` glob compiled once, or the problem naming why it is unusable |
246+
| `ExcludePattern` | type | A compiled `exclude` entry: the operator's text, the scope its slash chose, and the regex |
245247
| `parseReposInput` | function | The `repos` input form: slugs, or `"*"` for discovery |
246248
| `resolveCentralTargets` | function | The `<owner>/<name>.yml` files of a repos-dir as targets, with warnings for the files it skipped |
247249
| `dedupeTargets` | function | Merge the central and the remote targets, central first; a remote target whose slug a central one already names is dropped with a notice |

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"consola": "3.4.2",
7777
"neverthrow": "8.2.0",
7878
"picocolors": "1.1.1",
79+
"picomatch": "4.0.7",
7980
"yaml": "2.9.1",
8081
"zod": "4.6.5"
8182
},
@@ -89,6 +90,7 @@
8990
"@octokit/openapi-webhooks": "12.1.0",
9091
"@types/bun": "1.4.2",
9192
"@types/node": "26.6.4",
93+
"@types/picomatch": "4.0.3",
9294
"action-docs": "2.5.1",
9395
"ajv": "8.20.0",
9496
"ajv-formats": "3.0.1",

‎src/discovery/discover.ts‎

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*/
66

77
import type { components } from "@octokit/openapi-types";
8-
import { err, ok, ResultAsync } from "neverthrow";
8+
import { err, ok, type Result, ResultAsync } from "neverthrow";
9+
import picomatch from "picomatch/posix";
910
import type { GitHubClient } from "../github/api.js";
1011
import { classifyApiError } from "../github/api-error.js";
1112
import { paginate } from "../github/paginate.js";
@@ -64,7 +65,7 @@ export interface DiscoveryFilters {
6465
forks: (typeof FORKS_FILTERS)[number];
6566
affiliation: string[];
6667
topics: string[];
67-
exclude: string[];
68+
exclude: ExcludePattern[];
6869
}
6970

7071
export const DEFAULT_DISCOVERY_FILTERS: DiscoveryFilters = {
@@ -76,14 +77,99 @@ export const DEFAULT_DISCOVERY_FILTERS: DiscoveryFilters = {
7677
exclude: [],
7778
};
7879

79-
function compileExcludePattern(pattern: string): RegExp {
80-
const escaped = pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, ".*");
81-
return new RegExp(`^${escaped}$`, "i");
80+
/**
81+
* One `exclude` entry, compiled once at the input boundary. Plain data on purpose: a config built from argv must
82+
* equal one built from the environment, so the matcher is picomatch's RegExp, not a closure over it.
83+
*/
84+
export interface ExcludePattern {
85+
/** The operator's text, for the skip notice. */
86+
readonly pattern: string;
87+
/** "slug" when the pattern has a "/" and is compared against owner/name, "name" when against the name alone. */
88+
readonly scope: "slug" | "name";
89+
readonly regex: RegExp;
90+
}
91+
92+
/**
93+
* `nocase`: repository names compare case-insensitively on GitHub. `dot`: a leading "." is an ordinary character
94+
* in a repository name (".github"), not the hidden-file marker picomatch skips by default. `posix`: "[!ab]" is a
95+
* negated class, as in a shell, instead of a class holding "!". `noglobstar`: "**" is "*", since a slug has one
96+
* "/" and nothing to span; it also keeps picomatch's "***" parse from dropping the escape of a later dot.
97+
* `fastpaths: false`: the shortcut regexes picomatch mints for star-led patterns demand a character after a
98+
* literal dot, so "*.*" would miss "archive."; the full parser keeps "*" any run of characters. `debug`: an
99+
* invalid class like "[z-a]" throws instead of compiling to a never-matching regex. The posix entry point keeps
100+
* the matcher platform-independent, since a slug is a GitHub identifier, not a path.
101+
*/
102+
const EXCLUDE_GLOB_OPTIONS = {
103+
nocase: true,
104+
dot: true,
105+
posix: true,
106+
noglobstar: true,
107+
fastpaths: false,
108+
debug: true,
109+
} as const;
110+
111+
/** A character no owner or repository name holds and no glob operator here uses. */
112+
const EXCLUDE_STRAY = /[^\w.\-/*?[\]!^]/;
113+
114+
/** One side of the "/": name characters, "*", "?", and classes "[abc]" or "[!abc]" over name characters. */
115+
const EXCLUDE_SEGMENT = /^(?:[\w.\-*?]|\[[!^]?[\w.-]+\])+$/;
116+
117+
/**
118+
* The grammar is owned here, not left to picomatch, because picomatch accepts far more than a repository name can
119+
* hold ("(a)+" a quantifier, "\d" a digit class, "{az..bz}" a range, a stripped leading "./") and compiles several
120+
* such inputs to a wrong or never-matching regex with no option to refuse them. Each flaw names its fix.
121+
*/
122+
function excludePatternFlaw(pattern: string): string | null {
123+
const stray = EXCLUDE_STRAY.exec(pattern);
124+
if (stray) {
125+
return `it holds "${stray[0]}", which no owner or repository name contains`;
126+
}
127+
const body = pattern.startsWith("!") ? pattern.slice(1) : pattern;
128+
if (body === "") {
129+
return `"!" has nothing to negate`;
130+
}
131+
const segments = body.split("/");
132+
if (segments.length > 2 || segments.includes("")) {
133+
return `it takes at most one "/", with a non-empty glob on each side of it`;
134+
}
135+
if (segments.some((segment) => segment === "." || segment === "..")) {
136+
return `"." and ".." are never names`;
137+
}
138+
if (!segments.every((segment) => EXCLUDE_SEGMENT.test(segment))) {
139+
return (
140+
`"!" negates only at the start or as the first character of a class, "^" only as the first character ` +
141+
`of a class, and a class is "[", name characters, "]" on one side of the "/"`
142+
);
143+
}
144+
return null;
145+
}
146+
147+
/**
148+
* The scope split mirrors the repos-dir `<name>.yml` vs `<owner>/<name>.yml` pairing; picomatch's own basename
149+
* mode cannot express it (a slashed pattern stops matching the whole slug under it).
150+
*/
151+
export function compileExcludePattern(
152+
pattern: string,
153+
): Result<ExcludePattern, ProblemOf<"input-exclude-pattern-invalid">> {
154+
const flaw = excludePatternFlaw(pattern);
155+
if (flaw !== null) {
156+
return err({ code: "input-exclude-pattern-invalid", pattern, reason: flaw });
157+
}
158+
const scope = pattern.includes("/") ? "slug" : "name";
159+
try {
160+
return ok({ pattern, scope, regex: picomatch.makeRe(pattern, EXCLUDE_GLOB_OPTIONS) });
161+
} catch (error) {
162+
return err({
163+
code: "input-exclude-pattern-invalid",
164+
pattern,
165+
reason: error instanceof Error ? error.message : String(error),
166+
});
167+
}
82168
}
83169

84-
export function excludeMatches(pattern: string, slug: string): boolean {
85-
const candidate = pattern.includes("/") ? slug : (slug.split("/")[1] ?? slug);
86-
return compileExcludePattern(pattern).test(candidate);
170+
export function excludeMatches(exclude: ExcludePattern, slug: string): boolean {
171+
const candidate = exclude.scope === "slug" ? slug : slug.slice(slug.indexOf("/") + 1);
172+
return exclude.regex.test(candidate);
87173
}
88174

89175
export interface DiscoveryResult {
@@ -184,8 +270,8 @@ function applyFilters(
184270
return null;
185271
},
186272
(repo) => {
187-
const hit = filters.exclude.find((pattern) => excludeMatches(pattern, repo.full_name));
188-
return hit ? `exclude pattern "${hit}"` : null;
273+
const hit = filters.exclude.find((exclude) => excludeMatches(exclude, repo.full_name));
274+
return hit ? `exclude pattern "${hit.pattern}"` : null;
189275
},
190276
];
191277
const kept: DiscoveredRepoRef[] = [];

‎src/flows/inputs.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { err, ok, type Result, safeTry } from "neverthrow";
88
import {
99
AFFILIATIONS,
1010
ARCHIVED_FILTERS,
11+
compileExcludePattern,
1112
DEFAULT_DISCOVERY_FILTERS,
1213
type DiscoveryFilters,
14+
type ExcludePattern,
1315
FORKS_FILTERS,
1416
VISIBILITY_FILTERS,
1517
} from "../discovery/discover.js";
@@ -199,8 +201,12 @@ export const INPUT_DECLS = {
199201
},
200202
exclude: {
201203
description:
202-
'Comma- or newline-separated wildcard patterns removing repositories from repos: "*" ' +
203-
"discovery.",
204+
'Comma- or newline-separated glob patterns removing repositories from repos: "*" discovery, compared ' +
205+
'case-insensitively against owner/name when the pattern has a "/" and against the name alone ' +
206+
'otherwise. A star matches any run of characters, "?" one character, "[abc]" one of a set, "[!abc]" ' +
207+
'or "[^abc]" one outside it, and one leading "!" negates the pattern. Beyond those operators a pattern ' +
208+
'holds only letters, digits, ".", "-", and "_", with at most one "/", nothing empty around it, and no ' +
209+
'side that is just "." or "..".',
204210
default: "",
205211
list: true,
206212
},
@@ -495,13 +501,9 @@ function readDiscoveryFilters(
495501
allowed: AFFILIATIONS,
496502
});
497503
}
498-
const exclude = input.list("exclude");
499-
const unmatchable = exclude.find((pattern) => {
500-
const parts = pattern.split("/");
501-
return parts.length > 2 || (parts.length === 2 && (!parts[0] || !parts[1]));
502-
});
503-
if (unmatchable !== undefined) {
504-
return err({ code: "input-exclude-pattern-invalid", pattern: unmatchable });
504+
const exclude: ExcludePattern[] = [];
505+
for (const pattern of input.list("exclude")) {
506+
exclude.push(yield* compileExcludePattern(pattern));
505507
}
506508
const discoveryFilters: DiscoveryFilters = {
507509
visibility,

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66

77
export { resolveCentralTargets } from "./discovery/central.js";
88
export {
9+
compileExcludePattern,
910
DEFAULT_DISCOVERY_FILTERS,
1011
type DiscoveryFilters,
1112
discoverRepos,
13+
type ExcludePattern,
1214
} from "./discovery/discover.js";
1315
export { parseReposInput } from "./discovery/repos-input.js";
1416
export {

‎src/problem.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,11 @@ export type Problem =
131131
readonly entry: string;
132132
readonly allowed: readonly string[];
133133
}
134-
| { readonly code: "input-exclude-pattern-invalid"; readonly pattern: string }
134+
| {
135+
readonly code: "input-exclude-pattern-invalid";
136+
readonly pattern: string;
137+
readonly reason: string;
138+
}
135139
| { readonly code: "input-repository-with-multi" }
136140
| { readonly code: "input-settings-file-with-multi" }
137141
| {
@@ -509,12 +513,16 @@ export function describeProblem(problem: Problem): string {
509513
return 'the "private-report" input delivers reports only for redacted targets, but "private-repos" is "show", so nothing is redacted and no report would ever be sent. Set private-repos: redact, or set private-report: none';
510514
case "input-affiliation-unsupported":
511515
return `the "affiliation" input entry "${problem.entry}" is not a supported affiliation, so discovery cannot build the /user/repos query. Use a comma-separated list of ${quoteList(problem.allowed)}`;
512-
case "input-exclude-pattern-invalid":
516+
case "input-exclude-pattern-invalid": {
517+
const shown =
518+
problem.pattern.length > 60 ? `${problem.pattern.slice(0, 60)}...` : problem.pattern;
513519
return (
514-
`the "exclude" input pattern "${problem.pattern}" can never match an owner/name repository: a ` +
515-
`pattern takes at most one "/", with a non-empty glob on each side of it. Use ` +
516-
`"<name-glob>" or "<owner-glob>/<name-glob>", where "*" matches any characters`
520+
`the "exclude" input pattern "${shown}" is not a usable glob: ${problem.reason}. Write ` +
521+
`"<name-glob>" or "<owner-glob>/<name-glob>", where a glob is letters, digits, ".", "-", "_", "*", "?", ` +
522+
`and the classes "[abc]" or "[!abc]" over those characters, with one optional leading "!" negating ` +
523+
`the whole pattern`
517524
);
525+
}
518526
case "input-repository-with-multi":
519527
return 'the "repository" input cannot be combined with "repos" or "repos-dir"; multi-repo targets come from those inputs. Remove "repository", or remove the multi-repo inputs to stay in single-repo mode';
520528
case "input-settings-file-with-multi":

0 commit comments

Comments
 (0)