@crossplatformai/dependency-graph 0.9.4 → 0.11.0-next.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/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/pr-preview.d.ts +5 -0
- package/dist/cli/pr-preview.d.ts.map +1 -0
- package/dist/cli/validate-workflows.d.ts +3 -0
- package/dist/cli/validate-workflows.d.ts.map +1 -0
- package/dist/graph/analysis.d.ts +6 -0
- package/dist/graph/analysis.d.ts.map +1 -0
- package/dist/graph/builder.d.ts +3 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/traversal.d.ts +5 -0
- package/dist/graph/traversal.d.ts.map +1 -0
- package/dist/graph/types.d.ts +47 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/index-cli.js +1172 -0
- package/dist/index-cli.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +791 -0
- package/dist/index.js.map +1 -0
- package/dist/types/clients.d.ts +15 -0
- package/dist/types/clients.d.ts.map +1 -0
- package/dist/workflow/discovery.d.ts +4 -0
- package/dist/workflow/discovery.d.ts.map +1 -0
- package/dist/workflow/expected-paths.d.ts +13 -0
- package/dist/workflow/expected-paths.d.ts.map +1 -0
- package/dist/workflow/impact.d.ts +5 -0
- package/dist/workflow/impact.d.ts.map +1 -0
- package/dist/workflow/parser.d.ts +3 -0
- package/dist/workflow/parser.d.ts.map +1 -0
- package/dist/workflow/policy.d.ts +3 -0
- package/dist/workflow/policy.d.ts.map +1 -0
- package/dist/workflow/types.d.ts +41 -0
- package/dist/workflow/types.d.ts.map +1 -0
- package/dist/workflow/validator.d.ts +3 -0
- package/dist/workflow/validator.d.ts.map +1 -0
- package/dist/workspace/discovery.d.ts +12 -0
- package/dist/workspace/discovery.d.ts.map +1 -0
- package/dist/workspace/file-mapping.d.ts +4 -0
- package/dist/workspace/file-mapping.d.ts.map +1 -0
- package/dist/workspace/package-map.d.ts +12 -0
- package/dist/workspace/package-map.d.ts.map +1 -0
- package/package.json +33 -28
- package/src/cli/pr-preview.ts +0 -388
- package/src/cli/validate-workflows.ts +0 -287
- package/src/graph/analysis.ts +0 -147
- package/src/graph/builder.ts +0 -52
- package/src/graph/traversal.ts +0 -132
- package/src/graph/types.ts +0 -50
- package/src/index.test.ts +0 -94
- package/src/index.ts +0 -51
- package/src/types/clients.ts +0 -19
- package/src/workflow/discovery.ts +0 -58
- package/src/workflow/expected-paths.ts +0 -112
- package/src/workflow/parser.test.ts +0 -54
- package/src/workflow/parser.ts +0 -48
- package/src/workflow/policy.ts +0 -13
- package/src/workflow/types.ts +0 -42
- package/src/workflow/validator.test.ts +0 -214
- package/src/workflow/validator.ts +0 -230
- package/src/workspace/discovery.ts +0 -94
- package/src/workspace/file-mapping.ts +0 -35
- package/src/workspace/package-map.test.ts +0 -95
- package/src/workspace/package-map.ts +0 -74
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/pr-preview.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { resolve as resolve2 } from "path";
|
|
6
|
+
import { glob as glob2 } from "glob";
|
|
7
|
+
import { parse as parseYaml3 } from "yaml";
|
|
8
|
+
|
|
9
|
+
// src/graph/builder.ts
|
|
10
|
+
function buildDependencyGraph(packages) {
|
|
11
|
+
const graph = {
|
|
12
|
+
packages: /* @__PURE__ */ new Map(),
|
|
13
|
+
dependsOn: /* @__PURE__ */ new Map(),
|
|
14
|
+
dependedBy: /* @__PURE__ */ new Map()
|
|
15
|
+
};
|
|
16
|
+
for (const pkg of packages) {
|
|
17
|
+
graph.packages.set(pkg.name, pkg);
|
|
18
|
+
graph.dependsOn.set(pkg.name, /* @__PURE__ */ new Set());
|
|
19
|
+
graph.dependedBy.set(pkg.name, /* @__PURE__ */ new Set());
|
|
20
|
+
}
|
|
21
|
+
for (const pkg of packages) {
|
|
22
|
+
const allDeps = {
|
|
23
|
+
...pkg.dependencies,
|
|
24
|
+
...pkg.devDependencies
|
|
25
|
+
};
|
|
26
|
+
for (const depName of Object.keys(allDeps)) {
|
|
27
|
+
const matchedPkg = findMatchingPackage(depName, packages);
|
|
28
|
+
if (matchedPkg) {
|
|
29
|
+
graph.dependsOn.get(pkg.name).add(matchedPkg.name);
|
|
30
|
+
graph.dependedBy.get(matchedPkg.name).add(pkg.name);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return graph;
|
|
35
|
+
}
|
|
36
|
+
function findMatchingPackage(depName, packages) {
|
|
37
|
+
let match = packages.find((p) => p.name === depName);
|
|
38
|
+
if (match) return match;
|
|
39
|
+
match = packages.find((p) => p.name === `@repo/${depName}`);
|
|
40
|
+
if (match) return match;
|
|
41
|
+
const nameWithoutRepo = depName.replace(/^@repo\//, "");
|
|
42
|
+
match = packages.find((p) => p.name === nameWithoutRepo);
|
|
43
|
+
if (match) return match;
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/graph/traversal.ts
|
|
48
|
+
function findAffectedPackages(startingPackages, graph, options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
direction = "upstream",
|
|
51
|
+
maxDepth = Infinity,
|
|
52
|
+
filter,
|
|
53
|
+
respectAffectsUpstream = false
|
|
54
|
+
} = options;
|
|
55
|
+
const affected = new Set(startingPackages);
|
|
56
|
+
const queue = Array.from(
|
|
57
|
+
startingPackages
|
|
58
|
+
).map((name) => ({ name, depth: 0 }));
|
|
59
|
+
const visited = /* @__PURE__ */ new Set();
|
|
60
|
+
while (queue.length > 0) {
|
|
61
|
+
const current = queue.shift();
|
|
62
|
+
if (visited.has(current.name)) continue;
|
|
63
|
+
visited.add(current.name);
|
|
64
|
+
if (current.depth >= maxDepth) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const pkg = graph.packages.get(current.name);
|
|
68
|
+
if (!pkg) continue;
|
|
69
|
+
if (respectAffectsUpstream && direction === "upstream") {
|
|
70
|
+
const release = pkg.packageJson.release;
|
|
71
|
+
if (release && release.affectsUpstream === false) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const nextPackages = /* @__PURE__ */ new Set();
|
|
76
|
+
if (direction === "upstream" || direction === "both") {
|
|
77
|
+
const upstream = graph.dependedBy.get(current.name) || /* @__PURE__ */ new Set();
|
|
78
|
+
upstream.forEach((p) => nextPackages.add(p));
|
|
79
|
+
}
|
|
80
|
+
if (direction === "downstream" || direction === "both") {
|
|
81
|
+
const downstream = graph.dependsOn.get(current.name) || /* @__PURE__ */ new Set();
|
|
82
|
+
downstream.forEach((p) => nextPackages.add(p));
|
|
83
|
+
}
|
|
84
|
+
for (const pkgName of nextPackages) {
|
|
85
|
+
const nextPkg = graph.packages.get(pkgName);
|
|
86
|
+
if (filter && nextPkg && !filter(nextPkg)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!affected.has(pkgName)) {
|
|
90
|
+
affected.add(pkgName);
|
|
91
|
+
queue.push({ name: pkgName, depth: current.depth + 1 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return affected;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/workspace/discovery.ts
|
|
99
|
+
import { join, resolve } from "path";
|
|
100
|
+
async function discoverWorkspaces(rootDir, config) {
|
|
101
|
+
const workspaceConfig = await loadWorkspaceConfig(rootDir, config);
|
|
102
|
+
const packages = [];
|
|
103
|
+
for (const pattern of workspaceConfig.packages) {
|
|
104
|
+
if (pattern.startsWith("!")) continue;
|
|
105
|
+
const pkgDirs = await findPackageDirectories(rootDir, pattern, config);
|
|
106
|
+
for (const pkgDir of pkgDirs) {
|
|
107
|
+
const pkgJsonPath = join(pkgDir, "package.json");
|
|
108
|
+
try {
|
|
109
|
+
const pkgJsonContent = await config.fs.readFile(
|
|
110
|
+
pkgJsonPath,
|
|
111
|
+
"utf-8"
|
|
112
|
+
);
|
|
113
|
+
const pkgJson = JSON.parse(pkgJsonContent);
|
|
114
|
+
packages.push({
|
|
115
|
+
name: pkgJson.name,
|
|
116
|
+
version: pkgJson.version || "0.0.0",
|
|
117
|
+
path: pkgDir,
|
|
118
|
+
packageJson: pkgJson,
|
|
119
|
+
dependencies: pkgJson.dependencies || {},
|
|
120
|
+
devDependencies: pkgJson.devDependencies || {}
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return packages;
|
|
128
|
+
}
|
|
129
|
+
async function loadWorkspaceConfig(rootDir, config) {
|
|
130
|
+
const workspaceFilePath = join(rootDir, "pnpm-workspace.yaml");
|
|
131
|
+
try {
|
|
132
|
+
const content = await config.fs.readFile(
|
|
133
|
+
workspaceFilePath,
|
|
134
|
+
"utf-8"
|
|
135
|
+
);
|
|
136
|
+
const parsed = config.yaml.parse(content);
|
|
137
|
+
return parsed;
|
|
138
|
+
} catch {
|
|
139
|
+
return { packages: [] };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function findPackageDirectories(rootDir, pattern, config) {
|
|
143
|
+
const matches = await config.glob.glob(pattern, {
|
|
144
|
+
cwd: rootDir,
|
|
145
|
+
absolute: false,
|
|
146
|
+
ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**"]
|
|
147
|
+
});
|
|
148
|
+
return matches.map((match) => resolve(rootDir, match));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/workspace/package-map.ts
|
|
152
|
+
import { existsSync } from "fs";
|
|
153
|
+
import { join as join2, relative, sep } from "path";
|
|
154
|
+
function normalizeRelativePath(path) {
|
|
155
|
+
return path.split(sep).join("/");
|
|
156
|
+
}
|
|
157
|
+
function resolveWorkflowPath(relativePath, rootDir) {
|
|
158
|
+
if (!relativePath.startsWith("../")) {
|
|
159
|
+
return relativePath;
|
|
160
|
+
}
|
|
161
|
+
const segments = relativePath.split("/");
|
|
162
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
163
|
+
const candidateSegments = segments.slice(index);
|
|
164
|
+
if (candidateSegments.length === 0 || candidateSegments[0] === "..") {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const candidatePath = candidateSegments.join("/");
|
|
168
|
+
if (existsSync(join2(rootDir, candidatePath))) {
|
|
169
|
+
return candidatePath;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function buildPackageMap(packages, rootDir) {
|
|
175
|
+
const packageMap = /* @__PURE__ */ new Map();
|
|
176
|
+
const workspaceRoots = /* @__PURE__ */ new Set();
|
|
177
|
+
for (const pkg of packages) {
|
|
178
|
+
const relativePath = normalizeRelativePath(relative(rootDir, pkg.path));
|
|
179
|
+
const workflowPath = resolveWorkflowPath(relativePath, rootDir);
|
|
180
|
+
packageMap.set(pkg.name, {
|
|
181
|
+
filesystemPath: relativePath,
|
|
182
|
+
workflowPath
|
|
183
|
+
});
|
|
184
|
+
if (!workflowPath) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const [workspaceRoot] = workflowPath.split("/");
|
|
188
|
+
if (workspaceRoot) {
|
|
189
|
+
workspaceRoots.add(workspaceRoot);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
packageMap,
|
|
194
|
+
workspaceRoots
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/workflow/policy.ts
|
|
199
|
+
var defaultWorkflowValidationPolicy = {
|
|
200
|
+
workflowFilePatterns: ["deploy-*.yml", "release-*.yml"],
|
|
201
|
+
allowedRootPaths: [
|
|
202
|
+
"package.json",
|
|
203
|
+
"pnpm-lock.yaml",
|
|
204
|
+
"pnpm-workspace.yaml",
|
|
205
|
+
"turbo.json"
|
|
206
|
+
],
|
|
207
|
+
includeDevDependenciesForRootPackage: true,
|
|
208
|
+
includeDevDependenciesTransitively: false
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/workflow/discovery.ts
|
|
212
|
+
import { existsSync as existsSync2, readdirSync } from "fs";
|
|
213
|
+
import { join as join3 } from "path";
|
|
214
|
+
function patternToRegExp(pattern) {
|
|
215
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
216
|
+
return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
|
|
217
|
+
}
|
|
218
|
+
function discoverWorkflowTargets(rootDir, packages, policy) {
|
|
219
|
+
const workflowsDir = join3(rootDir, ".github/workflows");
|
|
220
|
+
if (!existsSync2(workflowsDir)) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
const { packageMap } = buildPackageMap(packages, rootDir);
|
|
224
|
+
const appPackages = packages.filter(
|
|
225
|
+
(pkg) => packageMap.get(pkg.name)?.workflowPath?.startsWith("apps/")
|
|
226
|
+
);
|
|
227
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
228
|
+
for (const pkg of appPackages) {
|
|
229
|
+
const relativePath = packageMap.get(pkg.name)?.workflowPath;
|
|
230
|
+
if (!relativePath) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const slug = relativePath.split("/").at(-1);
|
|
234
|
+
if (slug) {
|
|
235
|
+
bySlug.set(slug, pkg.name);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const allowedPatterns = policy.workflowFilePatterns.map(patternToRegExp);
|
|
239
|
+
const files = readdirSync(workflowsDir).filter((file) => file.endsWith(".yml")).filter((file) => allowedPatterns.some((pattern) => pattern.test(file)));
|
|
240
|
+
return files.map((workflowFile) => {
|
|
241
|
+
const match = /^(?:deploy|release)-(.+)\.yml$/.exec(workflowFile);
|
|
242
|
+
const targetSlug = match?.[1] ?? workflowFile.replace(/\.yml$/, "");
|
|
243
|
+
return {
|
|
244
|
+
workflowFile,
|
|
245
|
+
workflowPath: `.github/workflows/${workflowFile}`,
|
|
246
|
+
targetSlug,
|
|
247
|
+
targetPackage: bySlug.get(targetSlug) ?? null
|
|
248
|
+
};
|
|
249
|
+
}).sort((a, b) => a.workflowFile.localeCompare(b.workflowFile));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/workflow/expected-paths.ts
|
|
253
|
+
function uniqueSorted(values) {
|
|
254
|
+
return Array.from(new Set(values)).sort();
|
|
255
|
+
}
|
|
256
|
+
function resolveWorkspaceDependency(dependencyName, packages) {
|
|
257
|
+
let match = packages.find((pkg) => pkg.name === dependencyName);
|
|
258
|
+
if (match) {
|
|
259
|
+
return match;
|
|
260
|
+
}
|
|
261
|
+
match = packages.find((pkg) => pkg.name === `@repo/${dependencyName}`);
|
|
262
|
+
if (match) {
|
|
263
|
+
return match;
|
|
264
|
+
}
|
|
265
|
+
const nameWithoutRepo = dependencyName.replace(/^@repo\//, "");
|
|
266
|
+
return packages.find((pkg) => pkg.name === nameWithoutRepo);
|
|
267
|
+
}
|
|
268
|
+
function collectWorkspaceDependencyNames(pkg, packages, visited, includeDevDependencies, includeDevDependenciesTransitively) {
|
|
269
|
+
const collected = /* @__PURE__ */ new Set();
|
|
270
|
+
const dependencyEntries = Object.entries(pkg.dependencies);
|
|
271
|
+
const devDependencyEntries = includeDevDependencies ? Object.entries(pkg.devDependencies) : [];
|
|
272
|
+
for (const [dependencyName] of [
|
|
273
|
+
...dependencyEntries,
|
|
274
|
+
...devDependencyEntries
|
|
275
|
+
]) {
|
|
276
|
+
const dependency = resolveWorkspaceDependency(dependencyName, packages);
|
|
277
|
+
if (!dependency || visited.has(dependency.name)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
visited.add(dependency.name);
|
|
281
|
+
collected.add(dependency.name);
|
|
282
|
+
const nestedDependencies = collectWorkspaceDependencyNames(
|
|
283
|
+
dependency,
|
|
284
|
+
packages,
|
|
285
|
+
visited,
|
|
286
|
+
includeDevDependenciesTransitively,
|
|
287
|
+
includeDevDependenciesTransitively
|
|
288
|
+
);
|
|
289
|
+
for (const nestedDependency of nestedDependencies) {
|
|
290
|
+
collected.add(nestedDependency);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return collected;
|
|
294
|
+
}
|
|
295
|
+
function getExpectedWorkflowPaths({
|
|
296
|
+
workflowTarget,
|
|
297
|
+
packages,
|
|
298
|
+
packageMap,
|
|
299
|
+
policy
|
|
300
|
+
}) {
|
|
301
|
+
const targetPackageName = workflowTarget.targetPackage;
|
|
302
|
+
if (!targetPackageName) {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
const targetPackage = packages.find((pkg) => pkg.name === targetPackageName);
|
|
306
|
+
if (!targetPackage) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const dependencyNames = collectWorkspaceDependencyNames(
|
|
310
|
+
targetPackage,
|
|
311
|
+
packages,
|
|
312
|
+
/* @__PURE__ */ new Set(),
|
|
313
|
+
policy.includeDevDependenciesForRootPackage,
|
|
314
|
+
policy.includeDevDependenciesTransitively
|
|
315
|
+
);
|
|
316
|
+
const expectedPaths = [];
|
|
317
|
+
const targetPackagePath = packageMap.get(targetPackageName)?.workflowPath;
|
|
318
|
+
if (targetPackagePath) {
|
|
319
|
+
expectedPaths.push(`${targetPackagePath}/**`);
|
|
320
|
+
}
|
|
321
|
+
for (const dependencyName of dependencyNames) {
|
|
322
|
+
const dependencyPath = packageMap.get(dependencyName)?.workflowPath;
|
|
323
|
+
if (dependencyPath) {
|
|
324
|
+
expectedPaths.push(`${dependencyPath}/**`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return uniqueSorted(expectedPaths);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/workflow/impact.ts
|
|
331
|
+
function uniqueSorted2(values) {
|
|
332
|
+
return Array.from(new Set(values)).sort();
|
|
333
|
+
}
|
|
334
|
+
function mergeWorkflowPolicy(policyOverrides) {
|
|
335
|
+
return {
|
|
336
|
+
...defaultWorkflowValidationPolicy,
|
|
337
|
+
...policyOverrides,
|
|
338
|
+
allowedRootPaths: uniqueSorted2([
|
|
339
|
+
...defaultWorkflowValidationPolicy.allowedRootPaths,
|
|
340
|
+
...policyOverrides?.allowedRootPaths ?? []
|
|
341
|
+
])
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function matchesWorkflowPathFilter(changedPath, workflowPathFilter) {
|
|
345
|
+
if (workflowPathFilter === changedPath) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
if (!workflowPathFilter.endsWith("/**")) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const workflowPrefix = workflowPathFilter.slice(0, -3);
|
|
352
|
+
return changedPath.startsWith(`${workflowPrefix}/`);
|
|
353
|
+
}
|
|
354
|
+
function getWorkflowImpacts(rootDir, packages, changedPaths, policyOverrides) {
|
|
355
|
+
const policy = mergeWorkflowPolicy(policyOverrides);
|
|
356
|
+
const workflowTargets = discoverWorkflowTargets(rootDir, packages, policy);
|
|
357
|
+
const { packageMap } = buildPackageMap(packages, rootDir);
|
|
358
|
+
return workflowTargets.map((workflowTarget) => {
|
|
359
|
+
const calculatedPaths = uniqueSorted2([
|
|
360
|
+
...getExpectedWorkflowPaths({
|
|
361
|
+
workflowTarget,
|
|
362
|
+
packages,
|
|
363
|
+
packageMap,
|
|
364
|
+
policy
|
|
365
|
+
}),
|
|
366
|
+
...policy.allowedRootPaths,
|
|
367
|
+
workflowTarget.workflowPath
|
|
368
|
+
]);
|
|
369
|
+
const matchedPaths = calculatedPaths.filter(
|
|
370
|
+
(calculatedPath) => changedPaths.some(
|
|
371
|
+
(changedPath) => matchesWorkflowPathFilter(changedPath, calculatedPath)
|
|
372
|
+
)
|
|
373
|
+
);
|
|
374
|
+
return {
|
|
375
|
+
...workflowTarget,
|
|
376
|
+
matchedPaths: uniqueSorted2(matchedPaths)
|
|
377
|
+
};
|
|
378
|
+
}).filter((workflowImpact) => workflowImpact.matchedPaths.length > 0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/workflow/validator.ts
|
|
382
|
+
import { existsSync as existsSync4 } from "fs";
|
|
383
|
+
import { readFile } from "fs/promises";
|
|
384
|
+
import { glob } from "glob";
|
|
385
|
+
import { parse as parseYaml2 } from "yaml";
|
|
386
|
+
|
|
387
|
+
// src/workflow/parser.ts
|
|
388
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
389
|
+
import { join as join4 } from "path";
|
|
390
|
+
import { parse as parseYaml } from "yaml";
|
|
391
|
+
function uniqueSorted3(values) {
|
|
392
|
+
return Array.from(new Set(values)).sort();
|
|
393
|
+
}
|
|
394
|
+
function parseWorkflowFile(workflowFile, rootDir) {
|
|
395
|
+
const workflowPath = join4(rootDir, ".github/workflows", workflowFile);
|
|
396
|
+
if (!existsSync3(workflowPath)) {
|
|
397
|
+
throw new Error(`Workflow file not found: ${workflowFile}`);
|
|
398
|
+
}
|
|
399
|
+
const content = readFileSync(workflowPath, "utf-8");
|
|
400
|
+
const workflow = parseYaml(content);
|
|
401
|
+
const pushPaths = workflow.on?.push?.paths ?? [];
|
|
402
|
+
const pullRequestPaths = workflow.on?.pull_request?.paths ?? [];
|
|
403
|
+
const parsedWorkflow = {
|
|
404
|
+
pushPaths: uniqueSorted3(pushPaths),
|
|
405
|
+
pullRequestPaths: uniqueSorted3(pullRequestPaths),
|
|
406
|
+
paths: uniqueSorted3([...pushPaths, ...pullRequestPaths])
|
|
407
|
+
};
|
|
408
|
+
if (workflow.name) {
|
|
409
|
+
parsedWorkflow.name = workflow.name;
|
|
410
|
+
}
|
|
411
|
+
return parsedWorkflow;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/workflow/validator.ts
|
|
415
|
+
function uniqueSorted4(values) {
|
|
416
|
+
return Array.from(new Set(values)).sort();
|
|
417
|
+
}
|
|
418
|
+
function buildBroadWildcards(workspaceRoots) {
|
|
419
|
+
const wildcards = /* @__PURE__ */ new Set();
|
|
420
|
+
for (const root of workspaceRoots) {
|
|
421
|
+
wildcards.add(`${root}/*`);
|
|
422
|
+
wildcards.add(`${root}/**`);
|
|
423
|
+
}
|
|
424
|
+
return wildcards;
|
|
425
|
+
}
|
|
426
|
+
function splitActualPaths(paths, workspaceRoots, allowedRootPaths, workflowPath) {
|
|
427
|
+
const workspacePaths = [];
|
|
428
|
+
const ignoredPaths = [];
|
|
429
|
+
for (const path of paths) {
|
|
430
|
+
if (path === workflowPath || allowedRootPaths.includes(path)) {
|
|
431
|
+
ignoredPaths.push(path);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const [rootSegment] = path.split("/");
|
|
435
|
+
if (rootSegment && workspaceRoots.has(rootSegment)) {
|
|
436
|
+
workspacePaths.push(path);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
ignoredPaths.push(path);
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
workspacePaths: uniqueSorted4(workspacePaths),
|
|
443
|
+
ignoredPaths: uniqueSorted4(ignoredPaths)
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function isCoveredByExpectedPath(actualPath, expectedPaths) {
|
|
447
|
+
return expectedPaths.some((expectedPath) => {
|
|
448
|
+
if (expectedPath === actualPath) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (!expectedPath.endsWith("/**")) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
const expectedPrefix = expectedPath.slice(0, -3);
|
|
455
|
+
return actualPath.startsWith(`${expectedPrefix}/`);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function validateWorkflowResult(workflow, targetPackage, expectedPaths, actualPaths, broadWildcards) {
|
|
459
|
+
const issues = [];
|
|
460
|
+
const missing = expectedPaths.filter((path) => !actualPaths.includes(path));
|
|
461
|
+
const unnecessary = actualPaths.filter(
|
|
462
|
+
(path) => !isCoveredByExpectedPath(path, expectedPaths)
|
|
463
|
+
);
|
|
464
|
+
for (const path of missing) {
|
|
465
|
+
issues.push({
|
|
466
|
+
kind: "missing",
|
|
467
|
+
path,
|
|
468
|
+
message: `Missing path '${path}'`
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
for (const path of unnecessary) {
|
|
472
|
+
issues.push({
|
|
473
|
+
kind: "unnecessary",
|
|
474
|
+
path,
|
|
475
|
+
message: `Unnecessary path '${path}'`
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
for (const path of actualPaths) {
|
|
479
|
+
if (broadWildcards.has(path)) {
|
|
480
|
+
issues.push({
|
|
481
|
+
kind: "broad-wildcard",
|
|
482
|
+
path,
|
|
483
|
+
message: `Uses broad wildcard '${path}' which triggers on all workspace changes under that root`
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
workflow,
|
|
489
|
+
targetPackage,
|
|
490
|
+
valid: issues.length === 0,
|
|
491
|
+
expectedPaths,
|
|
492
|
+
actualPaths,
|
|
493
|
+
missing,
|
|
494
|
+
unnecessary,
|
|
495
|
+
issues
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
async function validateWorkflows(rootDir, policyOverrides) {
|
|
499
|
+
const discoveredPackages = await discoverWorkspaces(rootDir, {
|
|
500
|
+
fs: {
|
|
501
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
502
|
+
exists: (path) => Promise.resolve(existsSync4(path))
|
|
503
|
+
},
|
|
504
|
+
glob: {
|
|
505
|
+
glob: (pattern, options) => glob(pattern, options)
|
|
506
|
+
},
|
|
507
|
+
yaml: {
|
|
508
|
+
parse: (content) => parseYaml2(content)
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
const policy = {
|
|
512
|
+
...defaultWorkflowValidationPolicy,
|
|
513
|
+
...policyOverrides,
|
|
514
|
+
allowedRootPaths: uniqueSorted4([
|
|
515
|
+
...defaultWorkflowValidationPolicy.allowedRootPaths,
|
|
516
|
+
...policyOverrides?.allowedRootPaths ?? []
|
|
517
|
+
])
|
|
518
|
+
};
|
|
519
|
+
const { packageMap, workspaceRoots } = buildPackageMap(
|
|
520
|
+
discoveredPackages,
|
|
521
|
+
rootDir
|
|
522
|
+
);
|
|
523
|
+
const workflowTargets = discoverWorkflowTargets(
|
|
524
|
+
rootDir,
|
|
525
|
+
discoveredPackages,
|
|
526
|
+
policy
|
|
527
|
+
);
|
|
528
|
+
const broadWildcards = buildBroadWildcards(workspaceRoots);
|
|
529
|
+
return workflowTargets.map((workflowTarget) => {
|
|
530
|
+
if (!workflowTarget.targetPackage) {
|
|
531
|
+
return {
|
|
532
|
+
workflow: workflowTarget.workflowFile,
|
|
533
|
+
targetPackage: workflowTarget.targetSlug,
|
|
534
|
+
valid: false,
|
|
535
|
+
expectedPaths: [],
|
|
536
|
+
actualPaths: [],
|
|
537
|
+
missing: [],
|
|
538
|
+
unnecessary: [],
|
|
539
|
+
issues: [
|
|
540
|
+
{
|
|
541
|
+
kind: "config-error",
|
|
542
|
+
message: `Could not resolve workflow target package for '${workflowTarget.workflowFile}'`
|
|
543
|
+
}
|
|
544
|
+
]
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const parsedWorkflow = parseWorkflowFile(
|
|
549
|
+
workflowTarget.workflowFile,
|
|
550
|
+
rootDir
|
|
551
|
+
);
|
|
552
|
+
if (parsedWorkflow.paths.length === 0) {
|
|
553
|
+
return {
|
|
554
|
+
workflow: workflowTarget.workflowFile,
|
|
555
|
+
targetPackage: workflowTarget.targetPackage,
|
|
556
|
+
valid: true,
|
|
557
|
+
expectedPaths: [],
|
|
558
|
+
actualPaths: [],
|
|
559
|
+
missing: [],
|
|
560
|
+
unnecessary: [],
|
|
561
|
+
issues: []
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const { workspacePaths: actualPaths } = splitActualPaths(
|
|
565
|
+
parsedWorkflow.paths,
|
|
566
|
+
workspaceRoots,
|
|
567
|
+
policy.allowedRootPaths,
|
|
568
|
+
workflowTarget.workflowPath
|
|
569
|
+
);
|
|
570
|
+
const expectedPaths = getExpectedWorkflowPaths({
|
|
571
|
+
workflowTarget,
|
|
572
|
+
packages: discoveredPackages,
|
|
573
|
+
packageMap,
|
|
574
|
+
policy
|
|
575
|
+
});
|
|
576
|
+
return validateWorkflowResult(
|
|
577
|
+
workflowTarget.workflowFile,
|
|
578
|
+
workflowTarget.targetPackage,
|
|
579
|
+
expectedPaths,
|
|
580
|
+
actualPaths,
|
|
581
|
+
broadWildcards
|
|
582
|
+
);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return {
|
|
585
|
+
workflow: workflowTarget.workflowFile,
|
|
586
|
+
targetPackage: workflowTarget.targetPackage,
|
|
587
|
+
valid: false,
|
|
588
|
+
expectedPaths: [],
|
|
589
|
+
actualPaths: [],
|
|
590
|
+
missing: [],
|
|
591
|
+
unnecessary: [],
|
|
592
|
+
issues: [
|
|
593
|
+
{
|
|
594
|
+
kind: "parse-error",
|
|
595
|
+
message: error instanceof Error ? error.message : String(error)
|
|
596
|
+
}
|
|
597
|
+
]
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/cli/pr-preview.ts
|
|
604
|
+
var PLATFORM_INDICATORS = [
|
|
605
|
+
{
|
|
606
|
+
platform: "cloudflare-workers",
|
|
607
|
+
indicators: [{ file: "wrangler.toml" }]
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
platform: "expo",
|
|
611
|
+
indicators: [{ file: "app.json" }, { file: "eas.json" }]
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
platform: "npm-package",
|
|
615
|
+
indicators: [{ field: "bin" }]
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
platform: "electron",
|
|
619
|
+
indicators: [
|
|
620
|
+
{ dependency: "electron" },
|
|
621
|
+
{ dependency: "electron-builder" }
|
|
622
|
+
]
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
platform: "next.js",
|
|
626
|
+
indicators: [
|
|
627
|
+
{ file: "next.config.js" },
|
|
628
|
+
{ file: "next.config.mjs" },
|
|
629
|
+
{ file: "next.config.ts" },
|
|
630
|
+
{ dependency: "next" }
|
|
631
|
+
]
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
platform: "node.js",
|
|
635
|
+
indicators: [
|
|
636
|
+
{ field: "start" }
|
|
637
|
+
// has start script
|
|
638
|
+
]
|
|
639
|
+
}
|
|
640
|
+
];
|
|
641
|
+
var DEFAULT_APP_DIRECTORIES = ["apps"];
|
|
642
|
+
function formatWorkflowPathDrift(validationResults) {
|
|
643
|
+
const invalidResults = validationResults.filter((result) => !result.valid);
|
|
644
|
+
if (invalidResults.length === 0) {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
const lines = [
|
|
648
|
+
"\u26A0\uFE0F Workflow path drift (advisory - does not affect impact above):"
|
|
649
|
+
];
|
|
650
|
+
for (const result of invalidResults) {
|
|
651
|
+
lines.push(` ${result.workflow} (${result.targetPackage}):`);
|
|
652
|
+
for (const issue of result.issues) {
|
|
653
|
+
if (issue.kind === "missing") {
|
|
654
|
+
lines.push(` + ${issue.message}`);
|
|
655
|
+
} else if (issue.kind === "unnecessary") {
|
|
656
|
+
lines.push(` - ${issue.message}`);
|
|
657
|
+
} else {
|
|
658
|
+
lines.push(` ! ${issue.message}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return lines;
|
|
663
|
+
}
|
|
664
|
+
function isDeployableApp(pkg) {
|
|
665
|
+
const isInAppDirectory = DEFAULT_APP_DIRECTORIES.some(
|
|
666
|
+
(dir) => pkg.path.includes(`/${dir}/`) || pkg.path.endsWith(`/${dir}`)
|
|
667
|
+
);
|
|
668
|
+
if (!isInAppDirectory) {
|
|
669
|
+
return { deployable: false };
|
|
670
|
+
}
|
|
671
|
+
for (const platformDetection of PLATFORM_INDICATORS) {
|
|
672
|
+
const hasIndicators = platformDetection.indicators.some((indicator) => {
|
|
673
|
+
if (indicator.file) {
|
|
674
|
+
try {
|
|
675
|
+
const filePath = resolve2(pkg.path, indicator.file);
|
|
676
|
+
readFileSync2(filePath, "utf-8");
|
|
677
|
+
return true;
|
|
678
|
+
} catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (indicator.field) {
|
|
683
|
+
const scripts = pkg.packageJson.scripts;
|
|
684
|
+
if (indicator.field === "start" && scripts?.start) {
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
const packageJsonField = pkg.packageJson[indicator.field];
|
|
688
|
+
if (indicator.field !== "start" && packageJsonField) {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (indicator.dependency) {
|
|
693
|
+
return !!(pkg.dependencies[indicator.dependency] || pkg.devDependencies[indicator.dependency]);
|
|
694
|
+
}
|
|
695
|
+
return false;
|
|
696
|
+
});
|
|
697
|
+
if (hasIndicators) {
|
|
698
|
+
return { deployable: true, platform: platformDetection.platform };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { deployable: false };
|
|
702
|
+
}
|
|
703
|
+
async function getChangedFiles() {
|
|
704
|
+
const { execSync } = await import("child_process");
|
|
705
|
+
try {
|
|
706
|
+
let baseBranch = "production";
|
|
707
|
+
try {
|
|
708
|
+
execSync("git rev-parse production", {
|
|
709
|
+
encoding: "utf-8",
|
|
710
|
+
stdio: "pipe"
|
|
711
|
+
});
|
|
712
|
+
} catch {
|
|
713
|
+
baseBranch = "origin/production";
|
|
714
|
+
}
|
|
715
|
+
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
|
|
716
|
+
encoding: "utf-8"
|
|
717
|
+
});
|
|
718
|
+
return output.trim().split("\n").filter((line) => line).map((line) => {
|
|
719
|
+
const [status, path] = line.split(" ");
|
|
720
|
+
return {
|
|
721
|
+
path: path ?? "",
|
|
722
|
+
status: status === "D" ? "deleted" : status === "A" ? "added" : "modified"
|
|
723
|
+
};
|
|
724
|
+
}).filter((file) => file.path !== "");
|
|
725
|
+
} catch {
|
|
726
|
+
console.error(
|
|
727
|
+
"Failed to get changed files. Ensure you are on a branch with commits compared to production."
|
|
728
|
+
);
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function runPrPreview() {
|
|
733
|
+
try {
|
|
734
|
+
const rootDir = resolve2(process.cwd());
|
|
735
|
+
const changedFiles = await getChangedFiles();
|
|
736
|
+
console.log(
|
|
737
|
+
`
|
|
738
|
+
\u{1F4E6} Release Preview - Changed Files: ${changedFiles.length}
|
|
739
|
+
`
|
|
740
|
+
);
|
|
741
|
+
changedFiles.forEach((file) => {
|
|
742
|
+
console.log(` ${file.status === "deleted" ? "\u274C" : "\u{1F4DD}"} ${file.path}`);
|
|
743
|
+
});
|
|
744
|
+
const packages = await discoverWorkspaces(rootDir, {
|
|
745
|
+
fs: {
|
|
746
|
+
readFile: (path) => {
|
|
747
|
+
try {
|
|
748
|
+
return Promise.resolve(readFileSync2(path, "utf-8"));
|
|
749
|
+
} catch {
|
|
750
|
+
return Promise.reject(new Error(`Failed to read file: ${path}`));
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
exists: (path) => {
|
|
754
|
+
try {
|
|
755
|
+
readFileSync2(path, "utf-8");
|
|
756
|
+
return Promise.resolve(true);
|
|
757
|
+
} catch {
|
|
758
|
+
return Promise.resolve(false);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
glob: {
|
|
763
|
+
glob: async (pattern, options) => glob2(pattern, options)
|
|
764
|
+
},
|
|
765
|
+
yaml: {
|
|
766
|
+
parse: (content) => parseYaml3(content)
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
const graph = buildDependencyGraph(packages);
|
|
770
|
+
const changedPackages = /* @__PURE__ */ new Set();
|
|
771
|
+
for (const file of changedFiles) {
|
|
772
|
+
if (file.status === "deleted") continue;
|
|
773
|
+
const pkg = packages.find((p) => {
|
|
774
|
+
const relativePkgPath = p.path.replace(rootDir + "/", "");
|
|
775
|
+
return file.path.startsWith(relativePkgPath);
|
|
776
|
+
});
|
|
777
|
+
if (pkg) {
|
|
778
|
+
changedPackages.add(pkg.name);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const workflowImpacts = getWorkflowImpacts(
|
|
782
|
+
rootDir,
|
|
783
|
+
packages,
|
|
784
|
+
changedFiles.map((file) => file.path)
|
|
785
|
+
);
|
|
786
|
+
const workflowDriftLines = formatWorkflowPathDrift(
|
|
787
|
+
await validateWorkflows(rootDir)
|
|
788
|
+
);
|
|
789
|
+
const workflowAffectedApps = new Set(
|
|
790
|
+
workflowImpacts.map(
|
|
791
|
+
(workflowImpact) => workflowImpact.targetPackage ?? workflowImpact.targetSlug
|
|
792
|
+
)
|
|
793
|
+
);
|
|
794
|
+
console.log(
|
|
795
|
+
`
|
|
796
|
+
\u{1F4E6} Changed packages: ${Array.from(changedPackages).join(", ") || "none"}
|
|
797
|
+
`
|
|
798
|
+
);
|
|
799
|
+
if (changedPackages.size === 0 && workflowAffectedApps.size === 0) {
|
|
800
|
+
console.log(
|
|
801
|
+
"\n\u2728 No packages or workflow filters changed - no deployments needed\n"
|
|
802
|
+
);
|
|
803
|
+
if (workflowDriftLines.length > 0) {
|
|
804
|
+
console.log(workflowDriftLines.join("\n"));
|
|
805
|
+
console.log("");
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const affected = findAffectedPackages(changedPackages, graph, {
|
|
810
|
+
direction: "upstream",
|
|
811
|
+
respectAffectsUpstream: true
|
|
812
|
+
});
|
|
813
|
+
const allDeployableApps = packages.map((pkg) => {
|
|
814
|
+
const detection = isDeployableApp(pkg);
|
|
815
|
+
return detection.deployable ? { name: pkg.name, pkg, platform: detection.platform } : null;
|
|
816
|
+
}).filter(
|
|
817
|
+
(item) => item !== null
|
|
818
|
+
);
|
|
819
|
+
const affectedApps = allDeployableApps.filter(
|
|
820
|
+
(app) => affected.has(app.name) || workflowAffectedApps.has(app.name)
|
|
821
|
+
);
|
|
822
|
+
const unaffectedApps = allDeployableApps.filter(
|
|
823
|
+
(app) => !affected.has(app.name) && !workflowAffectedApps.has(app.name)
|
|
824
|
+
);
|
|
825
|
+
const getAppCategory = (platform) => {
|
|
826
|
+
switch (platform) {
|
|
827
|
+
case "next.js":
|
|
828
|
+
case "cloudflare-workers":
|
|
829
|
+
case "node.js":
|
|
830
|
+
return "deploy";
|
|
831
|
+
case "expo":
|
|
832
|
+
case "electron":
|
|
833
|
+
case "npm-package":
|
|
834
|
+
return "release";
|
|
835
|
+
default:
|
|
836
|
+
return "deploy";
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
const getAppIcon = (platform) => {
|
|
840
|
+
switch (platform) {
|
|
841
|
+
case "next.js":
|
|
842
|
+
case "cloudflare-workers":
|
|
843
|
+
case "node.js":
|
|
844
|
+
return "\u{1F310}";
|
|
845
|
+
case "expo":
|
|
846
|
+
return "\u{1F4F1}";
|
|
847
|
+
case "electron":
|
|
848
|
+
return "\u{1F5A5}\uFE0F";
|
|
849
|
+
case "npm-package":
|
|
850
|
+
return "\u26A1";
|
|
851
|
+
default:
|
|
852
|
+
return "\u{1F310}";
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
const affectedDeploys = affectedApps.filter(
|
|
856
|
+
(app) => getAppCategory(app.platform) === "deploy"
|
|
857
|
+
);
|
|
858
|
+
const affectedReleases = affectedApps.filter(
|
|
859
|
+
(app) => getAppCategory(app.platform) === "release"
|
|
860
|
+
);
|
|
861
|
+
console.log(`
|
|
862
|
+
\u{1F680} PR Preview
|
|
863
|
+
`);
|
|
864
|
+
if (affectedApps.length === 0) {
|
|
865
|
+
console.log("No apps affected by changes.\n");
|
|
866
|
+
} else {
|
|
867
|
+
if (affectedDeploys.length > 0) {
|
|
868
|
+
console.log("\u{1F4CB} Apps that will be DEPLOYED:");
|
|
869
|
+
for (const item of affectedDeploys) {
|
|
870
|
+
const platformDisplay = item.platform === "generic" ? "" : ` (${item.platform})`;
|
|
871
|
+
const icon = getAppIcon(item.platform);
|
|
872
|
+
console.log(`${icon} ${item.name}${platformDisplay}`);
|
|
873
|
+
}
|
|
874
|
+
console.log("");
|
|
875
|
+
}
|
|
876
|
+
if (affectedReleases.length > 0) {
|
|
877
|
+
console.log("\u{1F4CB} Apps that will be RELEASED:");
|
|
878
|
+
for (const item of affectedReleases) {
|
|
879
|
+
const platformDisplay = item.platform === "generic" ? "" : ` (${item.platform})`;
|
|
880
|
+
const icon = getAppIcon(item.platform);
|
|
881
|
+
console.log(`${icon} ${item.name}${platformDisplay}`);
|
|
882
|
+
}
|
|
883
|
+
console.log("");
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (unaffectedApps.length > 0) {
|
|
887
|
+
console.log(`\u{1F4CB} Apps that will NOT be affected:`);
|
|
888
|
+
for (const item of unaffectedApps) {
|
|
889
|
+
const platformDisplay = item.platform === "generic" ? "" : ` (${item.platform})`;
|
|
890
|
+
console.log(`\u23ED\uFE0F ${item.name}${platformDisplay}`);
|
|
891
|
+
}
|
|
892
|
+
console.log("");
|
|
893
|
+
}
|
|
894
|
+
console.log(`Legend:`);
|
|
895
|
+
console.log(`\u{1F310} = Deploy (web-based, instant updates)`);
|
|
896
|
+
console.log(`\u{1F4F1} = Release (mobile app, user installs)`);
|
|
897
|
+
console.log(`\u{1F5A5}\uFE0F = Release (desktop app, user installs)`);
|
|
898
|
+
console.log(`\u26A1 = Release (CLI tool, user installs)`);
|
|
899
|
+
console.log(`\u23ED\uFE0F = Unaffected (no changes needed)`);
|
|
900
|
+
console.log(`\u{1F4DD} = File changed`);
|
|
901
|
+
console.log(`\u274C = File deleted
|
|
902
|
+
`);
|
|
903
|
+
if (workflowDriftLines.length > 0) {
|
|
904
|
+
console.log(workflowDriftLines.join("\n"));
|
|
905
|
+
console.log("");
|
|
906
|
+
}
|
|
907
|
+
} catch (error) {
|
|
908
|
+
console.error(
|
|
909
|
+
"Error:",
|
|
910
|
+
error instanceof Error ? error.message : String(error)
|
|
911
|
+
);
|
|
912
|
+
process.exit(1);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) {
|
|
916
|
+
void runPrPreview();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/cli/validate-workflows.ts
|
|
920
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
921
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
922
|
+
import { parse as parseYaml4 } from "yaml";
|
|
923
|
+
function discoverPnpmWorkflows(rootDir) {
|
|
924
|
+
const workflowsDir = join5(rootDir, ".github/workflows");
|
|
925
|
+
if (!existsSync5(workflowsDir)) {
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
const workflows = [];
|
|
929
|
+
const files = readdirSync2(workflowsDir).filter(
|
|
930
|
+
(file) => file.endsWith(".yml")
|
|
931
|
+
);
|
|
932
|
+
for (const file of files) {
|
|
933
|
+
const content = readFileSync3(join5(workflowsDir, file), "utf-8");
|
|
934
|
+
if (content.includes("pnpm/action-setup")) {
|
|
935
|
+
workflows.push(file);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return workflows;
|
|
939
|
+
}
|
|
940
|
+
function discoverPnpmDockerfiles(rootDir) {
|
|
941
|
+
const dockerfiles = [];
|
|
942
|
+
for (const entry of readdirSync2(rootDir)) {
|
|
943
|
+
if (!entry.startsWith("Dockerfile")) {
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
const content = readFileSync3(join5(rootDir, entry), "utf-8");
|
|
947
|
+
if (content.includes("pnpm@")) {
|
|
948
|
+
dockerfiles.push(entry);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const appsDir = join5(rootDir, "apps");
|
|
952
|
+
if (!existsSync5(appsDir)) {
|
|
953
|
+
return dockerfiles;
|
|
954
|
+
}
|
|
955
|
+
for (const entry of readdirSync2(appsDir, { withFileTypes: true })) {
|
|
956
|
+
if (!entry.isDirectory()) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const dockerfilePath = join5(appsDir, entry.name, "Dockerfile");
|
|
960
|
+
if (!existsSync5(dockerfilePath)) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const content = readFileSync3(dockerfilePath, "utf-8");
|
|
964
|
+
if (content.includes("pnpm@")) {
|
|
965
|
+
dockerfiles.push(`apps/${entry.name}/Dockerfile`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return dockerfiles;
|
|
969
|
+
}
|
|
970
|
+
function getExpectedPnpmVersion(rootDir) {
|
|
971
|
+
const packageJson = JSON.parse(
|
|
972
|
+
readFileSync3(join5(rootDir, "package.json"), "utf-8")
|
|
973
|
+
);
|
|
974
|
+
const packageManager = packageJson.packageManager;
|
|
975
|
+
if (!packageManager?.startsWith("pnpm@")) {
|
|
976
|
+
throw new Error("packageManager field must specify pnpm version");
|
|
977
|
+
}
|
|
978
|
+
return packageManager.replace("pnpm@", "");
|
|
979
|
+
}
|
|
980
|
+
function checkWorkflowPnpmVersion(workflowFile, rootDir) {
|
|
981
|
+
const workflowPath = join5(rootDir, ".github/workflows", workflowFile);
|
|
982
|
+
if (!existsSync5(workflowPath)) {
|
|
983
|
+
return { valid: true };
|
|
984
|
+
}
|
|
985
|
+
const content = readFileSync3(workflowPath, "utf-8");
|
|
986
|
+
const workflow = parseYaml4(content);
|
|
987
|
+
for (const job of Object.values(workflow.jobs ?? {})) {
|
|
988
|
+
for (const step of job.steps ?? []) {
|
|
989
|
+
if (!step.uses?.startsWith("pnpm/action-setup")) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const version = step.with?.version;
|
|
993
|
+
if (version !== void 0 && !/^\d+$/.test(String(version))) {
|
|
994
|
+
return {
|
|
995
|
+
valid: false,
|
|
996
|
+
issue: `Hardcoded pnpm version '${version}' - remove 'version' key to auto-detect from packageManager`
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return { valid: true };
|
|
1002
|
+
}
|
|
1003
|
+
function checkDockerfilePnpmVersion(dockerfile, expectedVersion, rootDir) {
|
|
1004
|
+
const dockerfilePath = join5(rootDir, dockerfile);
|
|
1005
|
+
if (!existsSync5(dockerfilePath)) {
|
|
1006
|
+
return { valid: true };
|
|
1007
|
+
}
|
|
1008
|
+
const content = readFileSync3(dockerfilePath, "utf-8");
|
|
1009
|
+
const matches = content.matchAll(/npm install -g pnpm@([\d.]+)/g);
|
|
1010
|
+
for (const match of matches) {
|
|
1011
|
+
if (match[1] !== expectedVersion) {
|
|
1012
|
+
return {
|
|
1013
|
+
valid: false,
|
|
1014
|
+
issue: `${dockerfile} uses pnpm@${match[1]} but package.json specifies pnpm@${expectedVersion}`
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return { valid: true };
|
|
1019
|
+
}
|
|
1020
|
+
function validatePnpmVersions(rootDir) {
|
|
1021
|
+
const result = {
|
|
1022
|
+
valid: true,
|
|
1023
|
+
workflowIssues: [],
|
|
1024
|
+
dockerfileIssues: []
|
|
1025
|
+
};
|
|
1026
|
+
for (const workflowFile of discoverPnpmWorkflows(rootDir)) {
|
|
1027
|
+
const check = checkWorkflowPnpmVersion(workflowFile, rootDir);
|
|
1028
|
+
if (!check.valid && check.issue) {
|
|
1029
|
+
result.valid = false;
|
|
1030
|
+
result.workflowIssues.push({
|
|
1031
|
+
workflow: workflowFile,
|
|
1032
|
+
issue: check.issue
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const expectedVersion = getExpectedPnpmVersion(rootDir);
|
|
1037
|
+
for (const dockerfile of discoverPnpmDockerfiles(rootDir)) {
|
|
1038
|
+
const check = checkDockerfilePnpmVersion(
|
|
1039
|
+
dockerfile,
|
|
1040
|
+
expectedVersion,
|
|
1041
|
+
rootDir
|
|
1042
|
+
);
|
|
1043
|
+
if (!check.valid && check.issue) {
|
|
1044
|
+
result.valid = false;
|
|
1045
|
+
result.dockerfileIssues.push({ dockerfile, issue: check.issue });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return result;
|
|
1049
|
+
}
|
|
1050
|
+
function printResult(result) {
|
|
1051
|
+
const icon = result.valid ? "\u2705" : "\u274C";
|
|
1052
|
+
console.log(`
|
|
1053
|
+
${icon} ${result.workflow} (${result.targetPackage})`);
|
|
1054
|
+
if (result.valid) {
|
|
1055
|
+
console.log(" All paths match dependencies");
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const extraIssues = result.issues.filter(
|
|
1059
|
+
(issue) => issue.kind !== "missing" && issue.kind !== "unnecessary"
|
|
1060
|
+
);
|
|
1061
|
+
if (extraIssues.length > 0) {
|
|
1062
|
+
console.log(" Issues:");
|
|
1063
|
+
for (const issue of extraIssues) {
|
|
1064
|
+
console.log(` \u26A0\uFE0F ${issue.message}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (result.missing.length > 0) {
|
|
1068
|
+
console.log(" Missing paths:");
|
|
1069
|
+
for (const path of result.missing) {
|
|
1070
|
+
console.log(` - ${path}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (result.unnecessary.length > 0) {
|
|
1074
|
+
console.log(" Unnecessary paths:");
|
|
1075
|
+
for (const path of result.unnecessary) {
|
|
1076
|
+
console.log(` - ${path}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function runValidateWorkflows() {
|
|
1081
|
+
const rootDir = resolve3(process.cwd());
|
|
1082
|
+
let hasErrors = false;
|
|
1083
|
+
console.log(
|
|
1084
|
+
"\u{1F50D} Validating GitHub Actions workflows against dependencies...\n"
|
|
1085
|
+
);
|
|
1086
|
+
const results = await validateWorkflows(rootDir);
|
|
1087
|
+
console.log(`Found ${results.length} workflow(s) to validate
|
|
1088
|
+
`);
|
|
1089
|
+
if (results.length === 0) {
|
|
1090
|
+
console.log("No deploy-*.yml or release-*.yml workflows found.\n");
|
|
1091
|
+
}
|
|
1092
|
+
for (const result of results) {
|
|
1093
|
+
printResult(result);
|
|
1094
|
+
}
|
|
1095
|
+
const validCount = results.filter((result) => result.valid).length;
|
|
1096
|
+
const invalidCount = results.length - validCount;
|
|
1097
|
+
console.log("\n" + "=".repeat(60));
|
|
1098
|
+
console.log(
|
|
1099
|
+
`
|
|
1100
|
+
\u{1F4CA} Path validation: ${validCount} valid, ${invalidCount} invalid
|
|
1101
|
+
`
|
|
1102
|
+
);
|
|
1103
|
+
if (invalidCount > 0) {
|
|
1104
|
+
console.log("\u274C Some workflows need updates to match dependencies");
|
|
1105
|
+
console.log("\nTo fix:");
|
|
1106
|
+
console.log("1. Update workflow path filters to match missing paths");
|
|
1107
|
+
console.log("2. Remove unnecessary paths");
|
|
1108
|
+
console.log("3. Replace broad workspace wildcards with specific paths\n");
|
|
1109
|
+
hasErrors = true;
|
|
1110
|
+
} else if (results.length > 0) {
|
|
1111
|
+
console.log("\u2705 All workflows match their dependencies!\n");
|
|
1112
|
+
}
|
|
1113
|
+
console.log("=".repeat(60));
|
|
1114
|
+
console.log("\n\u{1F50D} Validating pnpm version consistency...\n");
|
|
1115
|
+
const pnpmResult = validatePnpmVersions(rootDir);
|
|
1116
|
+
if (!pnpmResult.valid) {
|
|
1117
|
+
console.log("\u274C PNPM version issues found:\n");
|
|
1118
|
+
for (const { workflow, issue } of pnpmResult.workflowIssues) {
|
|
1119
|
+
console.log(` \u26A0\uFE0F ${workflow}: ${issue}`);
|
|
1120
|
+
}
|
|
1121
|
+
for (const { issue } of pnpmResult.dockerfileIssues) {
|
|
1122
|
+
console.log(` \u26A0\uFE0F ${issue}`);
|
|
1123
|
+
}
|
|
1124
|
+
console.log("\nTo fix:");
|
|
1125
|
+
console.log(
|
|
1126
|
+
"1. Remove hardcoded pnpm versions from workflows (let pnpm/action-setup auto-detect from packageManager)"
|
|
1127
|
+
);
|
|
1128
|
+
console.log(
|
|
1129
|
+
"2. Update Dockerfile pnpm versions to match package.json packageManager field\n"
|
|
1130
|
+
);
|
|
1131
|
+
hasErrors = true;
|
|
1132
|
+
} else {
|
|
1133
|
+
console.log("\u2705 PNPM versions are consistent!\n");
|
|
1134
|
+
}
|
|
1135
|
+
if (hasErrors) {
|
|
1136
|
+
process.exit(1);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) {
|
|
1140
|
+
void runValidateWorkflows();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/cli/index.ts
|
|
1144
|
+
function printHelp() {
|
|
1145
|
+
console.log(`dependency-graph <command>
|
|
1146
|
+
|
|
1147
|
+
Commands:
|
|
1148
|
+
pr-preview Show affected deploys and releases for the current branch
|
|
1149
|
+
validate-workflows Validate workflow path filters and pnpm version consistency`);
|
|
1150
|
+
}
|
|
1151
|
+
async function main() {
|
|
1152
|
+
const command = process.argv[2];
|
|
1153
|
+
switch (command) {
|
|
1154
|
+
case "pr-preview":
|
|
1155
|
+
await runPrPreview();
|
|
1156
|
+
return;
|
|
1157
|
+
case "validate-workflows":
|
|
1158
|
+
await runValidateWorkflows();
|
|
1159
|
+
return;
|
|
1160
|
+
case "--help":
|
|
1161
|
+
case "-h":
|
|
1162
|
+
case void 0:
|
|
1163
|
+
printHelp();
|
|
1164
|
+
return;
|
|
1165
|
+
default:
|
|
1166
|
+
console.error(`Unknown dependency-graph command: ${command}`);
|
|
1167
|
+
printHelp();
|
|
1168
|
+
process.exit(1);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
void main();
|
|
1172
|
+
//# sourceMappingURL=index-cli.js.map
|