fix: 防止 artifact 复用污染组件证明

This commit is contained in:
Codex
2026-05-31 22:28:06 +08:00
parent a234145983
commit d5baeb461b
5 changed files with 218 additions and 16 deletions
+25 -2
View File
@@ -1481,6 +1481,7 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
const digest = catalogService?.digest ?? "not_published";
const imageTag = catalogService?.imageTag ?? imageTagFromReference(image);
const catalogCommitId = catalogService?.commitId ?? imageTag ?? null;
const catalogProvenance = serviceImageCatalogProvenance(catalogService);
if (!image || !imageTag || !shaDigestPattern.test(digest)) {
return {
...service,
@@ -1491,9 +1492,10 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
imageTag,
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
buildSource: catalogService?.buildSource ?? null,
buildBackend: "reused-catalog",
digest,
repositoryDigest: repositoryDigestFor(image, digest),
...catalogProvenance,
buildBackend: "reused-catalog",
reusedFrom: null,
notPublishedReason: "reuse_catalog_digest_unavailable"
};
@@ -1509,11 +1511,24 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
buildSource: catalogService?.buildSource ?? null,
digest,
repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest),
...catalogProvenance,
buildBackend: "reused-catalog",
reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId,
notPublishedReason: null
};
}
function serviceImageCatalogProvenance(catalogService) {
return {
componentCommitId: catalogService?.componentCommitId ?? null,
componentInputHash: catalogService?.componentInputHash ?? null,
dockerfileHash: catalogService?.dockerfileHash ?? null,
baseImageReference: catalogService?.baseImageReference ?? null,
baseImageDigest: catalogService?.baseImageDigest ?? null,
buildArgsHash: catalogService?.buildArgsHash ?? null
};
}
function artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId }) {
const environmentImage = catalogService?.environmentImage ?? service.environmentImage ?? null;
const environmentDigest = catalogService?.environmentDigest ?? service.environmentDigest ?? "not_published";
@@ -1651,6 +1666,7 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
const image = serviceResultValue(env, service.serviceId, "IMAGE");
const digest = serviceResultValue(env, service.serviceId, "DIGEST") ?? "not_published";
const imageTag = serviceResultValue(env, service.serviceId, "IMAGE_TAG") ?? imageTagFromReference(image);
const reusedServiceImage = status === "reused" && service.envReuse !== true;
const artifact = {
...service,
serviceId: service.serviceId,
@@ -1662,7 +1678,7 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
sourceCommitId: serviceResultValue(env, service.serviceId, "SOURCE_COMMIT_ID"),
commitId: imageTag,
buildCreatedAt: serviceResultValue(env, service.serviceId, "BUILD_CREATED_AT"),
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? service.componentInputHash ?? null,
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? (reusedServiceImage ? null : service.componentInputHash ?? null),
environmentInputHash: serviceResultValue(env, service.serviceId, "ENVIRONMENT_INPUT_HASH") ?? service.environmentInputHash ?? null,
codeInputHash: serviceResultValue(env, service.serviceId, "CODE_INPUT_HASH") ?? service.codeInputHash ?? null,
runtimeMode: serviceResultValue(env, service.serviceId, "RUNTIME_MODE") ?? service.runtimeMode ?? "service-image",
@@ -1671,6 +1687,13 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
bootSh: serviceResultValue(env, service.serviceId, "BOOT_SH") ?? service.bootSh ?? null,
reusedFrom: serviceResultValue(env, service.serviceId, "REUSED_FROM"),
buildBackend: serviceResultValue(env, service.serviceId, "BUILD_BACKEND"),
...(reusedServiceImage ? {
componentCommitId: null,
dockerfileHash: null,
baseImageReference: null,
baseImageDigest: null,
buildArgsHash: null
} : {}),
notPublishedReason: null
};
if (service.envReuse) {
+101 -7
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
@@ -69,6 +69,7 @@ test("planner rebuilds service image when catalog component input hash is stale"
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", {
sourceCommitId: initialPlan.sourceCommitId,
cloudApiComponentInputHash: staleHash
}), null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
@@ -99,6 +100,61 @@ test("planner rebuilds service image when catalog component input hash is stale"
assert.ok(plan.buildServices.includes("hwlab-cloud-api"));
});
test("planner rejects polluted catalog hash that does not match catalog source tree", async () => {
const repo = await createFixtureRepo({ includeDevicePod: true });
const oldSourceCommit = (await git(repo, ["rev-parse", "HEAD~1"])).stdout.trim();
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"]);
const currentPlan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services: ["hwlab-cloud-api"]
});
const currentCloudApi = currentPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
sourceCommitId: oldSourceCommit,
cloudApiComponentInputHash: currentCloudApi.componentInputHash
}), null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "pollute catalog provenance"]);
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs only after provenance pollution\n");
await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]);
await git(repo, ["commit", "-m", "docs after polluted catalog"]);
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.reuse.status, "component-source-mismatch");
assert.deepEqual(cloudApi.reason, ["component-source-mismatch"]);
});
test("artifact reuse paths preserve catalog provenance instead of current planner provenance", async () => {
const artifactPublish = await readFile("scripts/artifact-publish.mjs", "utf8");
assert.match(artifactPublish, /const catalogProvenance = serviceImageCatalogProvenance\(catalogService\);/u);
assert.match(artifactPublish, /\.\.\.catalogProvenance,[\s\S]{0,80}buildBackend: "reused-catalog"/u);
assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u);
const gitopsRender = await readFile("scripts/g14-gitops-render.mjs", "utf8");
assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
});
test("component model uses built-in service paths", () => {
const models = componentModelsForServices(["hwlab-cloud-api"]);
assert.deepEqual(models[0].componentPaths, [
@@ -139,6 +195,7 @@ test("planner ignores package script cleanup for runtime images", async () => {
}, null, 2));
await git(repo, ["add", "package.json"]);
await git(repo, ["commit", "-m", "seed package scripts"]);
await refreshFixtureCatalogForHead(repo, { includeDevicePod: true });
await writeFile(path.join(repo, "package.json"), JSON.stringify({
type: "module",
@@ -310,7 +367,10 @@ test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", a
});
const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
sourceCommitId: initialPlan.sourceCommitId,
componentInputHashes: initialHashes,
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
cloudWebBootCommit: initialPlan.sourceCommitId,
@@ -360,7 +420,10 @@ test("v02 planner treats hwpod runner CLI changes as runtime inputs", async () =
});
const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
sourceCommitId: initialPlan.sourceCommitId,
componentInputHashes: initialHashes,
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
cloudWebBootCommit: initialPlan.sourceCommitId,
@@ -411,10 +474,6 @@ async function createFixtureRepo(options = {}) {
await mkdir(path.join(repo, "deploy"), { recursive: true });
await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }), null, 2));
const catalogMode = options.catalog ?? "ready";
if (catalogMode !== false) {
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2));
}
await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n");
@@ -440,9 +499,43 @@ async function createFixtureRepo(options = {}) {
await git(repo, ["init"]);
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "initial"]);
if (catalogMode !== false) {
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const initialPlan = await createG14CiPlan({
repoRoot: repo,
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services: ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])]
});
const componentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
const catalog = createCatalogFixture(catalogMode, { sourceCommitId: initialSha, componentInputHashes });
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "seed artifact catalogs"]);
}
return repo;
}
async function refreshFixtureCatalogForHead(repo, { includeDevicePod = true } = {}) {
const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const services = ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])];
const plan = await createG14CiPlan({
repoRoot: repo,
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services
});
const componentInputHashes = Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash]));
const catalog = createCatalogFixture("ready", { sourceCommitId, componentInputHashes });
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
}
function createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }) {
const deploy = {
lanes: {
@@ -481,11 +574,12 @@ function createCatalogFixture(mode, options = {}) {
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"].map((serviceId) => ({
serviceId,
commitId: "abc1234",
sourceCommitId: options.sourceCommitId ?? "abc1234",
image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`,
imageTag: "abc1234",
digest,
...((serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash) ? {
componentInputHash: options.cloudApiComponentInputHash
...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? {
componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId]
} : {}),
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? {
runtimeMode: "env-reuse-git-mirror-checkout",
+6 -3
View File
@@ -1396,6 +1396,9 @@ const digest = envReuse ? (service.environmentDigest || service.digest || "not_p
const imageTag = service.imageTag || (image.includes(":") ? image.slice(image.lastIndexOf(":") + 1) : "");
const repository = image.includes(":") ? image.slice(0, image.lastIndexOf(":")) : image;
const revision = process.env.HWLAB_SOURCE_REVISION || planned.bootCommit || service.bootCommit || service.sourceCommitId || service.commitId || imageTag;
const componentInputHash = envReuse ? (planned.componentInputHash || service.componentInputHash || "") : (service.componentInputHash || "");
const environmentInputHash = envReuse ? (service.environmentInputHash || planned.environmentInputHash || "") : (service.environmentInputHash || "");
const codeInputHash = envReuse ? (planned.codeInputHash || service.codeInputHash || "") : (service.codeInputHash || "");
const values = {
"service-id": serviceId,
status: /^sha256:[a-f0-9]{64}$/.test(digest) ? "reused" : "blocked_reuse_unavailable",
@@ -1404,9 +1407,9 @@ const values = {
digest,
"repository-digest": /^sha256:[a-f0-9]{64}$/.test(digest) && repository ? repository + "@" + digest : "",
"source-commit-id": envReuse ? revision : (service.sourceCommitId || service.commitId || imageTag),
"component-input-hash": planned.componentInputHash || service.componentInputHash || "",
"environment-input-hash": planned.environmentInputHash || service.environmentInputHash || "",
"code-input-hash": planned.codeInputHash || service.codeInputHash || "",
"component-input-hash": componentInputHash,
"environment-input-hash": environmentInputHash,
"code-input-hash": codeInputHash,
"runtime-mode": envReuse ? "env-reuse-git-mirror-checkout" : "service-image",
"boot-repo": planned.bootRepo || service.bootRepo || "",
"boot-commit": envReuse ? revision : (service.bootCommit || ""),
+84 -3
View File
@@ -195,10 +195,32 @@ export async function createG14CiPlan(options = {}) {
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint
});
const componentInputChanged = !envReuse && typeof catalogRecord?.componentInputHash === "string" && catalogRecord.componentInputHash !== componentInputHash;
const catalogSourceCommitId = !envReuse ? text(catalogRecord?.sourceCommitId) : "";
const catalogComponentInputHash = !envReuse ? text(catalogRecord?.componentInputHash) : "";
const catalogComponentInputHashAtSource = !envReuse && catalogSourceCommitId
? await hashGitPathsIfCommitExists(repoRoot, catalogSourceCommitId, componentInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
semanticPackageJson: true
})
: null;
const catalogComponentProvenance = !envReuse ? catalogComponentProvenanceStatus({
catalogRecord,
catalogSourceCommitId,
catalogComponentInputHash,
catalogComponentInputHashAtSource,
componentInputHash,
dockerfileHash,
buildArgsHash
}) : null;
const catalogRecordForReuse = catalogRecord && catalogComponentProvenance ? {
...catalogRecord,
componentProvenanceStatus: catalogComponentProvenance.status,
componentProvenanceReason: catalogComponentProvenance.reason
} : catalogRecord;
const componentInputChanged = !envReuse && catalogComponentProvenance?.safeToReuse !== true;
const catalogReuse = envReuse
? envReuseCandidate(catalogRecord, envChanged)
: reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0, componentInputChanged);
: reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged);
const affected = envReuse
? Boolean(envChanged || codeChanged)
: imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
@@ -213,6 +235,10 @@ export async function createG14CiPlan(options = {}) {
buildSystemPaths: model.buildSystemPaths,
componentCommitId,
componentInputHash,
catalogSourceCommitId: catalogSourceCommitId || null,
catalogComponentInputHash: catalogComponentInputHash || null,
catalogComponentInputHashAtSource,
catalogComponentProvenance,
dockerfileHash,
baseImageReference: baseImage,
baseImageDigest: digestFromImageReference(baseImage),
@@ -501,6 +527,10 @@ function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, bui
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 === "component-source-mismatch") reasons.push("component-source-mismatch");
if (catalogReuse?.status === "component-source-unavailable") reasons.push("component-source-unavailable");
if (catalogReuse?.status === "component-provenance-missing") reasons.push("component-provenance-missing");
if (catalogReuse?.status === "component-build-mismatch") reasons.push("component-build-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"];
@@ -517,7 +547,16 @@ function reasonForUnchanged(globalChange) {
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) {
if (componentInputChanged && catalogRecord?.componentProvenanceStatus) {
return {
status: catalogRecord.componentProvenanceStatus,
reason: catalogRecord.componentProvenanceReason,
image: catalogRecord.image ?? null,
digest: catalogRecord.digest ?? null,
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.sourceCommitId ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
};
}
if (componentInputChanged && typeof catalogRecord?.componentInputHash === "string") {
return {
status: "component-input-mismatch",
reason: "catalog-component-input-hash-differs-from-current-plan",
@@ -526,6 +565,15 @@ function reuseCandidate(catalogRecord, affected, componentInputChanged = false)
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
};
}
if (componentInputChanged) {
return {
status: "component-source-mismatch",
reason: "catalog-source-commit-does-not-contain-current-component-input",
image: catalogRecord.image ?? null,
digest: catalogRecord.digest ?? null,
reusedFrom: catalogRecord.sourceCommitId ?? 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 };
}
@@ -537,6 +585,39 @@ function reuseCandidate(catalogRecord, affected, componentInputChanged = false)
};
}
function catalogComponentProvenanceStatus({ catalogRecord, catalogSourceCommitId, catalogComponentInputHash, catalogComponentInputHashAtSource, componentInputHash, dockerfileHash, buildArgsHash }) {
if (!catalogRecord) return { safeToReuse: false, status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
if (!catalogSourceCommitId) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-source-commit-missing" };
if (!catalogComponentInputHash) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-component-input-hash-missing" };
if (!catalogComponentInputHashAtSource) return { safeToReuse: false, status: "component-source-unavailable", reason: "catalog-source-commit-unavailable" };
if (catalogComponentInputHashAtSource !== catalogComponentInputHash) {
return { safeToReuse: false, status: "component-source-mismatch", reason: "catalog-component-input-hash-does-not-match-catalog-source-tree" };
}
if (catalogComponentInputHash !== componentInputHash) {
return { safeToReuse: false, status: "component-input-mismatch", reason: "catalog-component-input-hash-differs-from-current-plan" };
}
if (text(catalogRecord.dockerfileHash) && catalogRecord.dockerfileHash !== dockerfileHash) {
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-dockerfile-hash-differs-from-current-plan" };
}
if (text(catalogRecord.buildArgsHash) && catalogRecord.buildArgsHash !== buildArgsHash) {
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-build-args-hash-differs-from-current-plan" };
}
return { safeToReuse: true, status: "safe-reuse", reason: "catalog-source-tree-and-current-component-input-match" };
}
async function hashGitPathsIfCommitExists(repoRoot, targetRef, paths, options = {}) {
try {
await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]);
return await hashGitPaths(repoRoot, targetRef, paths, options);
} catch {
return null;
}
}
function text(value) {
return String(value ?? "").trim();
}
function envReuseCandidate(catalogRecord, envChanged) {
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };