fix(release): omit unavailable candidate Docker scripts (#116432)

* fix(release): omit unavailable candidate Docker scripts

* fix(docker): fail on unreadable candidate manifest

* fix(docker): expose candidate planner option
This commit is contained in:
Dallin Romney
2026-07-31 14:18:18 +08:00
committed by GitHub
parent 2aabde7262
commit d24cdea7ec
5 changed files with 117 additions and 2 deletions

View File

@@ -19,6 +19,7 @@ export type DockerE2ePlanLane = {
export type DockerE2ePlanOptions = {
allowFrozenTargetScenarioOmissions?: boolean;
candidatePackageRoot?: string;
includeOpenWebUI: boolean;
liveMode: "all" | "only" | "skip";
liveRetries: number;

View File

@@ -497,6 +497,63 @@ function applyLiveRetries(poolLanes, retries) {
return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane));
}
const PNPM_NON_SCRIPT_COMMANDS = new Set([
"add",
"audit",
"config",
"dlx",
"exec",
"fetch",
"install",
"pack",
"publish",
"rebuild",
"remove",
]);
function candidatePackageScripts(candidatePackageRoot) {
if (!candidatePackageRoot) {
return undefined;
}
const packageJson = JSON.parse(
readFileSync(resolve(candidatePackageRoot, "package.json"), "utf8"),
);
if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) {
throw new Error("Candidate package manifest must be an object");
}
if (
packageJson.scripts !== undefined &&
(!packageJson.scripts ||
typeof packageJson.scripts !== "object" ||
Array.isArray(packageJson.scripts))
) {
throw new Error("Candidate package manifest has an invalid scripts field");
}
return new Set(
Object.entries(packageJson.scripts ?? {})
.filter(([, command]) => typeof command === "string")
.map(([name]) => name),
);
}
function requiredPackageScripts(poolLane) {
return [...poolLane.command.matchAll(/\bpnpm\s+(?:run\s+)?([a-z][a-z0-9:-]*)/giu)]
.map(([, script]) => script)
.filter((script) => !PNPM_NON_SCRIPT_COMMANDS.has(script));
}
function filterUnavailableCandidateScriptLanes(poolLanes, candidatePackageRoot) {
const scripts = candidatePackageScripts(candidatePackageRoot);
if (!scripts) {
return poolLanes;
}
// The trusted catalog can add lanes before a frozen candidate has their scripts.
// Only schedule package-script commands the selected candidate can execute.
return poolLanes.filter((poolLane) =>
requiredPackageScripts(poolLane).every((script) => scripts.has(script)),
);
}
export function laneWeight(poolLane) {
return Math.max(1, poolLane.weight ?? 1);
}
@@ -726,8 +783,16 @@ export function resolveDockerE2ePlan(options) {
: options.liveMode === "only"
? []
: applyLiveMode(retriedTailLanes, options.liveMode);
const orderedLanes = options.orderLanes(configuredLanes, options.timingStore);
const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore);
const availableLanes = filterUnavailableCandidateScriptLanes(
configuredLanes,
options.candidatePackageRoot,
);
const availableTailLanes = filterUnavailableCandidateScriptLanes(
configuredTailLanes,
options.candidatePackageRoot,
);
const orderedLanes = options.orderLanes(availableLanes, options.timingStore);
const orderedTailLanes = options.orderLanes(availableTailLanes, options.timingStore);
return {
omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames],
orderedLanes,

View File

@@ -1538,6 +1538,7 @@ async function main() {
upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS,
upgradeSurvivorTargetRoot: process.env.OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT,
allowFrozenTargetScenarioOmissions,
candidatePackageRoot: ROOT_DIR,
});
if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) {
throw new Error(

View File

@@ -168,6 +168,7 @@ describe("scripts/test-docker-all scheduler", () => {
const scriptsDir = path.join(root, "scripts");
const libDir = path.join(scriptsDir, "lib");
mkdirSync(libDir, { recursive: true });
copyFileSync("package.json", path.join(root, "package.json"));
copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs"));
for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) {
copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName));

View File

@@ -54,6 +54,12 @@ function planFor(
}).plan;
}
function writeCandidatePackage(scripts: Record<string, string>): string {
const root = tempDirs.make("openclaw-docker-plan-candidate-");
writeFileSync(join(root, "package.json"), JSON.stringify({ scripts }));
return root;
}
function requireFirstLane(plan: ReturnType<typeof planFor>) {
const [lane] = plan.lanes;
if (!lane) {
@@ -120,6 +126,47 @@ function bundledPluginSweepLane(index: number): ReturnType<typeof summarizeLane>
}
describe("scripts/lib/docker-e2e-plan", () => {
it("omits a package-script lane unavailable from the candidate", () => {
const plan = planFor({
candidatePackageRoot: writeCandidatePackage({}),
selectedLaneNames: ["update-run-package-self-upgrade"],
});
expect(plan.lanes).toEqual([]);
});
it("keeps a package-script lane available from the candidate", () => {
const plan = planFor({
candidatePackageRoot: writeCandidatePackage({
"test:docker:update-run-package-self-upgrade": "node test.mjs",
}),
selectedLaneNames: ["update-run-package-self-upgrade"],
});
expect(plan.lanes.map((lane) => lane.name)).toEqual(["update-run-package-self-upgrade"]);
});
it("fails when the selected candidate package manifest is missing", () => {
expect(() =>
planFor({
candidatePackageRoot: tempDirs.make("openclaw-docker-plan-missing-package-"),
selectedLaneNames: ["update-run-package-self-upgrade"],
}),
).toThrow(/package\.json/);
});
it("fails when the selected candidate package manifest is malformed", () => {
const root = tempDirs.make("openclaw-docker-plan-malformed-package-");
writeFileSync(join(root, "package.json"), "{");
expect(() =>
planFor({
candidatePackageRoot: root,
selectedLaneNames: ["update-run-package-self-upgrade"],
}),
).toThrow(SyntaxError);
});
it("finds a named lane through the expanded catalog", () => {
expect(findLaneByName("plugin-binding-command-escape")?.name).toBe(
"plugin-binding-command-escape",