import { appendFileSync, readFileSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { matchesGlob } from "node:path"; import { fileURLToPath } from "node:url"; const releaseNote = /^releases\/(beta\/)?v[^/]+\.md$/; const validPath = (file) => typeof file === "string" && file.length > 0 && !file.startsWith("/") && !file.split("/").includes("..") && !file.includes("\0"); function validateGlobs(globs) { if (!Array.isArray(globs) || globs.some((glob) => !validPath(glob) || /[!{}[\]?$\\]/.test(glob))) { throw new Error("Unsupported task input pattern"); } } export function comparisonPaths(comparison) { if (!Array.isArray(comparison?.files) || comparison.files.length === 0 || comparison.files.length >= 300) { throw new Error("Missing, empty, or possibly truncated comparison"); } return comparison.files.flatMap((file) => { if (!validPath(file.filename) || (file.status === "renamed" && !validPath(file.previous_filename)) || (file.previous_filename !== undefined && !validPath(file.previous_filename))) { throw new Error("Malformed changed file"); } return file.previous_filename ? [file.previous_filename, file.filename] : [file.filename]; }); } export function planTasks({ config, lanes, paths, mode = "enabled", forceReason = null }) { if (!["enabled", "shadow", "full"].includes(mode)) throw new Error("Invalid CI mode"); validateGlobs(config.globalDependencies); const tasks = config.tasks; const inputs = new Map(); function taskInputs(name, parents = []) { if (inputs.has(name)) return inputs.get(name); if (parents.includes(name)) throw new Error("Cyclic task graph"); const task = tasks[`//#${name}`]; if (!task) throw new Error(`Missing task: ${name}`); validateGlobs(task.inputs); const all = [...task.inputs]; for (const dependency of task.dependsOn ?? []) { if (!dependency.startsWith("//#")) throw new Error("Unsupported task dependency"); all.push(...taskInputs(dependency.slice(3), [...parents, name])); } inputs.set(name, [...new Set(all)]); return inputs.get(name); } for (const entries of Object.values(lanes)) { if (!Array.isArray(entries) || !entries.length) throw new Error("Empty CI lane"); for (const entry of entries) taskInputs(entry.task); } const changed = [...new Set(paths)]; if (!changed.length || changed.some((file) => !validPath(file))) forceReason ??= "Missing or invalid diff"; const matches = (file, globs) => globs.some((glob) => matchesGlob(file, glob)); const globalChanges = changed.filter((file) => matches(file, config.globalDependencies)); if (globalChanges.length) forceReason ??= `Global CI input changed: ${globalChanges[0]}`; const unknown = changed.filter((file) => !releaseNote.test(file) && !matches(file, config.globalDependencies) && ![...inputs.values()].some((globs) => matches(file, globs))); if (unknown.length) forceReason ??= `Unclassified path: ${unknown[0]}`; if (mode === "full") forceReason ??= "Full CI requested"; const reasons = Object.fromEntries([...inputs].map(([task, globs]) => [task, forceReason ? [forceReason] : [...new Set([...globalChanges, ...changed.filter((file) => matches(file, globs))])], ])); const jobs = Object.fromEntries(Object.entries(lanes).map(([job, entries]) => { const affected = entries.filter((entry) => reasons[entry.task].length > 0); const selected = mode === "shadow" ? entries : affected; return [job, { run: selected.length > 0, matrix: { include: selected.length ? selected : entries }, selected: selected.length, proposed: affected.length, total: entries.length }]; })); return { version: 1, mode, forceReason, changed, reasons, jobs }; } export function planSummary(plan) { const rows = Object.entries(plan.jobs).map(([job, lane]) => `| ${job} | ${lane.proposed}/${lane.total} | ${lane.selected}/${lane.total} |`); return ["### Incremental CI", "", `Mode: **${plan.mode}**${plan.forceReason ? ` — ${plan.forceReason}` : ""}`, "", "| Suite | Affected jobs | Scheduled jobs |", "| --- | ---: | ---: |", ...rows, "", "
Selection reasons", "", "```json", JSON.stringify(plan.reasons, null, 2), "```", "
", ""].join("\n"); } if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const config = JSON.parse(readFileSync(process.env.CI_TASK_CONFIG ?? "turbo.json", "utf8")); const lanes = JSON.parse(readFileSync(process.env.CI_TASK_MATRIX ?? ".github/ci-task-matrix.json", "utf8")); let paths = [], forceReason = process.env.CI_FORCE_REASON || null; try { if (process.env.CI_COMPARISON) { paths = comparisonPaths(JSON.parse(readFileSync(process.env.CI_COMPARISON, "utf8"))); } else { const [base, head = "HEAD"] = process.argv.slice(2); if (!base) throw new Error("Usage: ci-plan.mjs [head]"); paths = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", `${base}...${head}`, "--"], { encoding: "utf8" }).split("\0").filter(Boolean); } } catch (error) { forceReason ??= `Diff unavailable: ${error.message}`; } const plan = planTasks({ config, lanes, paths, mode: process.env.CI_INCREMENTAL_MODE || "enabled", forceReason }); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `plan=${JSON.stringify(plan)}\n`); else console.log(JSON.stringify(plan, null, 2)); if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, planSummary(plan)); } catch (error) { console.error(`Incremental CI unavailable; selecting full CI: ${error.message}`); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, "plan=\n"); else process.exitCode = 1; } }