Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 31 additions & 48 deletions .github/scripts/detect-affected.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ const specializedPlugins = new Map([
["paseo-omp", "omp"],
["paseo-shared-browser", "shared-browser"],
]);
const ciPaths = [".github/workflows/ci.yml", ".github/scripts/"];
const runAllPaths = ["package.json", "bun.lock", ".github/workflows/ci.yml", ".github/scripts/"];
const workflowPaths = [".github/workflows/", ".github/actions/"];

export function discoverPlugins(root = process.cwd()) {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
Expand All @@ -26,58 +25,40 @@ export function discoverPlugins(root = process.cwd()) {

const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
const scripts = packageJson.scripts ?? {};
const invalidScripts = [
typeof scripts.check === "string" && scripts.check.length > 0
? undefined
: "script:check",
scripts.build === "node ../.github/scripts/build-plugin.mjs"
? undefined
: "script:build",
typeof scripts.typecheck === "string" ? undefined : "script:typecheck",
scripts.test === "run-s test:ci:*" ? undefined : "script:test",
Object.keys(scripts).some((script) => script.startsWith("test:ci:"))
? undefined
: "script:test:ci:*",
].filter(Boolean);
const devDependencies = packageJson.devDependencies ?? {};
const requiredTools = {
"@biomejs/biome": null,
"@getpaseo/server": "0.9.0-beta.1",
"npm-run-all2": null,
const coverage = typeof scripts["test:coverage"] === "string";
const testUnit = typeof scripts["test:unit"] === "string";
const descriptor = {
plugin,
kind: specializedPlugins.get(plugin) ?? "npm",
check: typeof scripts.check === "string",
lint: typeof scripts.lint === "string",
format_check: typeof scripts["format:check"] === "string",
typecheck: typeof scripts.typecheck === "string",
coverage,
test_unit: !coverage && testUnit,
test:
!coverage && !testUnit && typeof scripts.test === "string",
verify_package: typeof scripts["verify:package"] === "string",
};
const missingTools = Object.entries(requiredTools)
.filter(([tool, version]) =>
version === null
? typeof devDependencies[tool] !== "string"
: devDependencies[tool] !== version,
)
.map(([tool]) => tool);

if (invalidScripts.length > 0 || missingTools.length > 0) {
const missing = [
...invalidScripts,
...missingTools.map((tool) => `devDependency:${tool}`),
];

if (
descriptor.kind === "npm" &&
(!descriptor.typecheck ||
!(descriptor.coverage || descriptor.test_unit || descriptor.test))
) {
throw new Error(
`${plugin} is missing required CI entries: ${missing.join(", ")}`,
`${plugin} must define typecheck and a test, test:unit, or test:coverage script`,
);
}

return [
{
plugin,
kind: specializedPlugins.get(plugin) ?? "npm",
},
];
return [descriptor];
})
.sort(({ plugin: left }, { plugin: right }) => left.localeCompare(right));
}

export function detectAffected(files, plugins = discoverPlugins()) {
const runAll = files.some((file) =>
ciPaths.some((ciPath) =>
ciPath.endsWith("/") ? file.startsWith(ciPath) : file === ciPath,
),
runAllPaths.some((path) => (path.endsWith("/") ? file.startsWith(path) : file === path)),
);
const workflowAffected = files.some((file) =>
workflowPaths.some((workflowPath) => file.startsWith(workflowPath)),
Expand All @@ -88,11 +69,13 @@ export function detectAffected(files, plugins = discoverPlugins()) {
);
const isAffected = ({ plugin }) => runAll || changedPluginNames.has(plugin);
const affectedPlugins = plugins.filter(isAffected);
const pluginMatrix = affectedPlugins.map(({ plugin }) => plugin);
const npmPlugins = affectedPlugins
.filter(({ kind }) => kind === "npm")
.map(({ kind: _, ...plugin }) => plugin);

return {
pluginMatrix,
pluginsAffected: pluginMatrix.length > 0,
npmMatrix: { include: npmPlugins },
npmAffected: npmPlugins.length > 0,
ompAffected: affectedPlugins.some(({ kind }) => kind === "omp"),
sharedBrowserAffected: affectedPlugins.some(
({ kind }) => kind === "shared-browser",
Expand Down Expand Up @@ -124,8 +107,8 @@ function changedFiles(baseSha, headSha, diffMode) {

function writeOutputs(result, outputPath) {
const outputs = {
plugin_matrix: JSON.stringify(result.pluginMatrix),
plugins_affected: String(result.pluginsAffected),
npm_matrix: JSON.stringify(result.npmMatrix),
npm_affected: String(result.npmAffected),
omp_affected: String(result.ompAffected),
shared_browser_affected: String(result.sharedBrowserAffected),
workflow_affected: String(result.workflowAffected),
Expand Down
132 changes: 42 additions & 90 deletions .github/scripts/detect-affected.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import test from "node:test";

import { detectAffected, discoverPlugins } from "./detect-affected.mjs";

test("discovers plugins implementing the common CI contract", (t) => {
test("discovers new plugins and derives their checks from package scripts", (t) => {
const root = mkdtempSync(join(tmpdir(), "paseo-plugins-"));
t.after(() => rmSync(root, { force: true, recursive: true }));
const pluginRoot = join(root, "new-plugin");
Expand All @@ -17,15 +17,10 @@ test("discovers plugins implementing the common CI contract", (t) => {
JSON.stringify({
scripts: {
check: "biome check .",
build: "node ../.github/scripts/build-plugin.mjs",
typecheck: "tsc --noEmit",
test: "run-s test:ci:*",
"test:ci:unit": "vitest run",
},
devDependencies: {
"@biomejs/biome": "2.5.14",
"@getpaseo/server": "0.9.0-beta.1",
"npm-run-all2": "9.0.3",
test: "vitest run",
"test:coverage": "vitest run --coverage",
"verify:package": "node verify.mjs",
},
}),
);
Expand All @@ -34,90 +29,30 @@ test("discovers plugins implementing the common CI contract", (t) => {
{
plugin: "new-plugin",
kind: "npm",
check: true,
lint: false,
format_check: false,
typecheck: true,
coverage: true,
test_unit: false,
test: false,
verify_package: true,
},
]);
});

test("rejects plugins missing common CI entries", (t) => {
const root = mkdtempSync(join(tmpdir(), "paseo-plugins-"));
t.after(() => rmSync(root, { force: true, recursive: true }));
const pluginRoot = join(root, "incomplete-plugin");
mkdirSync(pluginRoot);
writeFileSync(join(pluginRoot, "paseo-plugin.json"), '{"id":"incomplete-plugin"}');
writeFileSync(
join(pluginRoot, "package.json"),
JSON.stringify({ scripts: { test: "vitest run" } }),
);

assert.throws(
() => discoverPlugins(root),
/script:check, script:build, script:typecheck, script:test, script:test:ci:\*, devDependency:@biomejs\/biome, devDependency:@getpaseo\/server, devDependency:npm-run-all2/,
);
});

test("rejects a plugin using a different build command", (t) => {
const root = mkdtempSync(join(tmpdir(), "paseo-plugins-"));
t.after(() => rmSync(root, { force: true, recursive: true }));
const pluginRoot = join(root, "wrong-build");
mkdirSync(pluginRoot);
writeFileSync(join(pluginRoot, "paseo-plugin.json"), '{"id":"wrong-build"}');
writeFileSync(
join(pluginRoot, "package.json"),
JSON.stringify({
scripts: {
check: "biome check .",
build: "npm pack --dry-run",
typecheck: "tsc --noEmit",
test: "run-s test:ci:*",
"test:ci:unit": "vitest run",
},
devDependencies: {
"@biomejs/biome": "2.5.14",
"@getpaseo/server": "0.9.0-beta.1",
"npm-run-all2": "9.0.3",
},
}),
);

assert.throws(() => discoverPlugins(root), /script:build/);
});

test("rejects a ranged host compiler dependency", (t) => {
const root = mkdtempSync(join(tmpdir(), "paseo-plugins-"));
t.after(() => rmSync(root, { force: true, recursive: true }));
const pluginRoot = join(root, "ranged-compiler");
mkdirSync(pluginRoot);
writeFileSync(join(pluginRoot, "paseo-plugin.json"), '{"id":"ranged-compiler"}');
writeFileSync(
join(pluginRoot, "package.json"),
JSON.stringify({
scripts: {
check: "biome check .",
build: "node ../.github/scripts/build-plugin.mjs",
typecheck: "tsc --noEmit",
test: "run-s test:ci:*",
"test:ci:unit": "vitest run",
},
devDependencies: {
"@biomejs/biome": "2.5.14",
"@getpaseo/server": "^0.9.0-beta.1",
"npm-run-all2": "9.0.3",
},
}),
);

assert.throws(() => discoverPlugins(root), /devDependency:@getpaseo\/server/);
});

test("selects only changed plugins", () => {
test("selects only changed npm plugins", () => {
const result = detectAffected([
"agent-monitor/server/index.ts",
"paseo-beads/package.json",
]);

assert.deepEqual(result.pluginMatrix, ["agent-monitor", "paseo-beads"]);
assert.deepEqual(
result.npmMatrix.include.map(({ plugin }) => plugin),
["agent-monitor", "paseo-beads"],
);
assert.deepEqual(result.changed, ["agent-monitor", "paseo-beads"]);
assert.equal(result.pluginsAffected, true);
assert.equal(result.npmAffected, true);
assert.equal(result.ompAffected, false);
assert.equal(result.sharedBrowserAffected, false);
});
Expand All @@ -129,19 +64,16 @@ test("selects platform-specific jobs independently", () => {
]);

assert.equal(omp.ompAffected, true);
assert.equal(omp.pluginsAffected, true);
assert.deepEqual(omp.pluginMatrix, ["paseo-omp"]);
assert.equal(omp.npmAffected, false);
assert.equal(sharedBrowser.sharedBrowserAffected, true);
assert.equal(sharedBrowser.pluginsAffected, true);
assert.deepEqual(sharedBrowser.pluginMatrix, ["paseo-shared-browser"]);
assert.equal(sharedBrowser.npmAffected, false);
});

test("ignores changes outside plugin and CI paths", () => {
const result = detectAffected(["SECURITY.md"]);

assert.deepEqual(result.affected, []);
assert.deepEqual(result.pluginMatrix, []);
assert.equal(result.pluginsAffected, false);
assert.deepEqual(result.npmMatrix, { include: [] });
assert.equal(result.workflowAffected, false);
});

Expand All @@ -153,6 +85,23 @@ test("workflow changes enable security analysis", () => {
assert.deepEqual(result.changed, []);
});

test("root package manager changes select every discovered plugin", () => {
const plugins = discoverPlugins();

for (const file of ["package.json", "bun.lock"]) {
const result = detectAffected([file], plugins);

assert.equal(
result.npmMatrix.include.length,
plugins.filter(({ kind }) => kind === "npm").length,
);
assert.equal(result.ompAffected, true);
assert.equal(result.sharedBrowserAffected, true);
assert.equal(result.affected.length, plugins.length);
assert.deepEqual(result.changed, []);
}
});

test("CI implementation changes select every discovered plugin", () => {
const plugins = discoverPlugins();

Expand All @@ -162,7 +111,10 @@ test("CI implementation changes select every discovered plugin", () => {
]) {
const result = detectAffected([file], plugins);

assert.equal(result.pluginMatrix.length, plugins.length);
assert.equal(
result.npmMatrix.include.length,
plugins.filter(({ kind }) => kind === "npm").length,
);
assert.equal(result.ompAffected, true);
assert.equal(result.sharedBrowserAffected, true);
assert.equal(result.affected.length, plugins.length);
Expand Down
Loading
Loading