From bfcd6722f2aae339404c42632dfd48b882d5f82a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 21:05:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A0=A1=E9=AA=8C=20artifact=20?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=BB=84=E4=BB=B6=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/g14-gitops-cicd.md | 2 +- scripts/g14-ci-plan.test.mjs | 46 +++++++++++++++++++++++++++++++ scripts/src/g14-ci-plan-lib.mjs | 26 +++++++++++++---- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index 49b14b80..d6c9735f 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -79,7 +79,7 @@ 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` 复用已有 sha256 digest;如果 catalog 没有可验证 digest,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;复用前必须同时满足 catalog 有可验证 digest 且非 env-reuse 服务的 `componentInputHash` 等于本轮 planner 计算值。catalog 缺 digest 或 component input hash 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。 - `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/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 16e36d28..42609f7b 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -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, diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 476ef702..240e8451 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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 }; }