Generalize CI plan runtime lanes

This commit is contained in:
Codex Agent
2026-06-08 15:07:35 +08:00
parent 8874f79b0b
commit 5e59a75ff8
3 changed files with 92 additions and 59 deletions
+3 -2
View File
@@ -38,13 +38,14 @@ function readOption(argv, index, name) {
function usage() {
return [
"usage: node scripts/g14-ci-plan.mjs [--lane v02] [--base-ref REF] [--target-ref REF] [--pretty]",
"usage: node scripts/g14-ci-plan.mjs [--lane v02|v03] [--base-ref REF] [--target-ref REF] [--pretty]",
"",
"Plan G14 monorepo image builds without mutating CI/CD state.",
"For v02 env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.",
"For runtime lane env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.",
"",
"examples:",
" node scripts/g14-ci-plan.mjs --lane v02 --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --lane v03 --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty"
].join("\n");
}
+38 -6
View File
@@ -610,6 +610,31 @@ test("v02 planner reads env reuse declarations and recipe from deploy config", a
assert.equal(recipe.launcherInputPaths.includes("custom/env-tool.mjs"), true);
});
test("v03 planner reads env reuse declarations and catalog from v03 lane config", async () => {
const repo = await createFixtureRepo();
const plan = await createG14CiPlan({
repoRoot: repo,
lane: "v03",
baseRef: "HEAD",
targetRef: "HEAD",
services: ["hwlab-cloud-api", "hwlab-cloud-web"]
});
assert.equal(plan.compatibility.lane, "v03");
assert.equal(plan.compatibility.artifactCatalog, "deploy/artifact-catalog.v03.json");
assert.equal(plan.compatibility.serviceIdSource, "cli-services");
assert.equal(plan.compatibility.serviceDeclarationSource, "deploy.lanes.v03.serviceDeclarations");
assert.equal(plan.compatibility.envRecipeSource, "deploy.lanes.v03.envRecipe");
assert.deepEqual(plan.compatibility.envReuseServiceIds, ["hwlab-cloud-api", "hwlab-cloud-web"]);
for (const service of plan.services) {
assert.equal(service.runtimeMode, "env-reuse-git-mirror-checkout", service.serviceId);
assert.equal(service.envReuse, true, service.serviceId);
assert.equal(service.bootRepo, "git@github.com:pikasTech/HWLAB.git", service.serviceId);
assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId);
}
});
async function createFixtureRepo(options = {}) {
const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"];
const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS;
@@ -684,7 +709,8 @@ async function createFixtureRepo(options = {}) {
const catalog = createCatalogFixture(catalogMode, catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha, serviceIds }));
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]);
await git(repo, ["commit", "-m", "seed artifact catalogs"]);
}
return repo;
@@ -704,7 +730,8 @@ async function refreshFixtureCatalogForHead(repo) {
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(plan, { sourceCommitId, serviceIds: services }));
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]);
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
}
@@ -719,10 +746,9 @@ function planV02CoreServices(repo, options) {
}
function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) {
const deploy = {
lanes: {
v02: {
const v02 = {
sourceRepo: "git@github.com:pikasTech/HWLAB.git",
artifactCatalog: "deploy/artifact-catalog.v02.json",
envReuseServices: [...serviceIds],
bootScripts: {
"hwlab-cloud-api": "deploy/runtime/boot/hwlab-cloud-api.sh",
@@ -822,7 +848,13 @@ function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) {
template: "default",
overrides: {}
}
}
};
const v03 = JSON.parse(JSON.stringify(v02));
v03.artifactCatalog = "deploy/artifact-catalog.v03.json";
const deploy = {
lanes: {
v02,
v03
}
};
return deploy;
+51 -51
View File
@@ -8,7 +8,8 @@ import { readStructuredFileIfPresent } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
export const G14_CI_PLAN_VERSION = "v1";
export const V02_ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const V02_ENV_REUSE_RUNTIME_MODE = ENV_REUSE_RUNTIME_MODE;
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
@@ -51,25 +52,22 @@ export async function createG14CiPlan(options = {}) {
const targetRef = options.targetRef ?? "HEAD";
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
const lane = options.lane ?? "v02";
if (lane !== "v02") throw new Error(`unsupported CI plan lane ${lane}; v02 env reuse is configured from deploy/deploy.yaml`);
const artifactCatalogPath = options.artifactCatalogPath ?? (lane === "v02" ? "deploy/artifact-catalog.v02.json" : "deploy/artifact-catalog.dev.json");
const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab";
const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
const [deployJson, artifactCatalog] = await Promise.all([
readStructuredFileIfPresent(repoRoot, deployConfigPath, {}),
readStructuredFileIfPresent(repoRoot, artifactCatalogPath, null)
]);
const deployJson = await readStructuredFileIfPresent(repoRoot, deployConfigPath, {});
const laneConfig = laneDeployConfig(deployJson, lane);
assertLaneEnvReuseConfig(laneConfig, lane);
const artifactCatalogPath = options.artifactCatalogPath ?? defaultArtifactCatalogPath(laneConfig, lane);
const artifactCatalog = await readStructuredFileIfPresent(repoRoot, artifactCatalogPath, null);
const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]);
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
const laneConfig = laneDeployConfig(deployJson, lane);
assertV02EnvReuseConfig(laneConfig, lane);
const serviceIdResolution = resolveServiceIds({ deployJson, options, laneConfig, lane });
const envReuseRecipe = envReuseRecipeForLane(deployJson, lane);
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig);
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig, lane);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
@@ -85,8 +83,8 @@ export async function createG14CiPlan(options = {}) {
const rawRuntimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const runtimeDepMatches = rawRuntimeDepMatches.filter((item) => semanticRuntimeDepPaths.has(item));
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
const envReuse = lane === "v02" && envReuseServices.has(model.serviceId);
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
const envReuse = envReuseServices.has(model.serviceId);
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId, lane) : null;
const envInputPaths = envReuse ? uniqueSorted([
...model.runtimeDeps,
...envReuseRecipe.launcherInputPaths
@@ -196,13 +194,13 @@ export async function createG14CiPlan(options = {}) {
affected,
buildRequired,
componentInputChanged,
runtimeMode: envReuse ? V02_ENV_REUSE_RUNTIME_MODE : "service-image",
runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image",
envReuse,
envChanged,
codeChanged,
environmentInputHash,
codeInputHash,
bootRepo: envReuse ? canonicalBootRepo(deployJson) : null,
bootRepo: envReuse ? canonicalBootRepo(deployJson, lane) : null,
bootCommit: envReuse ? sourceCommitId : null,
bootSh,
environmentImage,
@@ -234,9 +232,10 @@ export async function createG14CiPlan(options = {}) {
currentRenderCompatible: true,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
serviceDeclarationSource: lane === "v02" ? `deploy.lanes.${lane}.serviceDeclarations` : "not-applicable",
envRecipeSource: lane === "v02" ? `deploy.lanes.${lane}.envRecipe` : "not-applicable",
v02EnvReuseServiceIds: [...envReuseServices],
serviceDeclarationSource: `deploy.lanes.${lane}.serviceDeclarations`,
envRecipeSource: `deploy.lanes.${lane}.envRecipe`,
envReuseServiceIds: [...envReuseServices],
v02EnvReuseServiceIds: lane === "v02" ? [...envReuseServices] : [],
defaultComponentLazyBuild: true,
envReuseCodeOnlyFastLane: true,
fullBuildFallback: false
@@ -267,9 +266,9 @@ export async function createG14CiPlan(options = {}) {
};
}
export function componentModelsForServices(serviceIds, laneConfig = null) {
export function componentModelsForServices(serviceIds, laneConfig = null, lane = "v02") {
return serviceIds.map((serviceId) => {
const declaration = serviceDeclarationFor(laneConfig, serviceId);
const declaration = serviceDeclarationFor(laneConfig, serviceId, lane);
return {
serviceId,
runtimeKind: declaration.runtimeKind,
@@ -280,18 +279,18 @@ export function componentModelsForServices(serviceIds, laneConfig = null) {
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig).additionalEnvPaths)
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig, lane).additionalEnvPaths)
};
});
}
function serviceDeclarationFor(laneConfig, serviceId) {
function serviceDeclarationFor(laneConfig, serviceId, lane) {
const configured = laneConfig?.serviceDeclarations?.[serviceId];
const runtimeKind = requireDeclarationString(configured?.runtimeKind, `serviceDeclarations.${serviceId}.runtimeKind`);
const entrypoint = requireDeclarationString(configured?.entrypoint, `serviceDeclarations.${serviceId}.entrypoint`);
const runtimeKind = requireDeclarationString(configured?.runtimeKind, lane, `serviceDeclarations.${serviceId}.runtimeKind`);
const entrypoint = requireDeclarationString(configured?.entrypoint, lane, `serviceDeclarations.${serviceId}.entrypoint`);
const artifactKind = normalizeDeclarationString(configured?.artifactKind) || runtimeKind;
if (!Array.isArray(configured?.componentPaths) || configured.componentPaths.length === 0) {
throw new Error(`deploy.lanes.v02.serviceDeclarations.${serviceId}.componentPaths is required`);
throw new Error(`deploy.lanes.${lane}.serviceDeclarations.${serviceId}.componentPaths is required`);
}
return {
runtimeKind,
@@ -303,9 +302,9 @@ function serviceDeclarationFor(laneConfig, serviceId) {
};
}
function requireDeclarationString(value, label) {
function requireDeclarationString(value, lane, label) {
const text = normalizeDeclarationString(value);
if (!text) throw new Error(`deploy.lanes.v02.${label} is required`);
if (!text) throw new Error(`deploy.lanes.${lane}.${label} is required`);
return text;
}
@@ -318,27 +317,31 @@ function laneDeployConfig(deployJson, lane) {
return deployJson?.lanes?.[lane] ?? null;
}
export function envReuseRecipeForLane(deployJson, lane = "v02") {
return envReuseRecipeForLaneConfig(laneDeployConfig(deployJson, lane));
function defaultArtifactCatalogPath(laneConfig, lane) {
return normalizeDeclarationString(laneConfig?.artifactCatalog) ?? `deploy/artifact-catalog.${lane}.json`;
}
function envReuseRecipeForLaneConfig(laneConfig) {
export function envReuseRecipeForLane(deployJson, lane = "v02") {
return envReuseRecipeForLaneConfig(laneDeployConfig(deployJson, lane), lane);
}
function envReuseRecipeForLaneConfig(laneConfig, lane = "v02") {
const configured = laneConfig?.envRecipe;
if (!configured) throw new Error("deploy.lanes.v02.envRecipe is required");
if (!configured) throw new Error(`deploy.lanes.${lane}.envRecipe is required`);
const osPackages = normalizeStringList(configured.osPackages, []);
const bunVersion = requireDeclarationString(configured.bunVersion, "envRecipe.bunVersion");
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, "envRecipe.launcherPath"));
const bunVersion = requireDeclarationString(configured.bunVersion, lane, "envRecipe.bunVersion");
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, lane, "envRecipe.launcherPath"));
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
if (!runtimeNodeModulesPath) throw new Error("deploy.lanes.v02.envRecipe.runtimeNodeModulesPath must be absolute");
if (!runtimeNodeModulesPath) throw new Error(`deploy.lanes.${lane}.envRecipe.runtimeNodeModulesPath must be absolute`);
const additionalEnvPaths = uniqueSorted(normalizeStringList(configured.additionalEnvPaths, []).map(normalizeRepoPath));
const launcherInputPaths = uniqueSorted([
launcherPath,
...additionalEnvPaths
]);
const hwpodAliases = normalizeHwpodAliases(configured.hwpodAliases);
if (osPackages.length === 0) throw new Error("deploy.lanes.v02.envRecipe.osPackages is required");
if (additionalEnvPaths.length === 0) throw new Error("deploy.lanes.v02.envRecipe.additionalEnvPaths is required");
if (hwpodAliases.length === 0) throw new Error("deploy.lanes.v02.envRecipe.hwpodAliases is required");
if (osPackages.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.osPackages is required`);
if (additionalEnvPaths.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.additionalEnvPaths is required`);
if (hwpodAliases.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.hwpodAliases is required`);
return {
osPackages,
bunVersion,
@@ -376,12 +379,11 @@ function normalizeHwpodAliases(value) {
})).filter((item) => item.command && item.script);
}
function assertV02EnvReuseConfig(laneConfig, lane) {
if (lane !== "v02") return;
function assertLaneEnvReuseConfig(laneConfig, lane) {
if (!laneConfig?.serviceDeclarations || Object.keys(laneConfig.serviceDeclarations).length === 0) {
throw new Error("deploy.lanes.v02.serviceDeclarations is required for v02 env reuse planning");
throw new Error(`deploy.lanes.${lane}.serviceDeclarations is required for ${lane} env reuse planning`);
}
if (!laneConfig?.envRecipe) throw new Error("deploy.lanes.v02.envRecipe is required for v02 env reuse planning");
if (!laneConfig?.envRecipe) throw new Error(`deploy.lanes.${lane}.envRecipe is required for ${lane} env reuse planning`);
}
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
@@ -425,25 +427,24 @@ async function readJsonFromGit(repoRoot, ref, filePath, fallback) {
}
export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
if (lane !== "v02") return new Set();
const configured = deployJson?.lanes?.v02?.envReuseServices;
const configured = deployJson?.lanes?.[lane]?.envReuseServices;
const values = Array.isArray(configured) ? configured : [];
const allowed = new Set(serviceIds);
return new Set(values.filter((serviceId) => allowed.has(serviceId)));
}
export function bootShForService(deployJson, serviceId) {
const configured = deployJson?.lanes?.v02?.bootScripts?.[serviceId];
if (!configured) throw new Error(`deploy.lanes.v02.bootScripts.${serviceId} is required`);
export function bootShForService(deployJson, serviceId, lane = "v02") {
const configured = deployJson?.lanes?.[lane]?.bootScripts?.[serviceId];
if (!configured) throw new Error(`deploy.lanes.${lane}.bootScripts.${serviceId} is required`);
const bootSh = normalizeRepoPath(configured);
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
throw new Error(`invalid v02 boot script for ${serviceId}: ${configured}`);
throw new Error(`invalid ${lane} boot script for ${serviceId}: ${configured}`);
}
return bootSh;
}
function canonicalBootRepo(deployJson) {
return deployJson?.lanes?.v02?.sourceRepo || "git@github.com:pikasTech/HWLAB.git";
function canonicalBootRepo(deployJson, lane) {
return deployJson?.lanes?.[lane]?.sourceRepo || "git@github.com:pikasTech/HWLAB.git";
}
export function classifyGlobalChange(changedPaths) {
@@ -549,12 +550,11 @@ function resolveServiceIds({ options, laneConfig, lane }) {
return { source: "cli-services", serviceIds };
}
const configured = Array.isArray(laneConfig?.envReuseServices) ? laneConfig.envReuseServices.filter(Boolean) : [];
if (configured.length === 0) throw new Error("deploy.lanes.v02.envReuseServices is required for v02 CI planning");
return { source: "deploy.lanes.v02.envReuseServices", serviceIds: uniquePreserveOrder(configured) };
if (configured.length === 0) throw new Error(`deploy.lanes.${lane}.envReuseServices is required for ${lane} CI planning`);
return { source: `deploy.lanes.${lane}.envReuseServices`, serviceIds: uniquePreserveOrder(configured) };
}
function knownServiceIds(laneConfig, lane) {
if (lane !== "v02") return new Set();
const values = new Set(Object.keys(laneConfig?.serviceDeclarations ?? {}));
for (const serviceId of laneConfig?.envReuseServices ?? []) values.add(serviceId);
return values;