From 0ba5b2c3525285ffddceee12bebff3ada266756a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 26 May 2026 09:39:09 +0800 Subject: [PATCH] feat: add G14 monorepo CI planner --- AGENTS.md | 1 + docs/reference/g14-gitops-cicd.md | 12 + scripts/artifact-publish.mjs | 81 ++++- scripts/g14-ci-plan.mjs | 50 +++ scripts/g14-ci-plan.test.mjs | 122 +++++++ scripts/g14-gitops-render.mjs | 56 ++- scripts/refresh-artifact-catalog.mjs | 25 +- scripts/refresh-artifact-catalog.test.mjs | 11 +- scripts/src/g14-ci-plan-lib.mjs | 396 ++++++++++++++++++++++ 9 files changed, 738 insertions(+), 16 deletions(-) create mode 100644 scripts/g14-ci-plan.mjs create mode 100644 scripts/g14-ci-plan.test.mjs create mode 100644 scripts/src/g14-ci-plan-lib.mjs diff --git a/AGENTS.md b/AGENTS.md index e3ac434d..7cc70e7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Cloud Web M3 只读护栏:`npm run web:m3-readonly` - Cloud Workbench 布局/遮挡 smoke:`npm run web:layout`;local-build 用 `npm run web:layout:build`;DEV deploy 后用 `npm run web:layout:live`。 - G14 artifact build helper:`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy CLI CD。 +- G14 monorepo 组件计划:`node scripts/g14-ci-plan.mjs --base-ref --target-ref --pretty`,只读分析 affected/reused services,兼容 `CI.json` 与 `deploy/deploy.json`;细则见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 - G14 GitOps 渲染:`npm run g14:gitops:render`;检查已生成 Tekton/Argo CD manifests:`npm run g14:gitops:check` - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。 diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index 716ae4c0..44e501ae 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -38,6 +38,18 @@ npm run g14:gitops:render -- --source-revision npm run g14:gitops:check ``` +## Monorepo 组件计划与兼容 render + +HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动,但不能破坏当前 `CI.json`、`deploy/deploy.json` 与全量 source commit render 合同。 + +- `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 `CI.json`、`deploy/deploy.json` 和 `deploy/artifact-catalog.dev.json`,输出 `affectedServices`、`reusedServices`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImageDigest`、`buildArgsHash` 和原因;它不得修改 CI、deploy、catalog 或 GitOps 文件。 +- 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。 +- `CI.json` 仍是 CI 命令与禁用项合同来源;可选新增 `g14CiPlan.components.` 覆盖 `componentPaths`、`sharedPaths`、`runtimeDeps`、`buildSystemPaths`,但不得要求删除或改写现有 `commands` / `forbidden` 字段。 +- `scripts/g14-artifact-publish.mjs` 的 publish report 可以携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 可以把这些字段复制进 `deploy/artifact-catalog.dev.json` 的对应 service。旧字段 `commitId`、`image`、`imageTag`、`digest`、`publishState` 语义不变,旧检查脚本不得因为新增字段失败。 +- `scripts/g14-gitops-render.mjs` 默认保持 legacy 兼容:不传新开关时,仍按 `--source-revision` 把所有 workload image 渲染为同一个 source commit tag,现有 `npm run g14:gitops:check` 必须继续通过。 +- 混合 desired state 只能通过显式 `--use-deploy-images` 或 `HWLAB_G14_USE_DEPLOY_IMAGES=1` 启用。启用后 workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy.services[]` 的 per-service image/env;全局 GitOps metadata 仍记录本次 source commit。未启用时禁止读取旧 catalog/digest 去改变 rollout 行为。 +- 后续 Tekton 并发化只能以 planner 输出作为输入,先做到每个 changed service 一个构建任务;unchanged service 复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须回退当前全量 source commit render,而不是猜测复用。 + ## Code Agent Provider Profiles diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 04b72411..e7eedff0 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; +import { createG14CiPlan } from "./src/g14-ci-plan-lib.mjs"; import { BUILDABLE_IMPLEMENTATION_STATES, resolveDevArtifactServices, @@ -73,6 +74,7 @@ function parseArgs(argv) { baseImage: defaultBaseImage, reportPath: defaultReportPath, services: [...SERVICE_IDS], + servicesExplicit: false, emitReport: true, quietBuild: false, concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4) @@ -86,7 +88,10 @@ function parseArgs(argv) { else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg); else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg); else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg); - else if (arg === "--services") args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); + else if (arg === "--services") { + args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); + args.servicesExplicit = true; + } else if (arg === "--no-report") args.emitReport = false; else if (arg === "--quiet-build") args.quietBuild = true; else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4); @@ -1333,12 +1338,62 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, + componentCommitId: artifact.componentCommitId ?? null, + componentInputHash: artifact.componentInputHash ?? null, + dockerfileHash: artifact.dockerfileHash ?? null, + baseImageReference: artifact.baseImageReference ?? null, + baseImageDigest: artifact.baseImageDigest ?? null, + buildArgsHash: artifact.buildArgsHash ?? null, + ciAffected: artifact.ciAffected ?? null, + ciReason: artifact.ciReason ?? [], + reuse: artifact.reuse ?? null, notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked) }; }) }; } +function plannerFieldsFor(servicePlan) { + if (!servicePlan) return {}; + return { + componentCommitId: servicePlan.componentCommitId ?? null, + componentInputHash: servicePlan.componentInputHash ?? null, + dockerfileHash: servicePlan.dockerfileHash ?? null, + baseImageReference: servicePlan.baseImageReference ?? null, + baseImageDigest: servicePlan.baseImageDigest ?? null, + buildArgsHash: servicePlan.buildArgsHash ?? null, + ciAffected: servicePlan.affected ?? false, + ciReason: servicePlan.reason ?? [], + reuse: servicePlan.reuse ?? null + }; +} + +function enrichServicesWithCiPlan(services, ciPlan) { + const byServiceId = new Map((ciPlan?.services ?? []).map((service) => [service.serviceId, service])); + return services.map((service) => ({ + ...service, + ...plannerFieldsFor(byServiceId.get(service.serviceId)) + })); +} + +function ciPlanReportSummary(ciPlan) { + if (!ciPlan) return null; + return { + planVersion: ciPlan.planVersion, + sourceCommitId: ciPlan.sourceCommitId, + shortCommitId: ciPlan.shortCommitId, + baseRef: ciPlan.baseRef, + targetRef: ciPlan.targetRef, + compatibility: ciPlan.compatibility, + changedPathSummary: ciPlan.changedPathSummary, + imageBuildRequired: ciPlan.imageBuildRequired, + affectedServices: ciPlan.affectedServices, + reusedServices: ciPlan.reusedServices, + buildSkippedCount: ciPlan.buildSkippedCount, + ciCdPlan: ciPlan.ciCdPlan + }; +} + function sumDurations(artifacts, field) { return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0); } @@ -1357,7 +1412,7 @@ async function mapWithConcurrency(items, concurrency, worker) { return results; } -function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer }) { +function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }) { const status = reportStatus(mode, artifacts, blockers); const fatalBlocked = hasFatalBlocker(blockers); const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired); @@ -1453,6 +1508,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif environment: ENVIRONMENT_DEV, buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null, serviceInventory, + ciPlan: ciPlanReportSummary(ciPlan), publishPlan, serviceCount: artifacts.length, requiredServiceCount: serviceInventory.requiredServiceCount, @@ -1487,6 +1543,15 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, + componentCommitId: artifact.componentCommitId ?? null, + componentInputHash: artifact.componentInputHash ?? null, + dockerfileHash: artifact.dockerfileHash ?? null, + baseImageReference: artifact.baseImageReference ?? null, + baseImageDigest: artifact.baseImageDigest ?? null, + buildArgsHash: artifact.buildArgsHash ?? null, + ciAffected: artifact.ciAffected ?? null, + ciReason: artifact.ciReason ?? [], + reuse: artifact.reuse ?? null, dockerBuildLogTail: artifact.dockerBuildLogTail ?? null, logTail: artifact.logTail ?? null, pushLogTail: artifact.pushLogTail ?? null @@ -1543,7 +1608,14 @@ async function main() { const buildCreatedAt = new Date().toISOString(); const repo = repoLabelFromRemote(remoteUrl); const producer = g14ArtifactProducer(process.env); - const services = await resolveServices(args.services, catalog); + const ciPlan = await createG14CiPlan({ + repoRoot, + targetRef: "HEAD", + registryPrefix: args.registryPrefix, + baseImage: args.baseImage ?? undefined, + ...(args.servicesExplicit ? { services: args.services } : {}) + }); + const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan); let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }); let artifacts = services.map((service) => artifactRecord({ @@ -1590,7 +1662,7 @@ async function main() { artifacts = services.map((service) => artifactByServiceId.get(service.serviceId)); } - const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer }); + const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }); if (args.emitReport) { await writeReport(args.reportPath, report); } @@ -1604,6 +1676,7 @@ async function main() { registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), selectedServiceIds: report.artifactPublish.selectedServiceIds, + ciPlan: report.artifactPublish.ciPlan, concurrency: report.artifactPublish.concurrency, timings: report.artifactPublish.timings, publishPlan: report.artifactPublish.publishPlan, diff --git a/scripts/g14-ci-plan.mjs b/scripts/g14-ci-plan.mjs new file mode 100644 index 00000000..acaf0ca1 --- /dev/null +++ b/scripts/g14-ci-plan.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { createG14CiPlan } from "./src/g14-ci-plan-lib.mjs"; + +const args = parseArgs(process.argv.slice(2)); + +if (args.help) { + process.stdout.write(`${usage()}\n`); + process.exit(0); +} + +const plan = await createG14CiPlan(args); +process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`); + +function parseArgs(argv) { + const parsed = { pretty: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--base-ref") parsed.baseRef = readOption(argv, ++index, arg); + else if (arg === "--target-ref") parsed.targetRef = readOption(argv, ++index, arg); + else if (arg === "--ci-json") parsed.ciJsonPath = readOption(argv, ++index, arg); + else if (arg === "--deploy-json") parsed.deployJsonPath = readOption(argv, ++index, arg); + else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg); + else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg); + else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg); + else if (arg === "--services") parsed.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); + else if (arg === "--pretty") parsed.pretty = true; + else if (arg === "--help" || arg === "-h") parsed.help = true; + else throw new Error(`unknown argument ${arg}`); + } + return parsed; +} + +function readOption(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function usage() { + return [ + "usage: node scripts/g14-ci-plan.mjs [--base-ref REF] [--target-ref REF] [--pretty]", + "", + "Plan G14 monorepo image builds without mutating CI/CD state.", + "The planner reads current CI.json and deploy/deploy.json by default and remains compatible with legacy full-build render.", + "", + "examples:", + " node scripts/g14-ci-plan.mjs --base-ref HEAD~1 --target-ref HEAD --pretty", + " node scripts/g14-ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty" + ].join("\n"); +} diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs new file mode 100644 index 00000000..8627df26 --- /dev/null +++ b/scripts/g14-ci-plan.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +import { + classifyGlobalChange, + componentModelsForServices, + createG14CiPlan, + matchingPaths +} from "./src/g14-ci-plan-lib.mjs"; + +const execFileAsync = promisify(execFile); + +test("G14 CI planner maps bridge changes to cloud-api only", async () => { + const repo = await createFixtureRepo(); + await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.mjs"), "console.log('bridge v2');\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change bridge"]); + + const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); + assert.equal(plan.reusedServices.includes("hwlab-cloud-web"), true); + const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); + assert.equal(cloudApi.affected, true); + assert.deepEqual(cloudApi.reason, ["component-path-changed"]); +}); + +test("G14 CI planner treats docs-only changes as no image build", async () => { + const repo = await createFixtureRepo(); + await mkdir(path.join(repo, "docs/reference"), { recursive: true }); + await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs v2\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change docs"]); + + const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); + assert.deepEqual(plan.affectedServices, []); + assert.equal(plan.imageBuildRequired, false); + assert.equal(plan.changedPathSummary.docsOnly, true); +}); + +test("component model supports CI.json overrides without replacing deploy.json", () => { + const models = componentModelsForServices(["hwlab-cloud-api"], { + g14CiPlan: { + components: { + "hwlab-cloud-api": { + componentPaths: ["custom/api/"], + sharedPaths: ["shared/runtime.mjs"], + runtimeDeps: ["package.json"] + } + } + } + }); + assert.deepEqual(models[0].componentPaths, ["custom/api/"]); + assert.deepEqual(matchingPaths(["custom/api/main.mjs"], models[0].componentPaths), ["custom/api/main.mjs"]); +}); + +test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => { + const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true }); + await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.mjs"), "console.log('bridge v2');\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change bridge"]); + + const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" }); + assert.equal(plan.compatibility.currentRenderCompatible, true); + assert.equal(plan.compatibility.serviceIdSource, "deploy.k3s.serviceMappings"); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); + assert.equal(plan.inputs.serviceCount, 2); +}); + +test("global change classifier distinguishes gitops-only and test-only", () => { + assert.equal(classifyGlobalChange(["deploy/gitops/g14/runtime-dev/workloads.yaml"]).gitopsOnly, true); + assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/main.test.mjs"]).testOnly, true); +}); + +async function createFixtureRepo(options = {}) { + const deployServices = options.deployServices !== false; + const k3sServiceMappings = options.k3sServiceMappings === true; + const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-g14-ci-plan-")); + await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true }); + await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true }); + await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true }); + await mkdir(path.join(repo, "deploy"), { recursive: true }); + await writeFile(path.join(repo, "CI.json"), JSON.stringify({ version: 1, commands: [] }, null, 2)); + await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings }), 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.mjs"), "console.log('api');\n"); + await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.mjs"), "console.log('bridge');\n"); + await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.mjs"), "console.log('test');\n"); + await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "\n"); + await git(repo, ["init"]); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "initial"]); + return repo; +} + +function createDeployFixture({ deployServices, k3sServiceMappings }) { + const deploy = {}; + if (deployServices) { + deploy.services = [ + { serviceId: "hwlab-cloud-api" }, + { serviceId: "hwlab-cloud-web" } + ]; + } + if (k3sServiceMappings) { + deploy.k3s = { + serviceMappings: [ + { serviceId: "hwlab-cloud-api", name: "hwlab-cloud-api" }, + { serviceId: "hwlab-cloud-web", name: "hwlab-cloud-web" } + ] + }; + } + return deploy; +} + +async function git(cwd, args) { + return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd }); +} diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 9ad8561b..14f7d924 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -56,6 +56,7 @@ function parseArgs(argv) { webEndpoint: defaultWebEndpoint, prodRuntimeEndpoint: defaultProdRuntimeEndpoint, prodWebEndpoint: defaultProdWebEndpoint, + useDeployImages: process.env.HWLAB_G14_USE_DEPLOY_IMAGES === "1", check: false, write: true, help: false @@ -72,6 +73,7 @@ function parseArgs(argv) { else if (arg === "--web-endpoint") args.webEndpoint = readOption(argv, ++index, arg); else if (arg === "--prod-runtime-endpoint") args.prodRuntimeEndpoint = readOption(argv, ++index, arg); else if (arg === "--prod-web-endpoint") args.prodWebEndpoint = readOption(argv, ++index, arg); + else if (arg === "--use-deploy-images") args.useDeployImages = true; else if (arg === "--check") { args.check = true; args.write = false; @@ -105,6 +107,7 @@ function usage() { ` --web-endpoint URL default: ${defaultWebEndpoint}`, ` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`, ` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`, + " --use-deploy-images render workload images/env from deploy/deploy.json per service; default keeps legacy source revision tags", " --check verify generated files are current without writing" ].join("\n"); } @@ -195,6 +198,22 @@ function imageFor(registryPrefix, serviceId, shortCommit) { return `${registryPrefix}/${serviceId}:${shortCommit}`; } +function imageTagFromReference(image) { + const value = String(image ?? ""); + const slashIndex = value.lastIndexOf("/"); + const colonIndex = value.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return value.slice(colonIndex + 1).split("@", 1)[0] || null; +} + +function runtimeCommitForDeployService(deployService, source) { + return deployService?.env?.HWLAB_COMMIT_ID ?? deployService?.commitId ?? imageTagFromReference(deployService?.image) ?? source.full; +} + +function runtimeImageTagForDeployService(deployService, source) { + return deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(deployService?.image) ?? source.short; +} + function namespaceNameForProfile(profile) { return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; } @@ -222,7 +241,7 @@ function namespaceScopedHost(value, namespace) { return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); } -function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev" }) { +function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false }) { const result = cloneJson(workloads); const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); const namespace = namespaceNameForProfile(profile); @@ -255,10 +274,15 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime const podTemplate = item?.spec?.template; if (!podTemplate?.spec?.containers) continue; + const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]); + const templateDeployService = deployServices.get(templateServiceId); + const templateSourceCommit = useDeployImages + ? runtimeCommitForDeployService(templateDeployService, source) + : source.full; label(podTemplate.metadata ??= {}, { ...stableRuntimeLabels, "hwlab.pikastech.local/gitops-target": "g14", - "hwlab.pikastech.local/source-commit": source.full + "hwlab.pikastech.local/source-commit": templateSourceCommit }); if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) { item.spec.selector.matchLabels = { @@ -267,13 +291,18 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime }; } annotate(podTemplate.metadata, { - "hwlab.pikastech.local/source-commit": source.full, + "hwlab.pikastech.local/source-commit": templateSourceCommit, "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" }); for (const container of podTemplate.spec.containers) { const serviceId = serviceIdForWorkload(item, container); - if (!deployServices.has(serviceId)) continue; - const image = imageFor(registryPrefix, serviceId, source.short); + const deployService = deployServices.get(serviceId); + if (!deployService) continue; + const image = useDeployImages && deployService.image + ? deployService.image + : imageFor(registryPrefix, serviceId, source.short); + const runtimeCommit = useDeployImages ? runtimeCommitForDeployService(deployService, source) : source.full; + const runtimeImageTag = useDeployImages ? runtimeImageTagForDeployService(deployService, source) : source.short; container.image = image; container.imagePullPolicy = "IfNotPresent"; container.env ??= []; @@ -281,9 +310,9 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); } upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); - upsertEnv(container.env, "HWLAB_COMMIT_ID", source.full); + upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); upsertEnv(container.env, "HWLAB_IMAGE", image); - upsertEnv(container.env, "HWLAB_IMAGE_TAG", source.short); + upsertEnv(container.env, "HWLAB_IMAGE_TAG", runtimeImageTag); upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14"); upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel); upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full); @@ -1664,7 +1693,7 @@ async function plannedFiles(args) { const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value)); const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value)); - putJson("source.json", { + const sourceDescriptor = { kind: "hwlab-g14-gitops-source", sourceCommit: source.full, sourceShortCommit: source.short, @@ -1684,7 +1713,14 @@ async function plannedFiles(args) { }, tektonPipeline: "hwlab-ci/hwlab-g14-ci-image-publish", argoApplications: ["argocd/hwlab-g14-dev", "argocd/hwlab-g14-prod"] - }); + }; + if (args.useDeployImages) { + sourceDescriptor.renderMode = { + imageSource: "deploy.services", + mixedDesiredState: true + }; + } + putJson("source.json", sourceDescriptor); putText("README.md", readme({ source, args })); putJson("registry/registry.yaml", registryManifest()); putJson("tekton/rbac.yaml", tektonRbac()); @@ -1711,7 +1747,7 @@ async function plannedFiles(args) { putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); putJson(`${runtimePath}/services.yaml`, transformListNamespace(services, namespace, profileLabels, annotations)); putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile)); - putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile })); + putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix })); putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile })); diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index 477e4c74..92bf1c2a 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -187,7 +187,16 @@ function publishRecordsFromReport(report, target) { buildCreatedAt: service.buildCreatedAt ?? null, buildSource: service.buildSource ?? null, digest: service.digest, - repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}` + repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`, + componentCommitId: service.componentCommitId ?? null, + componentInputHash: service.componentInputHash ?? null, + dockerfileHash: service.dockerfileHash ?? null, + baseImageReference: service.baseImageReference ?? null, + baseImageDigest: service.baseImageDigest ?? null, + buildArgsHash: service.buildArgsHash ?? null, + ciAffected: service.ciAffected ?? null, + ciReason: service.ciReason ?? [], + reuse: service.reuse ?? null }); } @@ -277,6 +286,15 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, catalogService.buildCreatedAt = publishRecord?.buildCreatedAt ?? null; catalogService.buildSource = publishRecord?.buildSource ?? null; catalogService.digest = publishRecord?.digest ?? "not_published"; + catalogService.componentCommitId = publishRecord?.componentCommitId ?? catalogService.componentCommitId ?? null; + catalogService.componentInputHash = publishRecord?.componentInputHash ?? catalogService.componentInputHash ?? null; + catalogService.dockerfileHash = publishRecord?.dockerfileHash ?? catalogService.dockerfileHash ?? null; + catalogService.baseImageReference = publishRecord?.baseImageReference ?? catalogService.baseImageReference ?? null; + catalogService.baseImageDigest = publishRecord?.baseImageDigest ?? catalogService.baseImageDigest ?? null; + catalogService.buildArgsHash = publishRecord?.buildArgsHash ?? catalogService.buildArgsHash ?? null; + catalogService.ciAffected = publishRecord?.ciAffected ?? catalogService.ciAffected ?? null; + catalogService.ciReason = publishRecord?.ciReason ?? catalogService.ciReason ?? []; + catalogService.reuse = publishRecord?.reuse ?? catalogService.reuse ?? null; catalogService.publishState = publishRecord ? "published" : "skeleton-only"; catalogService.publishEnabled = inventory?.publishEnabled ?? required; catalogService.artifactRequired = required; @@ -290,6 +308,11 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, imageTag, buildCreatedAt: catalogService.buildCreatedAt, digest: catalogService.digest, + componentCommitId: catalogService.componentCommitId, + componentInputHash: catalogService.componentInputHash, + dockerfileHash: catalogService.dockerfileHash, + ciAffected: catalogService.ciAffected, + ciReason: catalogService.ciReason, publishState: catalogService.publishState, artifactRequired: catalogService.artifactRequired, notPublishedReason: catalogService.notPublishedReason diff --git a/scripts/refresh-artifact-catalog.test.mjs b/scripts/refresh-artifact-catalog.test.mjs index f4f1cde4..656e876f 100644 --- a/scripts/refresh-artifact-catalog.test.mjs +++ b/scripts/refresh-artifact-catalog.test.mjs @@ -61,7 +61,13 @@ test("refresh accepts an absolute publish report path", async () => { digest: digestFor(index), repositoryDigest: `127.0.0.1:5000/hwlab/${serviceId}@${digestFor(index)}`, buildCreatedAt: "2026-05-24T00:00:00.000Z", - buildSource: `pikasTech/HWLAB@${commitId}` + buildSource: `pikasTech/HWLAB@${commitId}`, + componentCommitId: commitId, + componentInputHash: `${String(index + 1).padStart(64, "a")}`, + dockerfileHash: `${String(index + 1).padStart(64, "b")}`, + buildArgsHash: `${String(index + 1).padStart(64, "c")}`, + ciAffected: index === 0, + ciReason: index === 0 ? ["component-path-changed"] : ["component-inputs-unchanged"] })) } }, null, 2)}\n`); @@ -82,4 +88,7 @@ test("refresh accepts an absolute publish report path", async () => { assert.equal(payload.status, "published"); assert.equal(payload.publishedCount, SERVICE_IDS.length); assert.deepEqual(payload.wrote, []); + assert.equal(payload.services[0].componentCommitId, commitId); + assert.equal(payload.services[0].ciAffected, true); + assert.equal(payload.services[0].componentInputHash, `${String(1).padStart(64, "a")}`); }); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs new file mode 100644 index 00000000..9690b6a5 --- /dev/null +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -0,0 +1,396 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { SERVICE_IDS } from "../../internal/protocol/index.mjs"; + +const execFileAsync = promisify(execFile); + +export const G14_CI_PLAN_VERSION = "v1"; + +export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([ + "scripts/artifact-publish.mjs", + "scripts/g14-artifact-publish.mjs", + "scripts/src/dev-artifact-services.mjs" +]); + +export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([ + "package.json", + "package-lock.json" +]); + +export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([ + "internal/protocol/", + "internal/build-metadata.mjs" +]); + +export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([ + "docs/", + "README.md", + "AGENTS.md" +]); + +export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([ + "deploy/gitops/", + "deploy/frp/", + "scripts/g14-gitops-render.mjs" +]); + +const serviceSpecificPaths = Object.freeze({ + "hwlab-cloud-api": [ + "cmd/hwlab-cloud-api/", + "cmd/hwlab-deepseek-responses-bridge/", + "internal/cloud/", + "internal/db/", + "internal/audit/", + "tools/hwlab-gateway-shell.mjs", + "skills/" + ], + "hwlab-cloud-web": ["web/hwlab-cloud-web/"], + "hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"], + "hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"], + "hwlab-gateway": ["cmd/hwlab-gateway/"], + "hwlab-gateway-simu": ["cmd/hwlab-gateway-simu/", "internal/sim/"], + "hwlab-box-simu": ["cmd/hwlab-box-simu/", "internal/sim/"], + "hwlab-patch-panel": ["cmd/hwlab-patch-panel/", "internal/patchpanel/"], + "hwlab-router": ["cmd/hwlab-router/"], + "hwlab-tunnel-client": ["cmd/hwlab-tunnel-client/"], + "hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/"], + "hwlab-cli": ["tools/hwlab-cli/"], + "hwlab-agent-skills": ["skills/"] +}); + +export async function createG14CiPlan(options = {}) { + const repoRoot = options.repoRoot ?? process.cwd(); + const ciJsonPath = options.ciJsonPath ?? "CI.json"; + const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json"; + const artifactCatalogPath = options.artifactCatalogPath ?? "deploy/artifact-catalog.dev.json"; + const targetRef = options.targetRef ?? "HEAD"; + const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef); + const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab"; + const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; + + const [ciJson, deployJson, artifactCatalog] = await Promise.all([ + readJsonIfPresent(repoRoot, ciJsonPath, {}), + readJsonIfPresent(repoRoot, deployJsonPath, {}), + readJsonIfPresent(repoRoot, artifactCatalogPath, null) + ]); + const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]); + const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]); + const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef); + const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean); + const serviceIdResolution = resolveServiceIds({ deployJson, options }); + const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, ciJson); + const globalChange = classifyGlobalChange(normalizedChangedPaths); + const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [ + ...DEFAULT_BUILD_SYSTEM_PATHS, + ...DEFAULT_RUNTIME_DEP_PATHS + ]); + const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service])); + + const services = []; + for (const model of componentModels) { + const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths); + const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths); + const runtimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps); + const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths); + const imageRelevantChangedPaths = uniqueSorted([ + ...directMatches, + ...sharedMatches, + ...runtimeDepMatches, + ...buildSystemMatches + ].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))); + const affected = imageRelevantChangedPaths.length > 0; + const componentInputPaths = uniqueSorted([ + ...model.componentPaths, + ...model.sharedPaths, + ...model.runtimeDeps, + ...model.buildSystemPaths + ]); + const componentCommitId = await lastCommitForPaths(repoRoot, targetRef, componentInputPaths); + const componentInputHash = await hashGitPaths(repoRoot, targetRef, componentInputPaths, { + skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath) + }); + const buildArgsHash = stableHash({ + serviceId: model.serviceId, + baseImage, + registryPrefix, + runtimeKind: model.runtimeKind, + entrypoint: model.entrypoint + }); + const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null; + services.push({ + serviceId: model.serviceId, + runtimeKind: model.runtimeKind, + entrypoint: model.entrypoint, + componentPaths: model.componentPaths, + sharedPaths: model.sharedPaths, + runtimeDeps: model.runtimeDeps, + buildSystemPaths: model.buildSystemPaths, + componentCommitId, + componentInputHash, + dockerfileHash, + baseImageReference: baseImage, + baseImageDigest: digestFromImageReference(baseImage), + buildArgsHash, + changedPaths: imageRelevantChangedPaths, + affected, + reason: affected ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches }) : reasonForUnchanged(globalChange), + reuse: reuseCandidate(catalogRecord, affected) + }); + } + + const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId); + const reusedServices = services.filter((service) => !service.affected).map((service) => service.serviceId); + return { + planVersion: G14_CI_PLAN_VERSION, + sourceCommitId, + shortCommitId, + baseRef, + targetRef, + compatibility: { + ciJson: ciJsonPath, + deployJson: deployJsonPath, + artifactCatalog: artifactCatalog ? artifactCatalogPath : null, + mode: "advisory-read-only", + currentRenderCompatible: true, + plannerMutatesCiJson: false, + plannerMutatesDeployJson: false, + serviceIdSource: serviceIdResolution.source, + legacyFullBuildFallback: true + }, + inputs: { + registryPrefix, + baseImage, + serviceCount: services.length + }, + changedPaths: normalizedChangedPaths, + changedPathSummary: globalChange, + imageBuildRequired: affectedServices.length > 0, + affectedServices, + reusedServices, + buildSkippedCount: reusedServices.length, + services, + ciCdPlan: { + willBuild: affectedServices, + willReuse: reusedServices, + willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0, + noImageBuildReason: affectedServices.length === 0 ? globalChange.summary : null + } + }; +} + +export function componentModelsForServices(serviceIds, ciJson = {}) { + const overrides = ciJson?.g14CiPlan?.components ?? {}; + return serviceIds.map((serviceId) => { + const override = overrides[serviceId] ?? {}; + return { + serviceId, + runtimeKind: override.runtimeKind ?? runtimeKindForService(serviceId), + entrypoint: override.entrypoint ?? entrypointForService(serviceId), + componentPaths: uniqueSorted((override.componentPaths ?? serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).map(normalizeRepoPath)), + sharedPaths: uniqueSorted((override.sharedPaths ?? DEFAULT_SHARED_RUNTIME_PATHS).map(normalizeRepoPath)), + runtimeDeps: uniqueSorted((override.runtimeDeps ?? DEFAULT_RUNTIME_DEP_PATHS).map(normalizeRepoPath)), + buildSystemPaths: uniqueSorted((override.buildSystemPaths ?? DEFAULT_BUILD_SYSTEM_PATHS).map(normalizeRepoPath)) + }; + }); +} + +export function classifyGlobalChange(changedPaths) { + const relevant = changedPaths.filter(Boolean); + const testOnly = relevant.length > 0 && relevant.every(isTestOnlyPath); + const docsOnly = relevant.length > 0 && relevant.every((item) => isDocsOnlyPath(item) || isTestOnlyPath(item)); + const gitopsOnly = relevant.length > 0 && relevant.every((item) => isGitOpsOnlyPath(item) || isDocsOnlyPath(item) || isTestOnlyPath(item)); + return { + count: relevant.length, + testOnly, + docsOnly, + gitopsOnly, + summary: relevant.length === 0 + ? "no-source-diff" + : testOnly + ? "test-only-change" + : docsOnly + ? "docs-only-change" + : gitopsOnly + ? "gitops-only-change" + : "runtime-or-build-change" + }; +} + +export function matchingPaths(changedPaths, patterns) { + return uniqueSorted(changedPaths.filter((filePath) => patterns.some((pattern) => pathMatches(pattern, filePath)))); +} + +export function normalizeRepoPath(value) { + return String(value ?? "") + .replaceAll("\\", "/") + .replace(/^\.\//u, "") + .replace(/^\/+/, "") + .trim(); +} + +export function pathMatches(pattern, filePath) { + const normalizedPattern = normalizeRepoPath(pattern); + const normalizedFilePath = normalizeRepoPath(filePath); + if (!normalizedPattern || !normalizedFilePath) return false; + if (normalizedPattern.endsWith("/")) return normalizedFilePath.startsWith(normalizedPattern); + return normalizedFilePath === normalizedPattern; +} + +export function isTestOnlyPath(filePath) { + const normalized = normalizeRepoPath(filePath); + return normalized.endsWith(".test.mjs") || normalized.includes("/__tests__/") || normalized.includes("/fixtures/"); +} + +export function isDocsOnlyPath(filePath) { + const normalized = normalizeRepoPath(filePath); + return DEFAULT_DOCS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized)) || normalized.endsWith(".md"); +} + +export function isGitOpsOnlyPath(filePath) { + const normalized = normalizeRepoPath(filePath); + return DEFAULT_GITOPS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized)); +} + +export async function changedPathsBetween(repoRoot, baseRef, targetRef) { + const diff = await gitValue(repoRoot, ["diff", "--name-only", baseRef, targetRef]); + return diff.split("\n").map((line) => line.trim()).filter(Boolean); +} + +export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) { + const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean)); + if (normalizedPaths.length === 0) return stableHash({ empty: true }); + const output = await gitValue(repoRoot, ["ls-tree", "-r", targetRef, "--", ...normalizedPaths]).catch(() => ""); + const lines = output.split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => { + const filePath = line.slice(line.indexOf("\t") + 1); + return !options.skipPath?.(filePath); + }) + .sort(); + return stableHash({ targetRef, entries: lines }); +} + +export async function lastCommitForPaths(repoRoot, targetRef, paths) { + const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean)); + if (normalizedPaths.length === 0) return null; + const value = await gitValue(repoRoot, ["rev-list", "-1", targetRef, "--", ...normalizedPaths]).catch(() => ""); + return value.trim() || null; +} + +function resolveServiceIds({ deployJson, options }) { + if (Array.isArray(options.services) && options.services.length > 0) { + return { source: "cli-services", serviceIds: uniqueSorted(options.services.map(String)) }; + } + const deployServices = Array.isArray(deployJson?.services) ? deployJson.services.map((service) => service?.serviceId).filter(Boolean) : []; + if (deployServices.length > 0) return { source: "deploy.services", serviceIds: uniquePreserveOrder(deployServices) }; + const mappingServices = Array.isArray(deployJson?.k3s?.serviceMappings) + ? deployJson.k3s.serviceMappings.map((service) => service?.serviceId).filter(Boolean) + : []; + if (mappingServices.length > 0) return { source: "deploy.k3s.serviceMappings", serviceIds: uniquePreserveOrder(mappingServices) }; + return { source: "internal.protocol.SERVICE_IDS", serviceIds: [...SERVICE_IDS] }; +} + +async function readJsonIfPresent(repoRoot, relativePath, fallback) { + try { + return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return fallback; + throw error; + } +} + +async function defaultBaseRef(repoRoot, targetRef) { + const parent = await gitValue(repoRoot, ["rev-parse", `${targetRef}^`]).catch(() => ""); + return parent || targetRef; +} + +async function gitValue(repoRoot, args) { + const result = await execFileAsync("git", args, { + cwd: repoRoot, + maxBuffer: 8 * 1024 * 1024, + timeout: 15000 + }); + return result.stdout.trim(); +} + +function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches }) { + const reasons = []; + if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed"); + 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"); + return reasons.length ? reasons : ["affected"]; +} + +function reasonForUnchanged(globalChange) { + if (globalChange.summary === "docs-only-change") return ["docs-only-no-image-build"]; + if (globalChange.summary === "gitops-only-change") return ["gitops-only-no-image-build"]; + if (globalChange.summary === "test-only-change") return ["test-only-no-image-build"]; + if (globalChange.summary === "no-source-diff") return ["no-source-diff"]; + return ["component-inputs-unchanged"]; +} + +function reuseCandidate(catalogRecord, affected) { + if (affected) return { status: "not-reused", reason: "component-affected" }; + if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" }; + if (!/^sha256:[a-f0-9]{64}$/u.test(catalogRecord.digest ?? "")) { + return { status: "candidate-unverified-digest", image: catalogRecord.image ?? null, digest: catalogRecord.digest ?? null }; + } + return { + status: catalogRecord.componentInputHash ? "ready" : "candidate-without-component-metadata", + image: catalogRecord.image, + digest: catalogRecord.digest, + reusedFrom: catalogRecord.componentInputHash ? catalogRecord.componentInputHash : catalogRecord.commitId ?? catalogRecord.imageTag ?? null + }; +} + +function runtimeKindForService(serviceId) { + if (serviceId === "hwlab-cloud-web") return "cloud-web"; + if (serviceId === "hwlab-cli") return "cli"; + if (serviceId === "hwlab-agent-skills") return "skills-bundle"; + return "node-command"; +} + +function entrypointForService(serviceId) { + if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html"; + if (serviceId === "hwlab-cli") return "tools/hwlab-cli/bin/hwlab-cli.mjs"; + if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md"; + return `cmd/${serviceId}/main.mjs`; +} + +function digestFromImageReference(image) { + const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u); + return match ? match[1] : null; +} + +function stableHash(value) { + return createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function uniqueSorted(values) { + return [...new Set(values.filter(Boolean))].sort(); +} + +function uniquePreserveOrder(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +}