fix: detect runtime config changes in ci plan
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user