Files
rea/scripts/ci/plan.mjs
T
morluto 0c7f4dfbd9 ci: separate fast feedback from full qualification
Scope main pushes from a successful ancestor so canceled runs cannot hide changes. Keep the complete portable suite in fast feedback and move coverage plus Intel macOS to hourly, manual, explicit-full, and release qualification. Let qualification finish independently of superseded feedback.

Bind publication to full qualification at its exact revision and publish the verified tarball after checking workflow identity, version, and SHA-256. Keep trusted cache writers and reject incomplete merged test inventories.

Validation: 151 focused CI tests; typecheck/lint; actionlint 1.7.7; knip; all 1044 files discovered; bounded blob/merge smoke; macOS package verifier with retention and cleanup; npm pack dry run. Native Linux/ARM/Windows and remote workflow execution remain CI verification.
2026-10-13 17:09:53 +08:00

254 lines
8.9 KiB
JavaScript

import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { matchesGlob } from "node:path";
import { MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
const flags = [
...new Set([
"code",
"runtime",
"package",
"apple",
"fixtures",
"readiness",
"docs",
"workflows",
...Object.keys(SCOPES),
]),
];
// Test files select the native-host lanes that run them explicitly. Provider
// scopes remain implementation-owned, so test-only edits stay off those lanes.
const NATIVE_TEST_OWNERS = ["apple", "package"];
// Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([
".github/workflows/release.yml",
".github/workflows/real-ghidra-windows.yml",
".github/workflows/sqlite-runtime.yml",
]);
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
// analyzes the built C fixture as well as its own applications.
const FIXTURE_SOURCES = [
"tests/conformance/c/**",
"tests/conformance/napi/**",
"tests/conformance/objc/**",
"tests/conformance/swift/**",
"tests/conformance/versions/**",
];
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
const matchesAny = (file, patterns) =>
patterns.some((pattern) => matchesGlob(file, pattern));
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
const reasons = [];
const full = (reason) => {
for (const flag of flags) selected[flag] = true;
reasons.push(reason);
};
const git = (...args) =>
execFileSync("git", args, { encoding: "utf8" }).trimEnd();
function classifyPackage(base, head) {
const before = JSON.parse(git("show", `${base}:package.json`));
const after = JSON.parse(git("show", `${head}:package.json`));
const { scripts: beforeScripts, ...beforeRest } = before;
const { scripts: afterScripts, ...afterRest } = after;
if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) {
full("Package metadata or dependencies changed");
return;
}
selected.code = true;
const changed = [
...new Set([
...Object.keys(beforeScripts ?? {}),
...Object.keys(afterScripts ?? {}),
]),
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
for (const script of changed) {
if (
/^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test(
script,
)
)
continue;
if (
["compile:termux", "build:termux", "build:termux:run"].includes(script)
) {
selected.workflows = true;
reasons.push(
`Android build script changed; real Android verification remains manual: ${script}`,
);
continue;
}
// Verify scripts own provider setup as well as execution. Reuse their
// existing source-file ownership rather than maintaining a second map.
const entrypoint = (command) =>
/^(?:npm run build:cached && )?node (scripts\/[\w/-]+\.mjs)(?: [\w./=-]+)*$/.exec(
command ?? "",
)?.[1];
const beforeEntry = entrypoint(beforeScripts?.[script]);
const afterEntry = entrypoint(afterScripts?.[script]);
const entries = [beforeEntry, afterEntry].filter(Boolean);
const simple =
(!beforeScripts?.[script] || beforeEntry) &&
(!afterScripts?.[script] || afterEntry);
const scriptOwners =
simple &&
entries.every((entry) =>
Object.values(SCOPES).some((patterns) => matchesAny(entry, patterns)),
)
? Object.entries(SCOPES)
.filter(([, patterns]) =>
entries.some((entry) => matchesAny(entry, patterns)),
)
.map(([owner]) => owner)
: [];
if (scriptOwners.length)
for (const owner of scriptOwners) selected[owner] = true;
else full(`Shared or unknown package script: ${script}`);
}
}
const event = process.env.GITHUB_EVENT_NAME;
if (!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event))
throw new Error(`Unsupported CI event: ${event}`);
const tier =
event === "schedule" ||
event === "workflow_dispatch" ||
process.env.CI_FULL === "true"
? "full"
: "fast";
let files = [];
if (tier === "full") {
full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request" || event === "push") {
const head = process.env.HEAD_SHA;
let base;
if (event === "pull_request") {
base = git("merge-base", process.env.BASE_SHA, head);
} else {
const candidate = process.env.BASE_SHA;
const baselineReason = process.env.BASELINE_REASON;
try {
if (!candidate || !/^[0-9a-f]{40}$/i.test(candidate))
throw new Error(
baselineReason || "No valid successful push baseline was found",
);
execFileSync("git", ["merge-base", "--is-ancestor", candidate, head], {
stdio: "ignore",
});
base = candidate;
} catch {
full(
baselineReason ||
"No valid successful push baseline ancestor; selecting all lanes",
);
}
}
if (base) {
// Disable rename detection so both the removed and added paths participate.
files = git("diff", "--name-only", "--no-renames", "-z", base, head)
.split("\0")
.filter(Boolean);
for (const file of files) {
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
file,
) ||
file.startsWith("docs/") ||
file.startsWith(".github/ISSUE_TEMPLATE/")
) {
selected.docs = true;
selected.website ||= matchesAny(file, SCOPES.website);
continue;
}
if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) {
selected.code = true;
for (const owner of NATIVE_TEST_OWNERS)
selected[owner] ||= matchesAny(file, SCOPES[owner]);
continue;
}
if (matchesAny(file, MANUAL_VERIFIERS)) {
selected.code = true;
continue;
}
const owners = Object.entries(SCOPES)
.filter(([, patterns]) =>
patterns.some((pattern) => matchesGlob(file, pattern)),
)
.map(([owner]) => owner);
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
if (owners.length === 1 && owners[0] === "website") continue;
if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true;
if (file === "package.json") {
classifyPackage(base, head);
continue;
}
selected.code = true;
if (matchesAny(file, FIXTURE_SOURCES)) {
selected.fixtures = true;
selected.readiness = true;
}
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
if (file.startsWith("tests/")) continue;
if (
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
file,
)
) {
full(`Shared boundary: ${file}`);
} else if (owners.length) {
if (file.startsWith("src/") || file.startsWith("bridge/"))
selected.runtime = true;
} else if (/^(src|bridge)\//.test(file)) {
// Unowned runtime sources keep the Linux x64 installed-package baseline;
// full qualification retains every native host.
selected.runtime = true;
reasons.push(`Unowned runtime source: ${file}`);
} else if (file.startsWith(".agents/skills/")) {
selected.runtime = true;
selected.docs = true;
} else {
full(`Unclassified input: ${file}`);
}
}
}
}
selected.runtime ||= selected.package;
const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" };
// macOS x64 is the slowest package host. Fast qualification omits it.
const intel =
tier === "full"
? [{ os: "macos-15-intel", platform: "darwin", architecture: "x64" }]
: [];
const matrix = {
include: selected.package
? [
linux,
{ os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" },
{ os: "macos-15", platform: "darwin", architecture: "arm64" },
...intel,
]
: [linux],
};
const plan = { ...selected, tier, matrix, files, reasons };
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries({ ...selected, tier, matrix: JSON.stringify(matrix) })
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Selected CI lanes (${tier})\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`,
);
console.log(JSON.stringify(plan));