microsoft/openvmm

Public

mirrored fromhttps://github.com/microsoft/openvmmAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
main

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

.github/scripts/dep-review.js

530lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4// Dependency review script — detects external (3rd-party) crate changes in
5// Cargo.lock and dependency containment policy violations. When issues are
6// found, requests review from the dependency reviewer team. When issues are
7// resolved, removes the review request.
8//
9// Can be run in two modes:
10// 1. GitHub Actions (via actions/github-script): pass github, context, core
11// 2. Local testing: `node dep-review.js --base <file> --pr <file> [--manifest <root-Cargo.toml> --policy <dep-policy.json>]`
12
13"use strict";
14
15// --- Pure functions (no GitHub API, easily testable) ---
16
17/**
18 * Parse a Cargo.lock file into a Set of "name\tversion\tsource" strings
19 * for external packages (those with a `source` field).
20 *
21 * Cargo.lock can contain multiple versions of the same crate from the
22 * same registry (e.g., windows-sys 0.48 and 0.52), so each unique
23 * (name, version, source) tuple is tracked independently.
24 */
25function parseExternalDeps(content) {
26 const deps = new Set();
27 const blocks = content.split(/\n(?=\[\[package\]\])/);
28 for (const block of blocks) {
29 if (!block.includes("[[package]]")) continue;
30 const name = block.match(/^name\s*=\s*"(.+?)"/m)?.[1];
31 const version = block.match(/^version\s*=\s*"(.+?)"/m)?.[1];
32 const source = block.match(/^source\s*=\s*"(.+?)"/m)?.[1];
33 if (!name || !version) continue;
34 if (source) {
35 deps.add(`${name}\t${version}\t${source}`);
36 }
37 }
38 return deps;
39}
40
41/** Format a source string for human-readable display. */
42function fmtSource(source) {
43 if (source.startsWith("registry+")) return "";
44 if (source.startsWith("git+")) {
45 const url = source.replace(/^git\+/, "").replace(/#.*$/, "");
46 return ` (${url})`;
47 }
48 return ` (${source})`;
49}
50
51/**
52 * Diff two parsed dependency sets.
53 * Returns { added } array — entries present in prDeps but not baseDeps.
54 * Removals are not tracked because dropping a dependency doesn't require review.
55 */
56function diffDeps(baseDeps, prDeps) {
57 const added = [];
58
59 for (const key of prDeps) {
60 if (!baseDeps.has(key)) {
61 const [name, version, source] = key.split("\t");
62 added.push({ name, version, source });
63 }
64 }
65
66 return { added };
67}
68
69/** Build a markdown summary of dependency changes. */
70function buildSummary(diff) {
71 const { added } = diff;
72 let summary = "### External dependency changes detected\n\n";
73
74 if (added.length > 0) {
75 summary += "**New external crate versions:**\n";
76 for (const d of added) {
77 summary += `- \`${d.name}\` ${d.version}${fmtSource(d.source)}\n`;
78 }
79 summary += "\n";
80 }
81
82 return summary;
83}
84
85// --- Dependency graph policy checks (reason 2a/2b) ---
86
87/**
88 * Parse root Cargo.toml [workspace.dependencies] to build a map of
89 * crate name → directory path (only for path-based / internal deps).
90 *
91 * NOTE: This only covers crates listed in [workspace.dependencies], not all
92 * workspace members. Crates that are workspace members but not listed there
93 * (e.g., fuzz targets) won't be covered by containment checks. In practice
94 * this is fine — such crates are leaf crates unlikely to introduce
95 * cross-boundary deps, and resolving all members would require fetching
96 * individual Cargo.toml files for each member.
97 */
98function parseCratePathMap(cargoTomlContent) {
99 const map = new Map();
100 // Match inline table entries with a path key:
101 // crate_name = { path = "some/dir", ... }
102 // Handles optional extra keys before/after `path`.
103 const regex = /^([\w][\w-]*)\s*=\s*\{[^}]*path\s*=\s*"([^"]+)"[^}]*\}/gm;
104 let match;
105 while ((match = regex.exec(cargoTomlContent)) !== null) {
106 map.set(match[1], match[2]);
107 }
108 return map;
109}
110
111/**
112 * Parse Cargo.lock to extract the internal dependency graph.
113 * Returns { graph: Map<name, string[]>, internalCrates: Set<name> }.
114 * Only internal crates (those without a `source` field) are included.
115 */
116function parseInternalDepGraph(lockContent) {
117 const graph = new Map();
118 const internalCrates = new Set();
119
120 const blocks = lockContent.split(/\n(?=\[\[package\]\])/);
121 for (const block of blocks) {
122 if (!block.includes("[[package]]")) continue;
123 const name = block.match(/^name\s*=\s*"(.+?)"/m)?.[1];
124 const source = block.match(/^source\s*=\s*"(.+?)"/m)?.[1];
125 if (!name) continue;
126 if (source) continue; // external crate
127
128 internalCrates.add(name);
129
130 const depsMatch = block.match(/^dependencies\s*=\s*\[([\s\S]*?)\]/m);
131 if (depsMatch) {
132 const deps = [];
133 for (const entry of depsMatch[1].matchAll(/"([^"]+)"/g)) {
134 // Entries are "name" or "name version"
135 deps.push(entry[1].split(" ")[0]);
136 }
137 graph.set(name, deps);
138 } else {
139 graph.set(name, []);
140 }
141 }
142
143 return { graph, internalCrates };
144}
145
146/**
147 * Check containment policies against the internal dep graph.
148 * A containment rule says: crates under `prefix` may only have internal
149 * dependencies on other crates under `prefix`.
150 *
151 * Returns an array of violation objects.
152 */
153function checkContainment(graph, internalCrates, pathMap, policy) {
154 const violations = [];
155
156 for (const rule of policy.containment || []) {
157 const prefix = rule.prefix;
158
159 for (const [name, path] of pathMap) {
160 if (!path.startsWith(prefix)) continue;
161 if (!graph.has(name)) continue;
162
163 for (const dep of graph.get(name)) {
164 // Only check edges to other internal crates
165 if (!internalCrates.has(dep)) continue;
166 const depPath = pathMap.get(dep);
167 if (!depPath) continue; // can't resolve — skip
168 if (depPath.startsWith(prefix)) continue; // same prefix — ok
169
170 violations.push({
171 crate: name,
172 cratePath: path,
173 dep,
174 depPath,
175 rule: rule.description || `${prefix} containment`,
176 });
177 }
178 }
179 }
180
181 return violations;
182}
183
184/**
185 * Diff containment violations between base and PR.
186 * Only returns *new* violations (present in PR but not in base),
187 * so existing violations are grandfathered.
188 */
189function diffContainmentViolations(baseViolations, prViolations) {
190 const baseSet = new Set(
191 baseViolations.map((v) => `${v.crate}\t${v.dep}`)
192 );
193 return prViolations.filter((v) => !baseSet.has(`${v.crate}\t${v.dep}`));
194}
195
196/** Build a markdown summary of containment violations. */
197function buildPolicySummary(violations) {
198 if (violations.length === 0) return "";
199
200 let summary = "### Dependency containment violations\n\n";
201 summary +=
202 "The following new internal dependency edges violate containment policy:\n\n";
203 for (const v of violations) {
204 summary +=
205 `- \`${v.crate}\` (${v.cratePath}) → \`${v.dep}\` (${v.depPath}) — ${v.rule}\n`;
206 }
207 summary += "\n";
208 return summary;
209}
210
211// --- GitHub Actions entrypoint ---
212
213const DEP_REVIEW_TEAM = "openvmm-dependency-reviewers";
214
215/**
216 * Main function called from actions/github-script.
217 * Requests or removes review from the dependency reviewer team based on
218 * whether external dep changes or policy violations are detected.
219 *
220 * @param {object} github - Octokit REST client
221 * @param {object} context - GitHub Actions context
222 * @param {object} core - GitHub Actions core (for setFailed)
223 */
224async function run(github, context, core) {
225 const prNumber = context.payload.pull_request.number;
226 const baseSha = context.payload.pull_request.base.sha;
227
228 // Step 1: Check if Cargo.lock was modified
229 let allFiles = [];
230 let page = 1;
231 const MAX_PAGES = 30;
232 while (page <= MAX_PAGES) {
233 const { data: files } = await github.rest.pulls.listFiles({
234 owner: context.repo.owner,
235 repo: context.repo.repo,
236 pull_number: prNumber,
237 per_page: 100,
238 page,
239 });
240 if (files.length === 0) break;
241 allFiles = allFiles.concat(files);
242 if (files.length < 100) break;
243 page++;
244 }
245
246 if (page > MAX_PAGES) {
247 core.warning(
248 `PR has more than ${MAX_PAGES * 100} changed files — ` +
249 `Cargo.lock detection may be incomplete. Assuming it changed.`
250 );
251 }
252
253 const lockfileChanged = page > MAX_PAGES || allFiles.some((f) => f.filename === "Cargo.lock");
254 if (!lockfileChanged) {
255 console.log("Cargo.lock not modified — nothing to review.");
256
257 // Remove any stale review request from a previous push that did touch Cargo.lock
258 try {
259 await github.rest.pulls.removeRequestedReviewers({
260 owner: context.repo.owner,
261 repo: context.repo.repo,
262 pull_number: prNumber,
263 team_reviewers: [DEP_REVIEW_TEAM],
264 });
265 console.log(`Removed stale review request from @microsoft/${DEP_REVIEW_TEAM}`);
266 } catch (e) {
267 if (e.status !== 422) {
268 console.log(`Note: failed to remove review request (${e.status}): ${e.message}`);
269 }
270 }
271 return;
272 }
273
274 // Step 2: Fetch base and PR Cargo.lock via API
275 // Always fetch from the base repo — for forked PRs, use the merge ref
276 // (refs/pull/N/head) so we don't need access to the fork itself.
277 async function fetchFile(path, ref) {
278 const { data } = await github.rest.repos.getContent({
279 owner: context.repo.owner,
280 repo: context.repo.repo,
281 path,
282 ref,
283 });
284 if (data.type !== "file") {
285 throw new Error(`${path}: not a regular file`);
286 }
287 return Buffer.from(data.content, "base64").toString("utf8");
288 }
289
290 const prRef = `refs/pull/${prNumber}/head`;
291
292 const baseContent = await fetchFile("Cargo.lock", baseSha);
293 const prContent = await fetchFile("Cargo.lock", prRef);
294
295 // Step 3: Diff external deps
296 const baseDeps = parseExternalDeps(baseContent);
297 const prDeps = parseExternalDeps(prContent);
298 const diff = diffDeps(baseDeps, prDeps);
299 const hasExternalChanges = diff.added.length > 0;
300
301 // Step 4: Check containment policies
302 const fs = require("fs");
303 const path = require("path");
304 const policyPath = path.join(__dirname, "..", "dep-policy.json");
305 let newViolations = [];
306 if (fs.existsSync(policyPath)) {
307 const policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
308
309 const baseManifest = await fetchFile("Cargo.toml", baseSha);
310 const prManifest = await fetchFile("Cargo.toml", prRef);
311
312 const basePathMap = parseCratePathMap(baseManifest);
313 const prPathMap = parseCratePathMap(prManifest);
314
315 const baseGraph = parseInternalDepGraph(baseContent);
316 const prGraph = parseInternalDepGraph(prContent);
317
318 const baseViolations = checkContainment(
319 baseGraph.graph, baseGraph.internalCrates, basePathMap, policy
320 );
321 const prViolations = checkContainment(
322 prGraph.graph, prGraph.internalCrates, prPathMap, policy
323 );
324 newViolations = diffContainmentViolations(baseViolations, prViolations);
325 }
326
327 const needsReview = hasExternalChanges || newViolations.length > 0;
328
329 if (needsReview) {
330 // Build and log summary
331 let summary = "";
332 if (hasExternalChanges) summary += buildSummary(diff);
333 if (newViolations.length > 0) summary += buildPolicySummary(newViolations);
334 console.log(summary);
335
336 // Request review from the dependency team
337 console.log(`Requesting review from @microsoft/${DEP_REVIEW_TEAM}`);
338 await github.rest.pulls.requestReviewers({
339 owner: context.repo.owner,
340 repo: context.repo.repo,
341 pull_number: prNumber,
342 team_reviewers: [DEP_REVIEW_TEAM],
343 });
344 } else {
345 console.log(
346 "Cargo.lock changed, but no new or updated external dependencies " +
347 "were detected and no policy violations found. No dependency review required."
348 );
349
350 // Remove the review request if it was previously added
351 try {
352 await github.rest.pulls.removeRequestedReviewers({
353 owner: context.repo.owner,
354 repo: context.repo.repo,
355 pull_number: prNumber,
356 team_reviewers: [DEP_REVIEW_TEAM],
357 });
358 console.log(`Removed review request from @microsoft/${DEP_REVIEW_TEAM}`);
359 } catch (e) {
360 // 422 = team was not requested — that's fine. Log anything else.
361 if (e.status !== 422) {
362 console.log(`Note: failed to remove review request (${e.status}): ${e.message}`);
363 }
364 }
365 }
366}
367
368// --- Local CLI entrypoint ---
369
370async function localMain() {
371 const fs = require("fs");
372 const path = require("path");
373 const { execSync } = require("child_process");
374 const args = process.argv.slice(2);
375
376 if (args.includes("--help") || args.includes("-h")) {
377 console.log(
378 "Usage:\n" +
379 " node dep-review.js --check Compare working tree against merge-base with origin/main\n" +
380 " node dep-review.js --check <upstream> Compare working tree against merge-base with <upstream>\n" +
381 " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml> --policy <dep-policy.json>]\n" +
382 "\nThe --check mode automatically finds the merge base, reads the base\n" +
383 "Cargo.lock/Cargo.toml from git, and compares against the working tree.\n" +
384 "It also auto-discovers dep-policy.json relative to the script location.\n" +
385 "\nIn --base/--pr mode, --manifest and --policy must be specified together\n" +
386 "for containment policy checks (both are optional if you only want dep diff)."
387 );
388 process.exit(0);
389 }
390
391 const checkIdx = args.indexOf("--check");
392 let baseContent, prContent, baseManifestContent, prManifestContent;
393
394 if (checkIdx !== -1) {
395 // --check mode: auto-detect merge base
396 const upstream = args[checkIdx + 1] && !args[checkIdx + 1].startsWith("--")
397 ? args[checkIdx + 1]
398 : "origin/main";
399
400 // Find repo root
401 const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
402
403 // Find merge base
404 let mergeBase;
405 try {
406 mergeBase = execSync(`git merge-base HEAD ${upstream}`, { encoding: "utf8" }).trim();
407 } catch (e) {
408 console.error(
409 `Could not find merge base with '${upstream}'. ` +
410 `Make sure '${upstream}' exists (try: git fetch origin).`
411 );
412 process.exit(1);
413 }
414
415 const shortBase = execSync(`git rev-parse --short ${mergeBase}`, { encoding: "utf8" }).trim();
416 console.log(`Comparing working tree against merge base ${shortBase} (with ${upstream})\n`);
417
418 // Read base files from git
419 baseContent = execSync(`git show ${mergeBase}:Cargo.lock`, { encoding: "utf8" });
420 baseManifestContent = execSync(`git show ${mergeBase}:Cargo.toml`, { encoding: "utf8" });
421
422 // Read PR files from working tree
423 prContent = fs.readFileSync(path.join(root, "Cargo.lock"), "utf8");
424 prManifestContent = fs.readFileSync(path.join(root, "Cargo.toml"), "utf8");
425
426 // Auto-discover policy file
427 const policyPath = path.join(root, ".github", "dep-policy.json");
428 let policy = null;
429 if (fs.existsSync(policyPath)) {
430 policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
431 }
432
433 const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy);
434 process.exit(result ? 1 : 0);
435
436 } else {
437 // Explicit --base / --pr mode
438 const baseIdx = args.indexOf("--base");
439 const prIdx = args.indexOf("--pr");
440
441 if (baseIdx === -1 || prIdx === -1) {
442 console.error(
443 "Usage:\n" +
444 " node dep-review.js --check (auto merge-base mode)\n" +
445 " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml>] [--policy <dep-policy.json>]"
446 );
447 process.exit(1);
448 }
449
450 baseContent = fs.readFileSync(args[baseIdx + 1], "utf8");
451 prContent = fs.readFileSync(args[prIdx + 1], "utf8");
452
453 const manifestIdx = args.indexOf("--manifest");
454 const policyIdx = args.indexOf("--policy");
455
456 let policy = null;
457 if (manifestIdx !== -1 && policyIdx !== -1) {
458 baseManifestContent = fs.readFileSync(args[manifestIdx + 1], "utf8");
459 prManifestContent = baseManifestContent; // same manifest in explicit mode
460 policy = JSON.parse(fs.readFileSync(args[policyIdx + 1], "utf8"));
461 }
462
463 const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy);
464 process.exit(result ? 1 : 0);
465 }
466}
467
468/**
469 * Run all checks (external dep diff + policy). Returns true if issues were found.
470 */
471function runChecks(baseContent, prContent, baseManifest, prManifest, policy) {
472 let failed = false;
473
474 // External dep diff
475 const baseDeps = parseExternalDeps(baseContent);
476 const prDeps = parseExternalDeps(prContent);
477 const diff = diffDeps(baseDeps, prDeps);
478 const hasExternalChanges = diff.added.length > 0;
479
480 if (hasExternalChanges) {
481 console.log(buildSummary(diff));
482 failed = true;
483 }
484
485 // Policy checks
486 if (policy && baseManifest && prManifest) {
487 const basePathMap = parseCratePathMap(baseManifest);
488 const prPathMap = parseCratePathMap(prManifest);
489
490 const baseGraph = parseInternalDepGraph(baseContent);
491 const prGraph = parseInternalDepGraph(prContent);
492
493 const baseViolations = checkContainment(
494 baseGraph.graph, baseGraph.internalCrates, basePathMap, policy
495 );
496 const prViolations = checkContainment(
497 prGraph.graph, prGraph.internalCrates, prPathMap, policy
498 );
499 const newViolations = diffContainmentViolations(baseViolations, prViolations);
500
501 if (newViolations.length > 0) {
502 console.log(buildPolicySummary(newViolations));
503 failed = true;
504 }
505 }
506
507 if (!failed) {
508 console.log("No dependency review issues detected.");
509 }
510 return failed;
511}
512
513// Export for testing and for actions/github-script
514module.exports = {
515 parseExternalDeps,
516 fmtSource,
517 diffDeps,
518 buildSummary,
519 parseCratePathMap,
520 parseInternalDepGraph,
521 checkContainment,
522 diffContainmentViolations,
523 buildPolicySummary,
524 run,
525};
526
527// Run CLI if invoked directly
528if (require.main === module) {
529 localMain();
530}
531