fix: detect runtime config changes in ci plan

This commit is contained in:
lyon
2026-06-17 22:07:10 +08:00
parent e36272b2b6
commit 545fa2fbc3
2 changed files with 72 additions and 5 deletions
+20
View File
@@ -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 });
+52 -5
View File
@@ -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");