diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 25d236ca..78a6a588 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -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(); diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 507b938e..7847264c 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -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") {