microsoft/openvmm

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
bc046e1ea621f83b97afc2694de8eed2ed249869

Branches

Tags

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

Clone

HTTPS

Download ZIP

.github/scripts/dep-review.js

560lines · 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 baseTipSha = context.payload.pull_request.base.sha;
227 const headSha = context.payload.pull_request.head.sha;
228
229 // Use merge-base (base...head) as the baseline so we only review dependency
230 // deltas introduced by this PR, not unrelated changes that landed on base.
231 let baseSha = baseTipSha;
232 try {
233 const { data: compare } = await github.request(
234 "GET /repos/{owner}/{repo}/compare/{basehead}",
235 {
236 owner: context.repo.owner,
237 repo: context.repo.repo,
238 basehead: `${baseTipSha}...${headSha}`,
239 }
240 );
241 const mergeBaseSha = compare?.merge_base_commit?.sha;
242 if (mergeBaseSha) {
243 baseSha = mergeBaseSha;
244 console.log(`Using merge-base ${mergeBaseSha} for dependency diff baseline.`);
245 } else {
246 core.warning(
247 "Could not determine merge-base from compare API response; " +
248 "falling back to pull_request.base.sha."
249 );
250 }
251 } catch (e) {
252 core.warning(
253 `Failed to compute merge-base (${e.status || "unknown"}): ${e.message}. ` +
254 "Falling back to pull_request.base.sha."
255 );
256 }
257
258 // Step 1: Check if Cargo.lock was modified
259 let allFiles = [];
260 let page = 1;
261 const MAX_PAGES = 30;
262 while (page <= MAX_PAGES) {
263 const { data: files } = await github.rest.pulls.listFiles({
264 owner: context.repo.owner,
265 repo: context.repo.repo,
266 pull_number: prNumber,
267 per_page: 100,
268 page,
269 });
270 if (files.length === 0) break;
271 allFiles = allFiles.concat(files);
272 if (files.length < 100) break;
273 page++;
274 }
275
276 if (page > MAX_PAGES) {
277 core.warning(
278 `PR has more than ${MAX_PAGES * 100} changed files — ` +
279 `Cargo.lock detection may be incomplete. Assuming it changed.`
280 );
281 }
282
283 const lockfileChanged = page > MAX_PAGES || allFiles.some((f) => f.filename === "Cargo.lock");
284 if (!lockfileChanged) {
285 console.log("Cargo.lock not modified — nothing to review.");
286
287 // Remove any stale review request from a previous push that did touch Cargo.lock
288 try {
289 await github.rest.pulls.removeRequestedReviewers({
290 owner: context.repo.owner,
291 repo: context.repo.repo,
292 pull_number: prNumber,
293 team_reviewers: [DEP_REVIEW_TEAM],
294 });
295 console.log(`Removed stale review request from @microsoft/${DEP_REVIEW_TEAM}`);
296 } catch (e) {
297 if (e.status !== 422) {
298 console.log(`Note: failed to remove review request (${e.status}): ${e.message}`);
299 }
300 }
301 return;
302 }
303
304 // Step 2: Fetch base and PR Cargo.lock via API
305 // Always fetch from the base repo — for forked PRs, use the merge ref
306 // (refs/pull/N/head) so we don't need access to the fork itself.
307 async function fetchFile(path, ref) {
308 const { data } = await github.rest.repos.getContent({
309 owner: context.repo.owner,
310 repo: context.repo.repo,
311 path,
312 ref,
313 });
314 if (data.type !== "file") {
315 throw new Error(`${path}: not a regular file`);
316 }
317 return Buffer.from(data.content, "base64").toString("utf8");
318 }
319
320 const prRef = `refs/pull/${prNumber}/head`;
321
322 const baseContent = await fetchFile("Cargo.lock", baseSha);
323 const prContent = await fetchFile("Cargo.lock", prRef);
324
325 // Step 3: Diff external deps
326 const baseDeps = parseExternalDeps(baseContent);
327 const prDeps = parseExternalDeps(prContent);
328 const diff = diffDeps(baseDeps, prDeps);
329 const hasExternalChanges = diff.added.length > 0;
330
331 // Step 4: Check containment policies
332 const fs = require("fs");
333 const path = require("path");
334 const policyPath = path.join(__dirname, "..", "dep-policy.json");
335 let newViolations = [];
336 if (fs.existsSync(policyPath)) {
337 const policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
338
339 const baseManifest = await fetchFile("Cargo.toml", baseSha);
340 const prManifest = await fetchFile("Cargo.toml", prRef);
341
342 const basePathMap = parseCratePathMap(baseManifest);
343 const prPathMap = parseCratePathMap(prManifest);
344
345 const baseGraph = parseInternalDepGraph(baseContent);
346 const prGraph = parseInternalDepGraph(prContent);
347
348 const baseViolations = checkContainment(
349 baseGraph.graph, baseGraph.internalCrates, basePathMap, policy
350 );
351 const prViolations = checkContainment(
352 prGraph.graph, prGraph.internalCrates, prPathMap, policy
353 );
354 newViolations = diffContainmentViolations(baseViolations, prViolations);
355 }
356
357 const needsReview = hasExternalChanges || newViolations.length > 0;
358
359 if (needsReview) {
360 // Build and log summary
361 let summary = "";
362 if (hasExternalChanges) summary += buildSummary(diff);
363 if (newViolations.length > 0) summary += buildPolicySummary(newViolations);
364 console.log(summary);
365
366 // Request review from the dependency team
367 console.log(`Requesting review from @microsoft/${DEP_REVIEW_TEAM}`);
368 await github.rest.pulls.requestReviewers({
369 owner: context.repo.owner,
370 repo: context.repo.repo,
371 pull_number: prNumber,
372 team_reviewers: [DEP_REVIEW_TEAM],
373 });
374 } else {
375 console.log(
376 "Cargo.lock changed, but no new or updated external dependencies " +
377 "were detected and no policy violations found. No dependency review required."
378 );
379
380 // Remove the review request if it was previously added
381 try {
382 await github.rest.pulls.removeRequestedReviewers({
383 owner: context.repo.owner,
384 repo: context.repo.repo,
385 pull_number: prNumber,
386 team_reviewers: [DEP_REVIEW_TEAM],
387 });
388 console.log(`Removed review request from @microsoft/${DEP_REVIEW_TEAM}`);
389 } catch (e) {
390 // 422 = team was not requested — that's fine. Log anything else.
391 if (e.status !== 422) {
392 console.log(`Note: failed to remove review request (${e.status}): ${e.message}`);
393 }
394 }
395 }
396}
397
398// --- Local CLI entrypoint ---
399
400async function localMain() {
401 const fs = require("fs");
402 const path = require("path");
403 const { execSync } = require("child_process");
404 const args = process.argv.slice(2);
405
406 if (args.includes("--help") || args.includes("-h")) {
407 console.log(
408 "Usage:\n" +
409 " node dep-review.js --check Compare working tree against merge-base with origin/main\n" +
410 " node dep-review.js --check <upstream> Compare working tree against merge-base with <upstream>\n" +
411 " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml> --policy <dep-policy.json>]\n" +
412 "\nThe --check mode automatically finds the merge base, reads the base\n" +
413 "Cargo.lock/Cargo.toml from git, and compares against the working tree.\n" +
414 "It also auto-discovers dep-policy.json relative to the script location.\n" +
415 "\nIn --base/--pr mode, --manifest and --policy must be specified together\n" +
416 "for containment policy checks (both are optional if you only want dep diff)."
417 );
418 process.exit(0);
419 }
420
421 const checkIdx = args.indexOf("--check");
422 let baseContent, prContent, baseManifestContent, prManifestContent;
423
424 if (checkIdx !== -1) {
425 // --check mode: auto-detect merge base
426 const upstream = args[checkIdx + 1] && !args[checkIdx + 1].startsWith("--")
427 ? args[checkIdx + 1]
428 : "origin/main";
429
430 // Find repo root
431 const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
432
433 // Find merge base
434 let mergeBase;
435 try {
436 mergeBase = execSync(`git merge-base HEAD ${upstream}`, { encoding: "utf8" }).trim();
437 } catch (e) {
438 console.error(
439 `Could not find merge base with '${upstream}'. ` +
440 `Make sure '${upstream}' exists (try: git fetch origin).`
441 );
442 process.exit(1);
443 }
444
445 const shortBase = execSync(`git rev-parse --short ${mergeBase}`, { encoding: "utf8" }).trim();
446 console.log(`Comparing working tree against merge base ${shortBase} (with ${upstream})\n`);
447
448 // Read base files from git
449 baseContent = execSync(`git show ${mergeBase}:Cargo.lock`, { encoding: "utf8" });
450 baseManifestContent = execSync(`git show ${mergeBase}:Cargo.toml`, { encoding: "utf8" });
451
452 // Read PR files from working tree
453 prContent = fs.readFileSync(path.join(root, "Cargo.lock"), "utf8");
454 prManifestContent = fs.readFileSync(path.join(root, "Cargo.toml"), "utf8");
455
456 // Auto-discover policy file
457 const policyPath = path.join(root, ".github", "dep-policy.json");
458 let policy = null;
459 if (fs.existsSync(policyPath)) {
460 policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
461 }
462
463 const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy);
464 process.exit(result ? 1 : 0);
465
466 } else {
467 // Explicit --base / --pr mode
468 const baseIdx = args.indexOf("--base");
469 const prIdx = args.indexOf("--pr");
470
471 if (baseIdx === -1 || prIdx === -1) {
472 console.error(
473 "Usage:\n" +
474 " node dep-review.js --check (auto merge-base mode)\n" +
475 " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml>] [--policy <dep-policy.json>]"
476 );
477 process.exit(1);
478 }
479
480 baseContent = fs.readFileSync(args[baseIdx + 1], "utf8");
481 prContent = fs.readFileSync(args[prIdx + 1], "utf8");
482
483 const manifestIdx = args.indexOf("--manifest");
484 const policyIdx = args.indexOf("--policy");
485
486 let policy = null;
487 if (manifestIdx !== -1 && policyIdx !== -1) {
488 baseManifestContent = fs.readFileSync(args[manifestIdx + 1], "utf8");
489 prManifestContent = baseManifestContent; // same manifest in explicit mode
490 policy = JSON.parse(fs.readFileSync(args[policyIdx + 1], "utf8"));
491 }
492
493 const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy);
494 process.exit(result ? 1 : 0);
495 }
496}
497
498/**
499 * Run all checks (external dep diff + policy). Returns true if issues were found.
500 */
501function runChecks(baseContent, prContent, baseManifest, prManifest, policy) {
502 let failed = false;
503
504 // External dep diff
505 const baseDeps = parseExternalDeps(baseContent);
506 const prDeps = parseExternalDeps(prContent);
507 const diff = diffDeps(baseDeps, prDeps);
508 const hasExternalChanges = diff.added.length > 0;
509
510 if (hasExternalChanges) {
511 console.log(buildSummary(diff));
512 failed = true;
513 }
514
515 // Policy checks
516 if (policy && baseManifest && prManifest) {
517 const basePathMap = parseCratePathMap(baseManifest);
518 const prPathMap = parseCratePathMap(prManifest);
519
520 const baseGraph = parseInternalDepGraph(baseContent);
521 const prGraph = parseInternalDepGraph(prContent);
522
523 const baseViolations = checkContainment(
524 baseGraph.graph, baseGraph.internalCrates, basePathMap, policy
525 );
526 const prViolations = checkContainment(
527 prGraph.graph, prGraph.internalCrates, prPathMap, policy
528 );
529 const newViolations = diffContainmentViolations(baseViolations, prViolations);
530
531 if (newViolations.length > 0) {
532 console.log(buildPolicySummary(newViolations));
533 failed = true;
534 }
535 }
536
537 if (!failed) {
538 console.log("No dependency review issues detected.");
539 }
540 return failed;
541}
542
543// Export for testing and for actions/github-script
544module.exports = {
545 parseExternalDeps,
546 fmtSource,
547 diffDeps,
548 buildSummary,
549 parseCratePathMap,
550 parseInternalDepGraph,
551 checkContainment,
552 diffContainmentViolations,
553 buildPolicySummary,
554 run,
555};
556
557// Run CLI if invoked directly
558if (require.main === module) {
559 localMain();
560}
561