fix: 校验 artifact 复用组件输入
This commit is contained in:
@@ -56,6 +56,49 @@ test("planner rebuilds when catalog digest is missing instead of blocking reuse"
|
||||
assert.equal(plan.services[0].reason.includes("catalog-digest-missing"), true);
|
||||
});
|
||||
|
||||
test("planner rebuilds service image when catalog component input hash is stale", async () => {
|
||||
const repo = await createFixtureRepo({ includeDevicePod: true });
|
||||
const initialPlan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
||||
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"]
|
||||
});
|
||||
const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
const staleHash = initialCloudApi.componentInputHash;
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
cloudApiComponentInputHash: staleHash
|
||||
}), null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
||||
await git(repo, ["commit", "-m", "seed catalog with current cloud-api hash"]);
|
||||
|
||||
await writeFile(path.join(repo, "tools/src/device-pod-cli-lib.ts"), "export const hwpod = true;\n");
|
||||
await git(repo, ["add", "tools/src/device-pod-cli-lib.ts"]);
|
||||
await git(repo, ["commit", "-m", "change shared hwpod runtime"]);
|
||||
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
|
||||
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "document planner reuse invariant\n");
|
||||
await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]);
|
||||
await git(repo, ["commit", "-m", "document planner reuse invariant"]);
|
||||
|
||||
const plan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD~1",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
||||
services: ["hwlab-cloud-api"]
|
||||
});
|
||||
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
assert.equal(cloudApi.affected, true);
|
||||
assert.equal(cloudApi.buildRequired, true);
|
||||
assert.equal(cloudApi.componentInputChanged, true);
|
||||
assert.equal(cloudApi.reuse.status, "component-input-mismatch");
|
||||
assert.deepEqual(cloudApi.reason, ["component-input-mismatch"]);
|
||||
assert.ok(plan.buildServices.includes("hwlab-cloud-api"));
|
||||
});
|
||||
|
||||
test("component model uses built-in service paths", () => {
|
||||
const models = componentModelsForServices(["hwlab-cloud-api"]);
|
||||
assert.deepEqual(models[0].componentPaths, [
|
||||
@@ -441,6 +484,9 @@ function createCatalogFixture(mode, options = {}) {
|
||||
image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`,
|
||||
imageTag: "abc1234",
|
||||
digest,
|
||||
...((serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash) ? {
|
||||
componentInputHash: options.cloudApiComponentInputHash
|
||||
} : {}),
|
||||
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? {
|
||||
runtimeMode: "env-reuse-git-mirror-checkout",
|
||||
envReuse: true,
|
||||
|
||||
@@ -177,11 +177,6 @@ export async function createG14CiPlan(options = {}) {
|
||||
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
|
||||
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null;
|
||||
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
|
||||
const catalogReuse = envReuse ? envReuseCandidate(catalogRecord, envChanged) : reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0);
|
||||
const affected = envReuse
|
||||
? Boolean(envChanged || codeChanged)
|
||||
: imageRelevantChangedPaths.length > 0 || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
|
||||
const buildRequired = envReuse ? envChanged === true : affected;
|
||||
const componentInputPaths = uniqueSorted([
|
||||
...model.componentPaths,
|
||||
...model.sharedPaths,
|
||||
@@ -200,6 +195,14 @@ export async function createG14CiPlan(options = {}) {
|
||||
runtimeKind: model.runtimeKind,
|
||||
entrypoint: model.entrypoint
|
||||
});
|
||||
const componentInputChanged = !envReuse && typeof catalogRecord?.componentInputHash === "string" && catalogRecord.componentInputHash !== componentInputHash;
|
||||
const catalogReuse = envReuse
|
||||
? envReuseCandidate(catalogRecord, envChanged)
|
||||
: reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0, componentInputChanged);
|
||||
const affected = envReuse
|
||||
? Boolean(envChanged || codeChanged)
|
||||
: imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
|
||||
const buildRequired = envReuse ? envChanged === true : affected;
|
||||
services.push({
|
||||
serviceId: model.serviceId,
|
||||
runtimeKind: model.runtimeKind,
|
||||
@@ -217,6 +220,7 @@ export async function createG14CiPlan(options = {}) {
|
||||
changedPaths: imageRelevantChangedPaths,
|
||||
affected,
|
||||
buildRequired,
|
||||
componentInputChanged,
|
||||
runtimeMode: envReuse ? V02_ENV_REUSE_RUNTIME_MODE : "service-image",
|
||||
envReuse,
|
||||
envChanged,
|
||||
@@ -496,6 +500,7 @@ function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, bui
|
||||
if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed");
|
||||
if (runtimeDepMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("runtime-deps-changed");
|
||||
if (buildSystemMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("build-system-changed");
|
||||
if (catalogReuse?.status === "component-input-mismatch") reasons.push("component-input-mismatch");
|
||||
if (catalogReuse?.status === "candidate-no-catalog") reasons.push("catalog-record-missing");
|
||||
if (catalogReuse?.status === "candidate-unverified-digest") reasons.push("catalog-digest-missing");
|
||||
return reasons.length ? reasons : ["affected"];
|
||||
@@ -509,9 +514,18 @@ function reasonForUnchanged(globalChange) {
|
||||
return ["component-inputs-unchanged"];
|
||||
}
|
||||
|
||||
function reuseCandidate(catalogRecord, affected) {
|
||||
function reuseCandidate(catalogRecord, affected, componentInputChanged = false) {
|
||||
if (affected) return { status: "not-reused", reason: "component-affected" };
|
||||
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
||||
if (componentInputChanged) {
|
||||
return {
|
||||
status: "component-input-mismatch",
|
||||
reason: "catalog-component-input-hash-differs-from-current-plan",
|
||||
image: catalogRecord.image ?? null,
|
||||
digest: catalogRecord.digest ?? null,
|
||||
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
||||
};
|
||||
}
|
||||
if (!/^sha256:[a-f0-9]{64}$/u.test(catalogRecord.digest ?? "")) {
|
||||
return { status: "candidate-unverified-digest", image: catalogRecord.image ?? null, digest: catalogRecord.digest ?? null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user