@gjsify/cli 0.38.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +189 -189
- package/lib/commands/affected-classify.d.ts +16 -0
- package/lib/commands/affected-classify.js +304 -0
- package/lib/commands/affected.js +2 -291
- package/lib/commands/flatpak/check.js +33 -42
- package/lib/commands/flatpak/deps.js +10 -19
- package/lib/commands/flatpak/release.js +7 -11
- package/lib/commands/flatpak/sync-flathub.js +17 -35
- package/lib/commands/gettext.js +7 -4
- package/lib/commands/showcase.js +9 -8
- package/lib/templates/install.mjs.tmpl +62 -1
- package/lib/utils/publish-headers.d.ts +1 -0
- package/lib/utils/publish-headers.js +6 -1
- package/lib/utils/spawn.d.ts +11 -1
- package/lib/utils/spawn.js +28 -4
- package/package.json +18 -18
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Workspace } from '@gjsify/workspace';
|
|
2
|
+
export interface ClassifyResult {
|
|
3
|
+
/** Cannot decide precisely — caller should run full suite. */
|
|
4
|
+
global: boolean;
|
|
5
|
+
/** Reason string surfaced in logs / debugging. */
|
|
6
|
+
reason: string;
|
|
7
|
+
/** Sorted workspace names. Includes every test-targeted workspace. */
|
|
8
|
+
workspaces: string[];
|
|
9
|
+
/** Should `tests/e2e/**` run? */
|
|
10
|
+
runE2E: boolean;
|
|
11
|
+
/** Should `@gjsify/integration-*` workspaces run? */
|
|
12
|
+
runIntegration: boolean;
|
|
13
|
+
/** Diff was empty AND no triggers fired → CI can skip the whole job. */
|
|
14
|
+
skipAll: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function classifyAndExpand(workspaces: readonly Workspace[], changedFiles: readonly string[]): ClassifyResult;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The `gjsify affected` CLASSIFIER — the pure decision, separated from the command.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS MODULE EXISTS (#1161). The spec used to reach `classifyAndExpand` by spawning
|
|
5
|
+
// the built CLI for all 40 cases, deliberately: the spawns kept the helper unexported.
|
|
6
|
+
// Measured, they cost 12.81 s against a 30 s per-`describe()` budget (11.68 s / 12.30 s on
|
|
7
|
+
// two other hosts, so it is the suite's property, not a host's). A runner 2.6x slower per
|
|
8
|
+
// spawn blows it, which CI macOS ordinarily is — so the failure was a BUDGET, never an
|
|
9
|
+
// assertion, on the legs that run on `main` rather than on PRs: a red `main` naming no test.
|
|
10
|
+
//
|
|
11
|
+
// The privacy survives without them: this module is NOT in the package's `exports` map, so
|
|
12
|
+
// the spec's relative import is the only way in. The seven cases that genuinely cross a
|
|
13
|
+
// process boundary — both emit branches, a real `sh` word-split — still spawn.
|
|
14
|
+
import { affectedClosure, buildDependencyGraph, buildReverseDependencyGraph, workspacesForChangedFiles, } from '@gjsify/workspace';
|
|
15
|
+
/** Patterns that force a full run. First-match wins; order is intentional. */
|
|
16
|
+
const GLOBAL_TRIGGERS = [
|
|
17
|
+
// The classifier itself + everything in its plumbing.
|
|
18
|
+
/^packages\/infra\/workspace\//,
|
|
19
|
+
/^packages\/infra\/cli\//,
|
|
20
|
+
/^packages\/infra\/rolldown-plugin-gjsify\//,
|
|
21
|
+
/^packages\/infra\/resolve-npm\//,
|
|
22
|
+
// The test framework every spec imports, as a *devDependency* — which the
|
|
23
|
+
// prod-deps-only closure below does not walk, so a change to it would yield a
|
|
24
|
+
// near-empty closure and silently skip every downstream test, while a bug in a
|
|
25
|
+
// matcher can break assertions anywhere.
|
|
26
|
+
/^packages\/gjs\/unit\//,
|
|
27
|
+
// The composite action every CI job sets itself up with: a change alters the
|
|
28
|
+
// environment of the whole matrix. Listed EXPLICITLY — it previously forced a
|
|
29
|
+
// full run only by falling through to the "unmatched files" path, which is luck
|
|
30
|
+
// rather than intent.
|
|
31
|
+
/^\.github\/actions\//,
|
|
32
|
+
// Cross-cutting dep + lockfile + root config.
|
|
33
|
+
/^gjsify-lock\.json$/,
|
|
34
|
+
/^package\.json$/,
|
|
35
|
+
/^tsconfig[^/]*\.json$/,
|
|
36
|
+
// The workflow file itself: a job-shape change is invisible until the workflow
|
|
37
|
+
// re-runs, so a path-filtered job cannot safely apply the new shape to an
|
|
38
|
+
// in-flight PR.
|
|
39
|
+
/^\.github\/workflows\/main\.yml$/,
|
|
40
|
+
/^scripts\/audit-runtimes\.mjs$/,
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
|
|
44
|
+
*
|
|
45
|
+
* Adding a regex here is a claim with two halves and BOTH must hold: `main.yml`
|
|
46
|
+
* must not read the path, and some other workflow must have it in its own
|
|
47
|
+
* `paths:` filter so a break still reds a PR. The test is never "is this file
|
|
48
|
+
* unimportant" — none of these are.
|
|
49
|
+
*/
|
|
50
|
+
const OTHER_WORKFLOW_INPUTS = [
|
|
51
|
+
// Each sibling workflow's own definition. `main.yml` is deliberately absent: it
|
|
52
|
+
// is a GLOBAL_TRIGGER instead.
|
|
53
|
+
/^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image|cancel-pr-runs)\.yml$/,
|
|
54
|
+
// `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and the
|
|
55
|
+
// classifier deciding which native packages a prebuild run rebuilds. It is in
|
|
56
|
+
// both of that workflow's `paths:` filters; `main.yml` builds no prebuild and
|
|
57
|
+
// runs no emulated leg.
|
|
58
|
+
/^\.github\/prebuild-toolchain\//,
|
|
59
|
+
// The REPO-SCOPED manifest-conformance rules (#847), gated by
|
|
60
|
+
// `audit-runtimes.yml`, which runs on EVERY pull_request with no `paths:` filter;
|
|
61
|
+
// `prebuilds.yml` also treats it as a shared input (#843).
|
|
62
|
+
//
|
|
63
|
+
// The distinction is real, not a technicality: `main.yml` DOES run
|
|
64
|
+
// manifest-conformance code, but reaches it through
|
|
65
|
+
// `packages/infra/manifest-conformance/` — the PACKAGE, a normal workspace that
|
|
66
|
+
// maps and seeds like any other. Only the `scripts/`-side repo-scoped half is
|
|
67
|
+
// ignored, and that is precisely the half `main.yml` does not load.
|
|
68
|
+
/^scripts\/manifest-conformance\//,
|
|
69
|
+
// The authored status data (ADR 0016) + its generator. Same two halves:
|
|
70
|
+
// `main.yml` never runs `status:generate` and never opens `status/`, while
|
|
71
|
+
// `audit-runtimes.yml` runs the `status-data` rule over it on every PR.
|
|
72
|
+
//
|
|
73
|
+
// Naming the directory makes the `.md` case intentional rather than incidental:
|
|
74
|
+
// `status/*.md` already fell into IGNORE via the generic `/\.md$/i`, so the gap
|
|
75
|
+
// showed up only on `status/status.json`, which matched nothing, landed in
|
|
76
|
+
// `unmatched`, and paid for a FULL matrix run over a file `main.yml` cannot read.
|
|
77
|
+
/^status\//,
|
|
78
|
+
/^scripts\/generate-status\.mjs$/,
|
|
79
|
+
];
|
|
80
|
+
/** Patterns that contribute no seed and don't force a full run. */
|
|
81
|
+
const IGNORE = [
|
|
82
|
+
/\.md$/i,
|
|
83
|
+
/^refs\//,
|
|
84
|
+
/^website\//,
|
|
85
|
+
/^docs\//,
|
|
86
|
+
...OTHER_WORKFLOW_INPUTS,
|
|
87
|
+
// Neither `@gjsify/node-gi` (the Node-native GI engine) nor `@gjsify/napi` (the
|
|
88
|
+
// N-API host over SpiderMonkey) is a gjsify workspace member: the GJS-first
|
|
89
|
+
// install/foreach tooling cannot build their node-gyp addons or Vala+C++/meson
|
|
90
|
+
// shim, so `main.yml` neither builds nor tests them and their own workflows are
|
|
91
|
+
// the source of truth. Without the carve-outs their files map to no workspace,
|
|
92
|
+
// land in `unmatched`, and force a full run on every node-gi/napi PR.
|
|
93
|
+
/^packages\/node-gi\//,
|
|
94
|
+
/^packages\/napi\//,
|
|
95
|
+
// Flatpak build/distribution tooling (SDK-extension manifest + metainfo). No
|
|
96
|
+
// package-test consumers; its own `tests/e2e/flatpak-sdk-extension` runs on
|
|
97
|
+
// `tests/e2e/**` and global triggers. Worth revisiting: ignoring `.githooks/`
|
|
98
|
+
// for the same reason is what let a change walk past the suite guarding it (see
|
|
99
|
+
// SCRIPT_COUPLINGS), and IGNORE wins over every other rule.
|
|
100
|
+
/^flatpak\//,
|
|
101
|
+
/^LICENSE/,
|
|
102
|
+
/^\.gitignore$/,
|
|
103
|
+
/^\.gjsify-[^/]*\.md$/,
|
|
104
|
+
/^STATUS\.md$/,
|
|
105
|
+
/^CHANGELOG\.md$/,
|
|
106
|
+
/^AGENTS\.md$/,
|
|
107
|
+
/^CLAUDE\.md$/,
|
|
108
|
+
/^README\.md$/,
|
|
109
|
+
];
|
|
110
|
+
/** Patterns that suggest a test-only change. */
|
|
111
|
+
const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
|
|
112
|
+
const SCRIPT_COUPLINGS = [
|
|
113
|
+
{
|
|
114
|
+
// `.githooks/pre-commit` is guarded by an e2e suite and reachable from no
|
|
115
|
+
// workspace at all — not source, not a dependency, its consumer is git. So a
|
|
116
|
+
// change to it selected NOTHING and the e2e tier stayed off (that turns on
|
|
117
|
+
// for a `tests/e2e/**` touch): #1095 edited the hook's command line, merged
|
|
118
|
+
// green, and turned `main` red on the push run. Same shape #1028 noted —
|
|
119
|
+
// whoever changes the GUARDED thing walks past the guard, because the guard's
|
|
120
|
+
// trigger names the TEST's path instead of the thing under test.
|
|
121
|
+
//
|
|
122
|
+
// No extra seeds: there is no workspace to rebuild. The tier is the point.
|
|
123
|
+
match: /^\.githooks\//,
|
|
124
|
+
seeds: [],
|
|
125
|
+
runE2E: true,
|
|
126
|
+
why: 'tests/e2e/git-hooks-cli-bundle-staleness drives .githooks/pre-commit',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
// `templates/*` ARE workspaces, so a template change already seeds its own
|
|
130
|
+
// `@gjsify/template-<name>` — not the consumer that matters. That consumer is
|
|
131
|
+
// `@gjsify/create-app`, unreachable twice over: it lives at
|
|
132
|
+
// `packages/infra/create-gjsify/`, so no directory-name search finds it, and
|
|
133
|
+
// it pulls the templates in through its own `scripts/process-template.mjs`
|
|
134
|
+
// build step (which resolves each `workspace:^` specifier and copies the
|
|
135
|
+
// result into `dist-templates/`), so there is no dependency edge either.
|
|
136
|
+
// `dist-templates/` is a build-output cache candidate, so once stale it
|
|
137
|
+
// stays stale.
|
|
138
|
+
match: /^templates\//,
|
|
139
|
+
seeds: ['@gjsify/create-app'],
|
|
140
|
+
// `tests/e2e/create-app` is the ONLY thing that would notice — it scaffolds a
|
|
141
|
+
// project out of `dist-templates/` and builds it. Without this a
|
|
142
|
+
// templates-only PR got neither the regenerated templates nor that suite;
|
|
143
|
+
// #853 passed only by also touching root `package.json`.
|
|
144
|
+
runE2E: true,
|
|
145
|
+
why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
export function classifyAndExpand(workspaces, changedFiles) {
|
|
149
|
+
const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
|
|
150
|
+
if (files.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
global: false,
|
|
153
|
+
reason: 'empty-diff',
|
|
154
|
+
workspaces: [],
|
|
155
|
+
runE2E: false,
|
|
156
|
+
runIntegration: false,
|
|
157
|
+
skipAll: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// Ignored files go FIRST, before the global-trigger check: ignore wins over
|
|
161
|
+
// global, so `packages/infra/cli/README.md` must not force a full run.
|
|
162
|
+
const remaining = [];
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
if (IGNORE.some((re) => re.test(f)))
|
|
165
|
+
continue;
|
|
166
|
+
remaining.push(f);
|
|
167
|
+
}
|
|
168
|
+
if (remaining.length === 0) {
|
|
169
|
+
return {
|
|
170
|
+
global: false,
|
|
171
|
+
reason: 'ignored-only',
|
|
172
|
+
workspaces: [],
|
|
173
|
+
runE2E: false,
|
|
174
|
+
runIntegration: false,
|
|
175
|
+
skipAll: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Global triggers short-circuit — checked on the non-ignored remainder.
|
|
179
|
+
for (const f of remaining) {
|
|
180
|
+
for (const re of GLOBAL_TRIGGERS) {
|
|
181
|
+
if (re.test(f)) {
|
|
182
|
+
return {
|
|
183
|
+
global: true,
|
|
184
|
+
reason: `global-trigger ${re.source} matched ${f}`,
|
|
185
|
+
workspaces: workspaces.map((w) => w.name),
|
|
186
|
+
runE2E: true,
|
|
187
|
+
runIntegration: true,
|
|
188
|
+
skipAll: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
|
|
194
|
+
// Seeds no graph edge can reach. BEFORE the `unmatched` bail-out, so the table
|
|
195
|
+
// also works for a coupled directory that is not itself a workspace — such a
|
|
196
|
+
// file would otherwise force a full run and never consult the declared seeds.
|
|
197
|
+
const knownNames = new Set(workspaces.map((w) => w.name));
|
|
198
|
+
const couplingSeeds = new Set();
|
|
199
|
+
const couplingAccounted = new Set();
|
|
200
|
+
let couplingRunE2E = false;
|
|
201
|
+
for (const c of SCRIPT_COUPLINGS) {
|
|
202
|
+
const hits = remaining.filter((f) => c.match.test(f));
|
|
203
|
+
if (hits.length === 0)
|
|
204
|
+
continue;
|
|
205
|
+
for (const h of hits)
|
|
206
|
+
couplingAccounted.add(h);
|
|
207
|
+
for (const s of c.seeds) {
|
|
208
|
+
// A declared seed that is not a workspace means the table drifted from the
|
|
209
|
+
// tree (renamed, moved, removed). Fail towards the full run and SAY SO: a
|
|
210
|
+
// missing rebuild here is invisible, so this must never degrade quietly
|
|
211
|
+
// back into the bug the table exists to prevent.
|
|
212
|
+
if (!knownNames.has(s)) {
|
|
213
|
+
return {
|
|
214
|
+
global: true,
|
|
215
|
+
reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
|
|
216
|
+
`SCRIPT_COUPLINGS in commands/affected.ts is stale`,
|
|
217
|
+
workspaces: workspaces.map((w) => w.name),
|
|
218
|
+
runE2E: true,
|
|
219
|
+
runIntegration: true,
|
|
220
|
+
skipAll: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
couplingSeeds.add(s);
|
|
224
|
+
}
|
|
225
|
+
if (c.runE2E)
|
|
226
|
+
couplingRunE2E = true;
|
|
227
|
+
}
|
|
228
|
+
// Unmatched-but-not-ignored (a new top-level dotfile, an uncarved `scripts/`
|
|
229
|
+
// file) falls back to the full run. A file a coupling claimed does not count.
|
|
230
|
+
const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
|
|
231
|
+
if (stillUnmatched.length > 0) {
|
|
232
|
+
return {
|
|
233
|
+
global: true,
|
|
234
|
+
reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
|
|
235
|
+
workspaces: workspaces.map((w) => w.name),
|
|
236
|
+
runE2E: true,
|
|
237
|
+
runIntegration: true,
|
|
238
|
+
skipAll: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
for (const s of couplingSeeds)
|
|
242
|
+
matched.add(s);
|
|
243
|
+
// Every remaining file is a spec / e2e / integration path under ONE workspace, so
|
|
244
|
+
// skip the closure: test code has no downstream consumers. A coupling disables
|
|
245
|
+
// this — its extra seeds exist precisely because a consumer DOES care.
|
|
246
|
+
const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
|
|
247
|
+
if (testOnly && matched.size === 1) {
|
|
248
|
+
const only = [...matched][0];
|
|
249
|
+
// An e2e- or integration-only change still needs its own job.
|
|
250
|
+
const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
|
|
251
|
+
const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
|
|
252
|
+
return {
|
|
253
|
+
global: false,
|
|
254
|
+
reason: `test-only (${remaining.length} file(s) in ${only})`,
|
|
255
|
+
workspaces: [only],
|
|
256
|
+
runE2E: touchedE2E,
|
|
257
|
+
runIntegration: touchedIntegration || isIntegrationWorkspace(only),
|
|
258
|
+
skipAll: false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// PRODUCTION dependencies only. The closure answers "whose tests must re-run
|
|
262
|
+
// because a package they depend on changed", which is a RUNTIME relationship;
|
|
263
|
+
// walking devDependency edges conflated it with the build/test toolchain, so ANY
|
|
264
|
+
// single-package change fanned out to ~210 of 221 workspaces and selective CI
|
|
265
|
+
// bought almost nothing. Prod-only collapses a typical seed to a handful (sqlite
|
|
266
|
+
// 210→4, fetch 210→34) while keeping every real runtime dependent.
|
|
267
|
+
//
|
|
268
|
+
// Safe against under-selection: no `*.spec.ts` imports a CROSS-PACKAGE sibling
|
|
269
|
+
// via a devDependency (the only such imports are self-imports, covered by the
|
|
270
|
+
// package being its own seed), `@gjsify/unit` is a GLOBAL_TRIGGER above, and
|
|
271
|
+
// every push-to-main plus the nightly cron still run the FULL suite.
|
|
272
|
+
const reverse = buildReverseDependencyGraph(workspaces, { includeDev: false });
|
|
273
|
+
const closure = affectedClosure(reverse, [...matched]);
|
|
274
|
+
// `includeDev: true`, deliberately broader than the reverse closure: integration
|
|
275
|
+
// packages declare the pillars they exercise as `dependencies`, but the wider
|
|
276
|
+
// walk tolerates a devDep-declared edge too. Over-running an integration suite is
|
|
277
|
+
// cheap; missing one is not.
|
|
278
|
+
const forward = buildDependencyGraph(workspaces, { includeDev: true });
|
|
279
|
+
let runIntegration = false;
|
|
280
|
+
for (const [from, deps] of forward.edges) {
|
|
281
|
+
if (!isIntegrationWorkspace(from))
|
|
282
|
+
continue;
|
|
283
|
+
for (const dep of deps) {
|
|
284
|
+
if (closure.has(dep)) {
|
|
285
|
+
closure.add(from);
|
|
286
|
+
runIntegration = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
|
|
292
|
+
return {
|
|
293
|
+
global: false,
|
|
294
|
+
reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
|
|
295
|
+
(couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
|
|
296
|
+
workspaces: [...closure].sort(),
|
|
297
|
+
runE2E,
|
|
298
|
+
runIntegration,
|
|
299
|
+
skipAll: false,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function isIntegrationWorkspace(name) {
|
|
303
|
+
return name.startsWith('@gjsify/integration-');
|
|
304
|
+
}
|
package/lib/commands/affected.js
CHANGED
|
@@ -56,7 +56,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
56
56
|
// every spec and fires only inside a GitHub Actions step.
|
|
57
57
|
import { appendFileSync } from 'node:fs';
|
|
58
58
|
import { readStdinLines } from '../utils/stdin.js';
|
|
59
|
-
import { discoverWorkspaces
|
|
59
|
+
import { discoverWorkspaces } from '@gjsify/workspace';
|
|
60
|
+
import { classifyAndExpand } from './affected-classify.js';
|
|
60
61
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
61
62
|
export const affectedCommand = {
|
|
62
63
|
command: 'affected',
|
|
@@ -95,296 +96,6 @@ export const affectedCommand = {
|
|
|
95
96
|
emit(args.format, result);
|
|
96
97
|
},
|
|
97
98
|
};
|
|
98
|
-
/** Patterns that force a full run. First-match wins; order is intentional. */
|
|
99
|
-
const GLOBAL_TRIGGERS = [
|
|
100
|
-
// The classifier itself + everything in its plumbing.
|
|
101
|
-
/^packages\/infra\/workspace\//,
|
|
102
|
-
/^packages\/infra\/cli\//,
|
|
103
|
-
/^packages\/infra\/rolldown-plugin-gjsify\//,
|
|
104
|
-
/^packages\/infra\/resolve-npm\//,
|
|
105
|
-
// The test framework every spec imports, as a *devDependency* — which the
|
|
106
|
-
// prod-deps-only closure below does not walk, so a change to it would yield a
|
|
107
|
-
// near-empty closure and silently skip every downstream test, while a bug in a
|
|
108
|
-
// matcher can break assertions anywhere.
|
|
109
|
-
/^packages\/gjs\/unit\//,
|
|
110
|
-
// The composite action every CI job sets itself up with: a change alters the
|
|
111
|
-
// environment of the whole matrix. Listed EXPLICITLY — it previously forced a
|
|
112
|
-
// full run only by falling through to the "unmatched files" path, which is luck
|
|
113
|
-
// rather than intent.
|
|
114
|
-
/^\.github\/actions\//,
|
|
115
|
-
// Cross-cutting dep + lockfile + root config.
|
|
116
|
-
/^gjsify-lock\.json$/,
|
|
117
|
-
/^package\.json$/,
|
|
118
|
-
/^tsconfig[^/]*\.json$/,
|
|
119
|
-
// The workflow file itself: a job-shape change is invisible until the workflow
|
|
120
|
-
// re-runs, so a path-filtered job cannot safely apply the new shape to an
|
|
121
|
-
// in-flight PR.
|
|
122
|
-
/^\.github\/workflows\/main\.yml$/,
|
|
123
|
-
/^scripts\/audit-runtimes\.mjs$/,
|
|
124
|
-
];
|
|
125
|
-
/**
|
|
126
|
-
* Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
|
|
127
|
-
*
|
|
128
|
-
* Adding a regex here is a claim with two halves and BOTH must hold: `main.yml`
|
|
129
|
-
* must not read the path, and some other workflow must have it in its own
|
|
130
|
-
* `paths:` filter so a break still reds a PR. The test is never "is this file
|
|
131
|
-
* unimportant" — none of these are.
|
|
132
|
-
*/
|
|
133
|
-
const OTHER_WORKFLOW_INPUTS = [
|
|
134
|
-
// Each sibling workflow's own definition. `main.yml` is deliberately absent: it
|
|
135
|
-
// is a GLOBAL_TRIGGER instead.
|
|
136
|
-
/^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image|cancel-pr-runs)\.yml$/,
|
|
137
|
-
// `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and the
|
|
138
|
-
// classifier deciding which native packages a prebuild run rebuilds. It is in
|
|
139
|
-
// both of that workflow's `paths:` filters; `main.yml` builds no prebuild and
|
|
140
|
-
// runs no emulated leg.
|
|
141
|
-
/^\.github\/prebuild-toolchain\//,
|
|
142
|
-
// The REPO-SCOPED manifest-conformance rules (#847), gated by
|
|
143
|
-
// `audit-runtimes.yml`, which runs on EVERY pull_request with no `paths:` filter;
|
|
144
|
-
// `prebuilds.yml` also treats it as a shared input (#843).
|
|
145
|
-
//
|
|
146
|
-
// The distinction is real, not a technicality: `main.yml` DOES run
|
|
147
|
-
// manifest-conformance code, but reaches it through
|
|
148
|
-
// `packages/infra/manifest-conformance/` — the PACKAGE, a normal workspace that
|
|
149
|
-
// maps and seeds like any other. Only the `scripts/`-side repo-scoped half is
|
|
150
|
-
// ignored, and that is precisely the half `main.yml` does not load.
|
|
151
|
-
/^scripts\/manifest-conformance\//,
|
|
152
|
-
// The authored status data (ADR 0016) + its generator. Same two halves:
|
|
153
|
-
// `main.yml` never runs `status:generate` and never opens `status/`, while
|
|
154
|
-
// `audit-runtimes.yml` runs the `status-data` rule over it on every PR.
|
|
155
|
-
//
|
|
156
|
-
// Naming the directory makes the `.md` case intentional rather than incidental:
|
|
157
|
-
// `status/*.md` already fell into IGNORE via the generic `/\.md$/i`, so the gap
|
|
158
|
-
// showed up only on `status/status.json`, which matched nothing, landed in
|
|
159
|
-
// `unmatched`, and paid for a FULL matrix run over a file `main.yml` cannot read.
|
|
160
|
-
/^status\//,
|
|
161
|
-
/^scripts\/generate-status\.mjs$/,
|
|
162
|
-
];
|
|
163
|
-
/** Patterns that contribute no seed and don't force a full run. */
|
|
164
|
-
const IGNORE = [
|
|
165
|
-
/\.md$/i,
|
|
166
|
-
/^refs\//,
|
|
167
|
-
/^website\//,
|
|
168
|
-
/^docs\//,
|
|
169
|
-
...OTHER_WORKFLOW_INPUTS,
|
|
170
|
-
// Neither `@gjsify/node-gi` (the Node-native GI engine) nor `@gjsify/napi` (the
|
|
171
|
-
// N-API host over SpiderMonkey) is a gjsify workspace member: the GJS-first
|
|
172
|
-
// install/foreach tooling cannot build their node-gyp addons or Vala+C++/meson
|
|
173
|
-
// shim, so `main.yml` neither builds nor tests them and their own workflows are
|
|
174
|
-
// the source of truth. Without the carve-outs their files map to no workspace,
|
|
175
|
-
// land in `unmatched`, and force a full run on every node-gi/napi PR.
|
|
176
|
-
/^packages\/node-gi\//,
|
|
177
|
-
/^packages\/napi\//,
|
|
178
|
-
// Flatpak build/distribution tooling (SDK-extension manifest + metainfo). No
|
|
179
|
-
// package-test consumers; its own `tests/e2e/flatpak-sdk-extension` runs on
|
|
180
|
-
// `tests/e2e/**` and global triggers. Worth revisiting: ignoring `.githooks/`
|
|
181
|
-
// for the same reason is what let a change walk past the suite guarding it (see
|
|
182
|
-
// SCRIPT_COUPLINGS), and IGNORE wins over every other rule.
|
|
183
|
-
/^flatpak\//,
|
|
184
|
-
/^LICENSE/,
|
|
185
|
-
/^\.gitignore$/,
|
|
186
|
-
/^\.gjsify-[^/]*\.md$/,
|
|
187
|
-
/^STATUS\.md$/,
|
|
188
|
-
/^CHANGELOG\.md$/,
|
|
189
|
-
/^AGENTS\.md$/,
|
|
190
|
-
/^CLAUDE\.md$/,
|
|
191
|
-
/^README\.md$/,
|
|
192
|
-
];
|
|
193
|
-
/** Patterns that suggest a test-only change. */
|
|
194
|
-
const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
|
|
195
|
-
const SCRIPT_COUPLINGS = [
|
|
196
|
-
{
|
|
197
|
-
// `.githooks/pre-commit` is guarded by an e2e suite and reachable from no
|
|
198
|
-
// workspace at all — not source, not a dependency, its consumer is git. So a
|
|
199
|
-
// change to it selected NOTHING and the e2e tier stayed off (that turns on
|
|
200
|
-
// for a `tests/e2e/**` touch): #1095 edited the hook's command line, merged
|
|
201
|
-
// green, and turned `main` red on the push run. Same shape #1028 noted —
|
|
202
|
-
// whoever changes the GUARDED thing walks past the guard, because the guard's
|
|
203
|
-
// trigger names the TEST's path instead of the thing under test.
|
|
204
|
-
//
|
|
205
|
-
// No extra seeds: there is no workspace to rebuild. The tier is the point.
|
|
206
|
-
match: /^\.githooks\//,
|
|
207
|
-
seeds: [],
|
|
208
|
-
runE2E: true,
|
|
209
|
-
why: 'tests/e2e/git-hooks-cli-bundle-staleness drives .githooks/pre-commit',
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
// `templates/*` ARE workspaces, so a template change already seeds its own
|
|
213
|
-
// `@gjsify/template-<name>` — not the consumer that matters. That consumer is
|
|
214
|
-
// `@gjsify/create-app`, unreachable twice over: it lives at
|
|
215
|
-
// `packages/infra/create-gjsify/`, so no directory-name search finds it, and
|
|
216
|
-
// it pulls the templates in through its own `scripts/process-template.mjs`
|
|
217
|
-
// build step (which resolves each `workspace:^` specifier and copies the
|
|
218
|
-
// result into `dist-templates/`), so there is no dependency edge either.
|
|
219
|
-
// `dist-templates/` is a build-output cache candidate, so once stale it
|
|
220
|
-
// stays stale.
|
|
221
|
-
match: /^templates\//,
|
|
222
|
-
seeds: ['@gjsify/create-app'],
|
|
223
|
-
// `tests/e2e/create-app` is the ONLY thing that would notice — it scaffolds a
|
|
224
|
-
// project out of `dist-templates/` and builds it. Without this a
|
|
225
|
-
// templates-only PR got neither the regenerated templates nor that suite;
|
|
226
|
-
// #853 passed only by also touching root `package.json`.
|
|
227
|
-
runE2E: true,
|
|
228
|
-
why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
|
|
229
|
-
},
|
|
230
|
-
];
|
|
231
|
-
function classifyAndExpand(workspaces, changedFiles) {
|
|
232
|
-
const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
|
|
233
|
-
if (files.length === 0) {
|
|
234
|
-
return {
|
|
235
|
-
global: false,
|
|
236
|
-
reason: 'empty-diff',
|
|
237
|
-
workspaces: [],
|
|
238
|
-
runE2E: false,
|
|
239
|
-
runIntegration: false,
|
|
240
|
-
skipAll: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
// Ignored files go FIRST, before the global-trigger check: ignore wins over
|
|
244
|
-
// global, so `packages/infra/cli/README.md` must not force a full run.
|
|
245
|
-
const remaining = [];
|
|
246
|
-
for (const f of files) {
|
|
247
|
-
if (IGNORE.some((re) => re.test(f)))
|
|
248
|
-
continue;
|
|
249
|
-
remaining.push(f);
|
|
250
|
-
}
|
|
251
|
-
if (remaining.length === 0) {
|
|
252
|
-
return {
|
|
253
|
-
global: false,
|
|
254
|
-
reason: 'ignored-only',
|
|
255
|
-
workspaces: [],
|
|
256
|
-
runE2E: false,
|
|
257
|
-
runIntegration: false,
|
|
258
|
-
skipAll: true,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
// Global triggers short-circuit — checked on the non-ignored remainder.
|
|
262
|
-
for (const f of remaining) {
|
|
263
|
-
for (const re of GLOBAL_TRIGGERS) {
|
|
264
|
-
if (re.test(f)) {
|
|
265
|
-
return {
|
|
266
|
-
global: true,
|
|
267
|
-
reason: `global-trigger ${re.source} matched ${f}`,
|
|
268
|
-
workspaces: workspaces.map((w) => w.name),
|
|
269
|
-
runE2E: true,
|
|
270
|
-
runIntegration: true,
|
|
271
|
-
skipAll: false,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
|
|
277
|
-
// Seeds no graph edge can reach. BEFORE the `unmatched` bail-out, so the table
|
|
278
|
-
// also works for a coupled directory that is not itself a workspace — such a
|
|
279
|
-
// file would otherwise force a full run and never consult the declared seeds.
|
|
280
|
-
const knownNames = new Set(workspaces.map((w) => w.name));
|
|
281
|
-
const couplingSeeds = new Set();
|
|
282
|
-
const couplingAccounted = new Set();
|
|
283
|
-
let couplingRunE2E = false;
|
|
284
|
-
for (const c of SCRIPT_COUPLINGS) {
|
|
285
|
-
const hits = remaining.filter((f) => c.match.test(f));
|
|
286
|
-
if (hits.length === 0)
|
|
287
|
-
continue;
|
|
288
|
-
for (const h of hits)
|
|
289
|
-
couplingAccounted.add(h);
|
|
290
|
-
for (const s of c.seeds) {
|
|
291
|
-
// A declared seed that is not a workspace means the table drifted from the
|
|
292
|
-
// tree (renamed, moved, removed). Fail towards the full run and SAY SO: a
|
|
293
|
-
// missing rebuild here is invisible, so this must never degrade quietly
|
|
294
|
-
// back into the bug the table exists to prevent.
|
|
295
|
-
if (!knownNames.has(s)) {
|
|
296
|
-
return {
|
|
297
|
-
global: true,
|
|
298
|
-
reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
|
|
299
|
-
`SCRIPT_COUPLINGS in commands/affected.ts is stale`,
|
|
300
|
-
workspaces: workspaces.map((w) => w.name),
|
|
301
|
-
runE2E: true,
|
|
302
|
-
runIntegration: true,
|
|
303
|
-
skipAll: false,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
couplingSeeds.add(s);
|
|
307
|
-
}
|
|
308
|
-
if (c.runE2E)
|
|
309
|
-
couplingRunE2E = true;
|
|
310
|
-
}
|
|
311
|
-
// Unmatched-but-not-ignored (a new top-level dotfile, an uncarved `scripts/`
|
|
312
|
-
// file) falls back to the full run. A file a coupling claimed does not count.
|
|
313
|
-
const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
|
|
314
|
-
if (stillUnmatched.length > 0) {
|
|
315
|
-
return {
|
|
316
|
-
global: true,
|
|
317
|
-
reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
|
|
318
|
-
workspaces: workspaces.map((w) => w.name),
|
|
319
|
-
runE2E: true,
|
|
320
|
-
runIntegration: true,
|
|
321
|
-
skipAll: false,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
for (const s of couplingSeeds)
|
|
325
|
-
matched.add(s);
|
|
326
|
-
// Every remaining file is a spec / e2e / integration path under ONE workspace, so
|
|
327
|
-
// skip the closure: test code has no downstream consumers. A coupling disables
|
|
328
|
-
// this — its extra seeds exist precisely because a consumer DOES care.
|
|
329
|
-
const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
|
|
330
|
-
if (testOnly && matched.size === 1) {
|
|
331
|
-
const only = [...matched][0];
|
|
332
|
-
// An e2e- or integration-only change still needs its own job.
|
|
333
|
-
const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
|
|
334
|
-
const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
|
|
335
|
-
return {
|
|
336
|
-
global: false,
|
|
337
|
-
reason: `test-only (${remaining.length} file(s) in ${only})`,
|
|
338
|
-
workspaces: [only],
|
|
339
|
-
runE2E: touchedE2E,
|
|
340
|
-
runIntegration: touchedIntegration || isIntegrationWorkspace(only),
|
|
341
|
-
skipAll: false,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
// PRODUCTION dependencies only. The closure answers "whose tests must re-run
|
|
345
|
-
// because a package they depend on changed", which is a RUNTIME relationship;
|
|
346
|
-
// walking devDependency edges conflated it with the build/test toolchain, so ANY
|
|
347
|
-
// single-package change fanned out to ~210 of 221 workspaces and selective CI
|
|
348
|
-
// bought almost nothing. Prod-only collapses a typical seed to a handful (sqlite
|
|
349
|
-
// 210→4, fetch 210→34) while keeping every real runtime dependent.
|
|
350
|
-
//
|
|
351
|
-
// Safe against under-selection: no `*.spec.ts` imports a CROSS-PACKAGE sibling
|
|
352
|
-
// via a devDependency (the only such imports are self-imports, covered by the
|
|
353
|
-
// package being its own seed), `@gjsify/unit` is a GLOBAL_TRIGGER above, and
|
|
354
|
-
// every push-to-main plus the nightly cron still run the FULL suite.
|
|
355
|
-
const reverse = buildReverseDependencyGraph(workspaces, { includeDev: false });
|
|
356
|
-
const closure = affectedClosure(reverse, [...matched]);
|
|
357
|
-
// `includeDev: true`, deliberately broader than the reverse closure: integration
|
|
358
|
-
// packages declare the pillars they exercise as `dependencies`, but the wider
|
|
359
|
-
// walk tolerates a devDep-declared edge too. Over-running an integration suite is
|
|
360
|
-
// cheap; missing one is not.
|
|
361
|
-
const forward = buildDependencyGraph(workspaces, { includeDev: true });
|
|
362
|
-
let runIntegration = false;
|
|
363
|
-
for (const [from, deps] of forward.edges) {
|
|
364
|
-
if (!isIntegrationWorkspace(from))
|
|
365
|
-
continue;
|
|
366
|
-
for (const dep of deps) {
|
|
367
|
-
if (closure.has(dep)) {
|
|
368
|
-
closure.add(from);
|
|
369
|
-
runIntegration = true;
|
|
370
|
-
break;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
|
|
375
|
-
return {
|
|
376
|
-
global: false,
|
|
377
|
-
reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
|
|
378
|
-
(couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
|
|
379
|
-
workspaces: [...closure].sort(),
|
|
380
|
-
runE2E,
|
|
381
|
-
runIntegration,
|
|
382
|
-
skipAll: false,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
function isIntegrationWorkspace(name) {
|
|
386
|
-
return name.startsWith('@gjsify/integration-');
|
|
387
|
-
}
|
|
388
99
|
function runGitDiff(cwd, base, head) {
|
|
389
100
|
// `base...head` lists changed paths on `head` relative to the MERGE-BASE, which
|
|
390
101
|
// matches what a GitHub PR diff shows and survives stacked PRs without picking up
|