diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index d6c9735f..3f3edca3 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -79,7 +79,8 @@ HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动 - 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。 - 组件边界固定由 `scripts/src/g14-ci-plan-lib.mjs` 的内建 service-path model 定义;如需新增或调整 `componentPaths`、`sharedPaths`、`runtimeDeps` 或 `buildSystemPaths`,直接修改 planner 库和对应测试,不再额外维护 repo-local commands/forbidden skeleton。 - `hwpod`/`device-pod-cli` 是 runner 和 runtime 镜像内的稳定工具入口,不是纯本地 `hwlab-cli` 源码工具;`tools/device-pod-cli.mjs`、`tools/device-pod-cli.ts`、`tools/src/device-pod-cli-lib.ts` 和 `skills/device-pod-cli/` 必须作为 shared runtime input 进入 component model。修改这些路径时,G14/v0.2 CI 必须至少触发携带 `/usr/local/bin/hwpod` 的 runtime 服务重新构建或 env-reuse rollout,不能全量复用旧 artifact 后只报告 PipelineRun 成功。 -- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 从 `deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest;复用前必须同时满足 catalog 有可验证 digest 且非 env-reuse 服务的 `componentInputHash` 等于本轮 planner 计算值。catalog 缺 digest 或 component input hash 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。 +- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 从 `deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest。非 env-reuse 服务复用前必须满足 artifact provenance 自证:catalog 有可验证 digest、catalog 的 `sourceCommitId` 在当前 repo 可解析、用该 `sourceCommitId` 的 source tree 重新计算出的 `componentInputHash` 等于 catalog 记录、该 hash 再等于本轮 planner 计算值,且 `dockerfileHash`/`buildArgsHash` 没有不一致。catalog 缺 digest、缺 provenance、source tree 无法解析、catalog hash 与 catalog source tree 不一致、或 catalog hash 与本轮 input 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。 +- Artifact catalog 的 per-service provenance 是镜像 digest 的身份证明,不是本轮 planner 状态缓存。reuse 路径只能保留旧 artifact 自身的 `sourceCommitId`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImage*` 和 `buildArgsHash`;禁止把当前 planner 的 component/build 元数据写入复用的旧 digest,否则下一轮 planner 会把旧镜像误判成已包含新输入,造成 CI/CD false-green。 - `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 默认只预览,只有 G14 Tekton promotion 显式传 `--write` 时,才把生成的 `commitId`、`image`、`imageTag`、`digest`、`publishState` 和 component provenance 字段写进当前 workspace 的 `deploy/artifact-catalog.dev.json`,随后只提交到 `G14-gitops`。`deploy/deploy.json` 是人写的 runtime config 真相源,不得被 promotion、refresh 脚本或人工发布流程回写镜像身份字段。 - `scripts/g14-gitops-render.mjs` 只支持混合 desired state:workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy/artifact-catalog.dev.json` 的 per-service artifact identity;普通 env、replica、healthPath、profile 等配置来自 `deploy/deploy.json`。全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images` 与 `HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。 - G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 显式 `--write` 刷新 workspace 内的 `deploy/artifact-catalog.dev.json`,然后把刷新后的 catalog 和 rendered GitOps desired state 一起提交到 `G14-gitops`。promotion 不得自动修改或推送 `G14` source branch,也不得自动修改 `deploy/deploy.json` 或 `deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突。 diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 836e9748..a4bc4216 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -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) { diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 42609f7b..8b13e2dd 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -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", diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index fcc0ed20..d57fb8bc 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -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 || ""), diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 240e8451..6c405e31 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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" };