From 5e59a75ff8ea9a0a1e42db6e20ca1568a06a86ac Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 8 Jun 2026 15:07:35 +0800 Subject: [PATCH] Generalize CI plan runtime lanes --- scripts/g14-ci-plan.mjs | 5 +- scripts/g14-ci-plan.test.mjs | 44 ++++++++++++-- scripts/src/g14-ci-plan-lib.mjs | 102 ++++++++++++++++---------------- 3 files changed, 92 insertions(+), 59 deletions(-) diff --git a/scripts/g14-ci-plan.mjs b/scripts/g14-ci-plan.mjs index 0044da43..842c202e 100644 --- a/scripts/g14-ci-plan.mjs +++ b/scripts/g14-ci-plan.mjs @@ -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"); } diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 2d696ff1..572514e2 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -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; diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index af0513f9..07b3523d 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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;