fix: reuse env catalog across hash metadata drift

This commit is contained in:
lyon
2026-06-26 08:03:57 +08:00
committed by root
parent 91c8d6009c
commit ddf52e02d2
2 changed files with 114 additions and 7 deletions
+60 -2
View File
@@ -152,7 +152,7 @@ test("v02 planner rebuilds env image when an env reuse catalog digest is missing
assert.deepEqual(cloudApi.reason, ["environment-input-changed"]);
});
test("v03 planner rebuilds env image when catalog env input hash is stale", async () => {
test("v03 planner rebuilds env image when catalog env input hash is stale and source commit is unavailable", async () => {
const repo = await createFixtureRepo();
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const initialPlan = await createCiPlan({
@@ -163,7 +163,7 @@ test("v03 planner rebuilds env image when catalog env input hash is stale", asyn
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
services: ["hwlab-cloud-api"]
});
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: "0".repeat(40) }));
catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input";
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
@@ -184,6 +184,64 @@ test("v03 planner rebuilds env image when catalog env input hash is stale", asyn
assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]);
});
test("v03 planner reuses catalog env image when hash metadata drifts but env source tree is unchanged", async () => {
const repo = await createFixtureRepo();
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const initialPlan = await createCiPlan({
repoRoot: repo,
lane: "v03",
baseRef: "HEAD",
targetRef: initialSha,
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
services: ["hwlab-cloud-api"]
});
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
const catalogCloudApi = catalog.services.find((service) => service.serviceId === "hwlab-cloud-api");
catalogCloudApi.environmentInputHash = "stale-environment-input";
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
await git(repo, ["commit", "-m", "seed drifted v03 env catalog"]);
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n");
await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]);
await git(repo, ["commit", "-m", "change cloud-api code only"]);
const requests = [];
const plan = await createCiPlan({
repoRoot: repo,
lane: "v03",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
services: ["hwlab-cloud-api"],
verifyReuseRegistry: true,
reuseRegistryProbe: async (request) => {
requests.push(request);
if (request.image === catalogCloudApi.environmentImage && request.digest === catalogCloudApi.environmentDigest) {
return { status: "present", method: "HEAD", statusCode: 200, digest: catalogCloudApi.environmentDigest };
}
return { status: "missing", method: "HEAD", statusCode: 404 };
}
});
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
assert.equal(cloudApi.environmentInputChanged, true);
assert.equal(cloudApi.effectiveEnvironmentInputChanged, false);
assert.equal(cloudApi.environmentMetadataHashDrift, true);
assert.equal(cloudApi.environmentSourceUnchanged, true);
assert.equal(cloudApi.envChanged, false);
assert.equal(cloudApi.codeChanged, true);
assert.equal(cloudApi.affected, true);
assert.equal(cloudApi.buildRequired, false);
assert.equal(cloudApi.environmentImage, catalogCloudApi.environmentImage);
assert.equal(cloudApi.environmentDigest, catalogCloudApi.environmentDigest);
assert.equal(cloudApi.reuseRegistry.reuseSource, "catalog-metadata-hash-drift");
assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
assert.deepEqual(plan.buildServices, []);
assert.equal(requests.length, 2);
assert.match(requests[0].image, /hwlab-cloud-api-env:env-/u);
assert.equal(requests[1].image, catalogCloudApi.environmentImage);
});
test("v03 planner reuses current env hash tag when registry already has it", async () => {
const repo = await createFixtureRepo();
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
+54 -5
View File
@@ -130,24 +130,60 @@ export async function createCiPlan(options = {}) {
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
}) : null;
const environmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash) : null;
const currentEnvironmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash) : null;
const catalogEnvironmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
const catalogEnvironmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
const catalogEnvironmentSourceCommitId = envReuse ? text(catalogRecord?.sourceCommitId) : "";
const catalogEnvironmentInputHashAtSource = envReuse && catalogEnvironmentSourceCommitId
? await hashEnvReuseInputsIfCommitExists(repoRoot, catalogEnvironmentSourceCommitId, envInputPaths, serviceEnvReuseRecipe, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
semanticPackageJson: true
})
: null;
const environmentSourceUnchanged = envReuse && Boolean(catalogEnvironmentInputHashAtSource) && catalogEnvironmentInputHashAtSource === environmentInputHash;
let environmentImage = currentEnvironmentImage;
let environmentDigest = envReuse ? environmentDigestFromCatalogForHash(catalogRecord, environmentInputHash) : null;
const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null;
const reuseRegistry = envReuse && reuseRegistryProbe && environmentImage
const currentReuseRegistry = envReuse && reuseRegistryProbe && currentEnvironmentImage
? await reuseRegistryProbe({
serviceId: model.serviceId,
image: environmentImage,
image: currentEnvironmentImage,
digest: environmentDigest,
registryPrefix,
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
})
: null;
let reuseRegistry = currentReuseRegistry;
let environmentMetadataHashDrift = false;
const catalogEnvironmentReusable = envReuse
&& environmentInputChanged === true
&& environmentSourceUnchanged
&& Boolean(catalogEnvironmentImage)
&& /^sha256:[a-f0-9]{64}$/u.test(catalogEnvironmentDigest ?? "");
if (catalogEnvironmentReusable && currentReuseRegistry?.status !== "present") {
const catalogReuseRegistry = reuseRegistryProbe && catalogEnvironmentImage
? await reuseRegistryProbe({
serviceId: model.serviceId,
image: catalogEnvironmentImage,
digest: catalogEnvironmentDigest,
registryPrefix,
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
})
: null;
if (!reuseRegistryProbe || catalogReuseRegistry?.status === "present") {
environmentImage = catalogEnvironmentImage;
environmentDigest = catalogReuseRegistry?.digest ?? catalogEnvironmentDigest;
reuseRegistry = catalogReuseRegistry ? { ...catalogReuseRegistry, reuseSource: "catalog-metadata-hash-drift" } : null;
environmentMetadataHashDrift = true;
}
}
if (reuseRegistry?.status === "present" && /^sha256:[a-f0-9]{64}$/u.test(reuseRegistry.digest ?? "")) {
environmentDigest = reuseRegistry.digest;
}
const reuseRegistryUnavailable = Boolean(reuseRegistry && reuseRegistry.status !== "present");
const digestReady = /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "");
const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !environmentInputChanged && digestReady);
const effectiveEnvironmentInputChanged = environmentInputChanged === true && !environmentMetadataHashDrift;
const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !effectiveEnvironmentInputChanged && digestReady);
const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || reuseRegistryUnavailable) : null;
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
const componentInputPaths = uniqueSorted([
@@ -230,6 +266,10 @@ export async function createCiPlan(options = {}) {
envChanged,
runtimeConfigChanged,
environmentInputChanged,
effectiveEnvironmentInputChanged,
environmentMetadataHashDrift,
catalogEnvironmentInputHashAtSource,
environmentSourceUnchanged,
codeChanged,
reuseRegistry,
environmentInputHash,
@@ -242,7 +282,7 @@ export async function createCiPlan(options = {}) {
envChangedPaths: relevantEnvMatches,
codeChangedPaths: relevantCodeMatches,
reason: affected
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged, codeChanged, reuseRegistry })
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged: effectiveEnvironmentInputChanged, codeChanged, reuseRegistry })
: reasonForUnchanged(globalChange),
reuse: catalogReuse
});
@@ -721,6 +761,15 @@ async function hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options =
});
}
async function hashEnvReuseInputsIfCommitExists(repoRoot, targetRef, paths, recipe, options = {}) {
try {
await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]);
return await hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options);
} catch {
return null;
}
}
function envReuseIdentityRecipe(recipe) {
const identity = JSON.parse(JSON.stringify(recipe ?? {}));
if (identity.downloadStack && typeof identity.downloadStack === "object") {