From 545fa2fbc3880b9bf7e7ae1a5766f51908f1b7a1 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 22:07:10 +0800 Subject: [PATCH] fix: detect runtime config changes in ci plan --- scripts/ci-plan.test.mjs | 20 +++++++++++++ scripts/src/ci-plan-lib.mjs | 57 +++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index dcb93ab6..083ec912 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -1,3 +1,4 @@ +// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针 import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; @@ -53,6 +54,25 @@ test("node CI planner treats docs-only changes as no image build", async () => { assert.equal(plan.changedPathSummary.docsOnly, true); }); +test("node CI planner rolls service runtime config changes without rebuilding image", async () => { + const repo = await createFixtureRepo(); + const deployPath = path.join(repo, "deploy/deploy.yaml"); + const deploy = await readStructuredFile(repo, deployPath); + deploy.lanes.v02.serviceDeclarations["hwlab-cloud-api"].env.HWLAB_METRICS_NAMESPACE = "hwlab-v02"; + await writeStructuredFile(repo, deployPath, deploy); + await git(repo, ["add", "deploy/deploy.yaml"]); + await git(repo, ["commit", "-m", "change cloud api runtime env"]); + + const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.imageBuildRequired, false); + const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); + assert.equal(cloudApi.runtimeConfigChanged, true); + assert.equal(cloudApi.envChanged, false); + assert.deepEqual(cloudApi.reason, ["runtime-config-changed"]); +}); + test("planner rebuilds when catalog digest is missing instead of blocking reuse", async () => { const repo = await createFixtureRepo({ catalog: "missing-digest" }); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 230554ec..b632ebb8 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -1,3 +1,4 @@ +// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针 import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import * as http from "node:http"; @@ -5,7 +6,7 @@ import * as https from "node:https"; import path from "node:path"; import { promisify } from "node:util"; -import { readStructuredFileIfPresent } from "./structured-config.mjs"; +import { parseStructuredConfig, readStructuredFileIfPresent } from "./structured-config.mjs"; const execFileAsync = promisify(execFile); @@ -98,6 +99,7 @@ export async function createCiPlan(options = {}) { const envReuse = envReuseServices.has(model.serviceId); const bootSh = envReuse ? bootShForService(deployJson, model.serviceId, lane) : null; const serviceEnvReuseRecipe = envReuse ? envReuseRecipeForService(envReuseRecipe.publicRecipe, model) : null; + const runtimeConfigChanged = envReuse ? await serviceRuntimeConfigChanged(repoRoot, deployConfigPath, baseRef, targetRef, lane, model.serviceId) : null; const envInputPaths = envReuse ? uniqueSorted([ ...model.runtimeDeps, ...envReuseRecipe.launcherInputPaths @@ -189,7 +191,7 @@ export async function createCiPlan(options = {}) { ? envReuseCandidate(catalogRecord, envChanged) : reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged); const affected = envReuse - ? Boolean(envChanged || codeChanged) + ? Boolean(envChanged || codeChanged || runtimeConfigChanged) : imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest"; const buildRequired = envReuse ? envChanged === true : affected; services.push({ @@ -221,6 +223,7 @@ export async function createCiPlan(options = {}) { runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image", envReuse, envChanged, + runtimeConfigChanged, environmentInputChanged, codeChanged, reuseRegistry, @@ -234,7 +237,7 @@ export async function createCiPlan(options = {}) { envChangedPaths: relevantEnvMatches, codeChangedPaths: relevantCodeMatches, reason: affected - ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, environmentInputChanged, codeChanged, reuseRegistry }) + ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged, codeChanged, reuseRegistry }) : reasonForUnchanged(globalChange), reuse: catalogReuse }); @@ -303,6 +306,8 @@ export function componentModelsForServices(serviceIds, laneConfig = null, lane = artifactKind: declaration.artifactKind, healthPath: declaration.healthPath, healthPort: declaration.healthPort, + env: declaration.env, + observable: declaration.observable, componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)), sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)), runtimeDeps: runtimeDependencyPathsForDeclaration(declaration), @@ -333,10 +338,17 @@ function serviceDeclarationFor(laneConfig, serviceId, lane) { artifactKind, healthPath: normalizeDeclarationString(configured?.healthPath) || "/health/live", healthPort: Number.isInteger(configured?.healthPort) ? configured.healthPort : null, - componentPaths: configured.componentPaths + componentPaths: configured.componentPaths, + env: normalizeServiceEnv(configured?.env), + observable: configured?.observable === true }; } +function normalizeServiceEnv(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).map(([key, raw]) => [String(key), raw == null ? null : String(raw)]).sort(([left], [right]) => left.localeCompare(right))); +} + function requireDeclarationString(value, lane, label) { const text = normalizeDeclarationString(value); if (!text) throw new Error(`deploy.lanes.${lane}.${label} is required`); @@ -530,6 +542,40 @@ export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef return result; } +async function serviceRuntimeConfigChanged(repoRoot, deployConfigPath, baseRef, targetRef, lane, serviceId) { + const [before, after] = await Promise.all([ + readDeployConfigFromGit(repoRoot, baseRef, deployConfigPath), + readDeployConfigFromGit(repoRoot, targetRef, deployConfigPath) + ]); + if (!before || !after) return true; + return stableJson(serviceRuntimeConfigIdentity(before, lane, serviceId)) !== stableJson(serviceRuntimeConfigIdentity(after, lane, serviceId)); +} + +async function readDeployConfigFromGit(repoRoot, ref, deployConfigPath) { + try { + return parseStructuredConfig(await gitValue(repoRoot, ["show", `${ref}:${deployConfigPath}`]), deployConfigPath); + } catch { + return null; + } +} + +function serviceRuntimeConfigIdentity(deployJson, lane, serviceId) { + const laneConfig = laneDeployConfig(deployJson, lane); + const declaration = laneConfig?.serviceDeclarations?.[serviceId] ?? {}; + return { + namespace: normalizeDeclarationString(laneConfig?.namespace), + serviceId, + bootScript: normalizeRepoPath(laneConfig?.bootScripts?.[serviceId]), + runtimeKind: normalizeDeclarationString(declaration.runtimeKind), + artifactKind: normalizeDeclarationString(declaration.artifactKind), + entrypoint: normalizeRepoPath(declaration.entrypoint), + healthPath: normalizeDeclarationString(declaration.healthPath), + healthPort: Number.isInteger(declaration.healthPort) ? declaration.healthPort : null, + env: normalizeServiceEnv(declaration.env), + observable: declaration.observable === true + }; +} + export async function packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath = "package.json") { const [before, after] = await Promise.all([ readJsonFromGit(repoRoot, baseRef, filePath, null), @@ -722,11 +768,12 @@ async function gitValue(repoRoot, args) { return result.stdout.trim(); } -function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, environmentInputChanged = null, codeChanged = null, reuseRegistry = null }) { +function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, runtimeConfigChanged = null, environmentInputChanged = null, codeChanged = null, reuseRegistry = null }) { const reasons = []; if (envReuse && environmentInputChanged) reasons.push("environment-input-mismatch"); if (envReuse && reuseRegistry && reuseRegistry.status !== "present") reasons.push("reuse-registry-missing"); if (envReuse && envChanged && reasons.length === 0) reasons.push("environment-input-changed"); + if (envReuse && runtimeConfigChanged) reasons.push("runtime-config-changed"); if (envReuse && codeChanged) reasons.push("code-input-changed"); if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed"); if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed");