// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针 import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, 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, createCiPlan, envReuseRecipeForLane, matchingPaths, packageRuntimeFieldsChanged } from "./src/ci-plan-lib.mjs"; import { readStructuredFile, writeStructuredFile } from "./src/structured-config.mjs"; const execFileAsync = promisify(execFile); const V02_RUNTIME_SERVICE_IDS = Object.freeze([ "hwlab-cloud-api", "hwlab-cloud-web", "hwlab-gateway", "hwlab-edge-proxy", "hwlab-agent-skills" ]); test("node 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.ts"), "console.log('bridge v2');\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change bridge"]); const plan = await planV02CoreServices(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, ["code-input-changed", "component-path-changed"]); }); test("node 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/node-gitops-cicd.md"), "docs v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change docs"]); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, []); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.changedPathSummary.docsOnly, true); }); test("v03 planner keeps affected code rollouts out of catalog reuse", async () => { const selectedServices = ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-gateway"]; const repo = await createFixtureRepo({ serviceIds: selectedServices }); const baseRef = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); await mkdir(path.join(repo, "internal/cloud"), { recursive: true }); await mkdir(path.join(repo, "tools/src/hwlab-cli"), { recursive: true }); await writeFile(path.join(repo, "internal/cloud/kafka-event-bridge.ts"), "export const bridge = 2;\n"); await writeFile(path.join(repo, "tools/src/hwlab-cli/trace-renderer.ts"), "export const renderer = 2;\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change shared cloud and web source"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef, targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: selectedServices }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.deepEqual(plan.buildServices, []); assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.deepEqual(plan.reusedServices, ["hwlab-gateway"]); assert.equal(plan.serviceReusedCount, 1); assert.deepEqual(plan.imageBuildSkippedServices, selectedServices); assert.equal(plan.buildSkippedCount, 3); assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); assert.equal(plan.artifactCatalog.authority, "workspace-file"); assert.equal(plan.artifactCatalog.serviceCount, 3); }); test("tracked PaC remote pipelines pass selected services to catalog restore", async () => { const targets = [ { node: "NC01", pipelineName: "hwlab-nc01-v03-ci-image-publish", pipelinePath: "ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml", pacPath: ".tekton/hwlab-nc01-v03-pac.yaml" }, { node: "JD01", pipelineName: "hwlab-jd01-v03-ci-image-publish", pipelinePath: "ci/pipelines/hwlab-jd01-v03-ci-image-publish.yaml", pacPath: ".tekton/hwlab-jd01-v03-pac.yaml" } ]; for (const target of targets) { const pipeline = await readStructuredFile(process.cwd(), target.pipelinePath); assert.equal(pipeline.metadata?.name, target.pipelineName, `${target.node} tracked Pipeline name`); const planArtifacts = pipeline.spec?.tasks?.find((task) => task.name === "plan-artifacts"); assert.ok(planArtifacts, `${target.node} tracked Pipeline plan-artifacts task`); assert.ok(planArtifacts.taskSpec?.params?.some((param) => param.name === "services"), `${target.node} plan-artifacts services param`); assert.ok(planArtifacts.params?.some((param) => param.name === "services" && param.value === "$(params.services)"), `${target.node} plan-artifacts services binding`); const script = planArtifacts.taskSpec?.steps?.[0]?.script ?? ""; assert.match(script, /HWLAB_SERVICES="\$\(params\.services\)" node scripts\/ci\/restore-artifact-catalog\.mjs/u); assert.match(script, /rolloutWithoutImageBuildServices/u); assert.match(script, /artifactCatalog: plan\.artifactCatalog/u); const pac = await readStructuredFile(process.cwd(), target.pacPath); assert.equal(pac.metadata?.annotations?.["pipelinesascode.tekton.dev/pipeline"], target.pipelinePath, `${target.node} PaC remote Pipeline path`); assert.equal(pac.spec?.pipelineRef?.name, target.pipelineName, `${target.node} PaC PipelineRef`); } }); test("artifact catalog restore refreshes GitOps authority even when a source file exists", async () => { const repo = await createFixtureRepo(); const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim(); await git(repo, ["checkout", "-b", "fixture-gitops"]); const catalogPath = path.join(repo, "deploy/artifact-catalog.v03.json"); const catalog = JSON.parse(await readFile(catalogPath, "utf8")); catalog.commitId = "gitops-authoritative"; catalog.publish = { sourceCommitId: "a".repeat(40) }; await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "refresh gitops catalog"]); const gitopsCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); await git(repo, ["checkout", sourceBranch]); const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json", HWLAB_GITOPS_BRANCH: "fixture-gitops", HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web" } }); const restored = JSON.parse(await readFile(catalogPath, "utf8")); const authority = JSON.parse(await readFile(`${catalogPath}.authority.json`, "utf8")); assert.equal(restored.commitId, "gitops-authoritative"); assert.equal(authority.authority, "gitops-branch"); assert.equal(authority.gitopsCommitId, gitopsCommitId); assert.equal(authority.catalogSourceCommitId, "a".repeat(40)); assert.equal(authority.coverage.status, "exact"); assert.match(restore.stderr, /"status":"hydrated"/u); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.equal(plan.artifactCatalog.status, "hydrated"); assert.equal(plan.artifactCatalog.authority, "gitops-branch"); assert.equal(plan.artifactCatalog.gitopsCommitId, gitopsCommitId); assert.equal(plan.artifactCatalog.coverage.status, "exact"); assert.equal(plan.compatibility.artifactCatalogAuthority, "gitops-branch"); const fastPlan = await runCiPlanCli(repo, [ "--lane", "v03", "--base-ref", "HEAD", "--target-ref", "HEAD", "--artifact-catalog", "deploy/artifact-catalog.v03.json", "--services", "hwlab-cloud-api,hwlab-cloud-web" ]); assert.equal(fastPlan.compatibility.mode, "no-deps-global-change-fast-path"); assert.deepEqual(sortedServiceIds(fastPlan.reusedServices), ["hwlab-cloud-api", "hwlab-cloud-web"]); }); test("artifact catalog restore hydrates partial GitOps coverage and builds missing services", async () => { const repo = await createFixtureRepo(); const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim(); await git(repo, ["checkout", "-b", "fixture-gitops-mismatch"]); const catalogPath = path.join(repo, "deploy/artifact-catalog.v03.json"); const catalog = JSON.parse(await readFile(catalogPath, "utf8")); catalog.services = catalog.services.filter((service) => service.serviceId === "hwlab-cloud-api"); await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "remove a service from gitops catalog"]); await git(repo, ["checkout", sourceBranch]); const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json", HWLAB_GITOPS_BRANCH: "fixture-gitops-mismatch", HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web" } }); assert.match(restore.stderr, /"status":"partial"/u); assert.match(restore.stderr, /"missingSelectedServices":\["hwlab-cloud-web"\]/u); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.equal(plan.artifactCatalog.authority, "gitops-branch"); assert.equal(plan.artifactCatalog.coverage.status, "partial"); assert.deepEqual(plan.artifactCatalog.coverage.missingSelectedServices, ["hwlab-cloud-web"]); assert.deepEqual(sortedServiceIds(plan.buildServices), ["hwlab-cloud-web"]); const cliPlan = await runCiPlanCli(repo, [ "--lane", "v03", "--base-ref", "HEAD", "--target-ref", "HEAD", "--artifact-catalog", "deploy/artifact-catalog.v03.json", "--services", "hwlab-cloud-api,hwlab-cloud-web" ]); assert.equal(cliPlan.changedPathSummary.summary, "no-source-diff"); assert.equal(cliPlan.compatibility.mode, "advisory-read-only"); assert.deepEqual(cliPlan.artifactCatalog.coverage.missingSelectedServices, ["hwlab-cloud-web"]); assert.deepEqual(sortedServiceIds(cliPlan.buildServices), ["hwlab-cloud-web"]); }); test("v03 planner builds all services when the GitOps branch is absent", async () => { const repo = await createFixtureRepo({ catalog: false }); const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"]; const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json", HWLAB_GITOPS_BRANCH: "fixture-gitops-missing", HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: serviceIds.join(",") } }); assert.match(restore.stderr, /"status":"missing"/u); assert.match(restore.stderr, /"reason":"gitops-branch-missing"/u); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: serviceIds }); assert.equal(plan.artifactCatalog.authority, "none"); assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds)); }); test("ci-plan CLI bypasses no-deps reuse when the v03 artifact catalog is missing", async () => { const repo = await createFixtureRepo({ catalog: false }); const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"]; const commonArgs = [ "--lane", "v03", "--target-ref", "HEAD", "--artifact-catalog", "deploy/artifact-catalog.v03.json", "--services", serviceIds.join(",") ]; const initialPlan = await runCiPlanCli(repo, commonArgs); assert.equal(initialPlan.baseRef, "HEAD"); assert.equal(initialPlan.changedPathSummary.summary, "no-source-diff"); assert.equal(initialPlan.compatibility.mode, "advisory-read-only"); assert.equal(initialPlan.artifactCatalog.authority, "none"); assert.deepEqual(sortedServiceIds(initialPlan.buildServices), sortedServiceIds(serviceIds)); assert.deepEqual(initialPlan.reusedServices, []); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); await writeFile(path.join(repo, "docs/reference/bootstrap.md"), "bootstrap docs\n"); await git(repo, ["add", "docs/reference/bootstrap.md"]); await git(repo, ["commit", "-m", "add bootstrap docs"]); const docsPlan = await runCiPlanCli(repo, ["--base-ref", "HEAD~1", ...commonArgs]); assert.equal(docsPlan.changedPathSummary.docsOnly, true); assert.equal(docsPlan.compatibility.mode, "advisory-read-only"); assert.deepEqual(sortedServiceIds(docsPlan.buildServices), sortedServiceIds(serviceIds)); assert.deepEqual(docsPlan.reusedServices, []); }); test("v03 planner builds all services when the GitOps branch has no catalog", async () => { const repo = await createFixtureRepo({ catalog: false }); const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim(); const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"]; const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json", HWLAB_GITOPS_BRANCH: sourceBranch, HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: serviceIds.join(",") } }); assert.match(restore.stderr, /"status":"missing"/u); assert.match(restore.stderr, /"reason":"gitops-catalog-missing"/u); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: serviceIds }); assert.equal(plan.artifactCatalog.authority, "none"); assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds)); }); test("v02 planner accepts a current contract skeleton when the GitOps catalog is absent", async () => { const repo = await createFixtureRepo({ catalog: false }); const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim(); const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const catalogPath = path.join(repo, "deploy/artifact-catalog.v02.json"); const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"]; const skeleton = { catalogVersion: "v1", kind: "hwlab-artifact-catalog", environment: "v02", profile: "v02", namespace: "hwlab-v02", commitId: sourceCommitId, artifactState: "contract-skeleton", publish: { sourceCommitId }, services: serviceIds.map((serviceId) => ({ serviceId, sourceCommitId, digest: null, buildBackend: "contract-skeleton" })) }; await writeFile(catalogPath, `${JSON.stringify(skeleton, null, 2)}\n`); const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v02.json", HWLAB_GITOPS_BRANCH: sourceBranch, HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: serviceIds.join(",") } }); assert.match(restore.stderr, /"status":"bootstrap"/u); assert.match(restore.stderr, /"authority":"source-bootstrap"/u); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: serviceIds }); assert.equal(plan.artifactCatalog.status, "bootstrap"); assert.equal(plan.artifactCatalog.authority, "source-bootstrap"); assert.equal(plan.artifactCatalog.sourceCommitId, sourceCommitId); assert.equal(plan.artifactCatalog.coverage.status, "exact"); assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds)); }); test("artifact catalog restore rejects a non-bootstrap source catalog when GitOps is absent", async () => { const repo = await createFixtureRepo(); await assert.rejects( execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], { cwd: repo, env: { ...process.env, HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json", HWLAB_GITOPS_BRANCH: "fixture-gitops-missing", HWLAB_GIT_READ_URL: repo, HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web" } }), (error) => { assert.equal(error.code, 1); assert.match(error.stderr, /existing source catalog is not an allowed v02 contract skeleton/u); return true; } ); }); test("node CI planner rolls service runtime config changes without rebuilding image", async () => { const repo = await createFixtureRepo(); const deployPath = path.join(repo, "deploy/deploy.yaml"); const deploy = await readStructuredFile(repo, deployPath); deploy.lanes.v02.serviceDeclarations["hwlab-cloud-api"].env.HWLAB_METRICS_NAMESPACE = "hwlab-v02"; await writeStructuredFile(repo, deployPath, deploy); await git(repo, ["add", "deploy/deploy.yaml"]); await git(repo, ["commit", "-m", "change cloud api runtime env"]); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.runtimeConfigChanged, true); assert.equal(cloudApi.envChanged, false); assert.deepEqual(cloudApi.reason, ["runtime-config-changed"]); }); test("planner rebuilds when catalog digest is missing instead of blocking reuse", async () => { const repo = await createFixtureRepo({ catalog: "missing-digest" }); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); await writeFile(path.join(repo, "docs/reference/node-gitops-cicd.md"), "docs v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change docs"]); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.equal(plan.imageBuildRequired, true); assert.equal(plan.services.every((service) => service.envChanged === true), true); assert.equal(plan.services.every((service) => service.reuse.status === "not-reused"), true); }); test("v02 planner rolls cloud-api service code changes without rebuilding service image", async () => { const repo = await createFixtureRepo(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { sourceCommitId: initialPlan.sourceCommitId, environmentInputHashes: { "hwlab-cloud-api": initialCloudApi.environmentInputHash }, codeInputHashes: { "hwlab-cloud-api": initialCloudApi.codeInputHash }, bootCommits: { "hwlab-cloud-api": initialPlan.sourceCommitId } }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed catalog with current cloud-api env reuse hashes"]); await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]); await git(repo, ["commit", "-m", "change cloud-api service code"]); const plan = await createCiPlan({ 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.runtimeMode, "env-reuse-git-mirror-checkout"); assert.equal(cloudApi.envChanged, false); assert.equal(cloudApi.codeChanged, true); assert.equal(cloudApi.buildRequired, false); assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]); assert.deepEqual(plan.buildServices, []); assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api"]); assert.deepEqual(plan.reusedServices, []); }); test("v02 planner rebuilds env image when an env reuse catalog digest is missing", async () => { const repo = await createFixtureRepo({ catalog: "missing-digest" }); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); await writeFile(path.join(repo, "docs/reference/node-gitops-cicd.md"), "docs only with missing env digest\n"); await git(repo, ["add", "docs/reference/node-gitops-cicd.md"]); await git(repo, ["commit", "-m", "docs after missing env digest"]); const plan = await createCiPlan({ 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.envChanged, true); assert.equal(cloudApi.reuse.status, "not-reused"); assert.deepEqual(cloudApi.reason, ["environment-input-changed"]); }); test("v03 planner rebuilds env image when catalog env input hash is stale and source commit is unavailable", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: "0".repeat(40) })); catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input"; await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed stale v03 env catalog"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.buildRequired, true); assert.equal(cloudApi.environmentInputChanged, true); assert.deepEqual(cloudApi.reason, ["environment-input-mismatch"]); assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); }); test("v03 planner reuses catalog env image when hash metadata drifts but env source tree is unchanged", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })); const catalogCloudApi = catalog.services.find((service) => service.serviceId === "hwlab-cloud-api"); catalogCloudApi.environmentInputHash = "stale-environment-input"; await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed drifted v03 env catalog"]); await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]); await git(repo, ["commit", "-m", "change cloud-api code only"]); const requests = []; const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"], verifyReuseRegistry: true, reuseRegistryProbe: async (request) => { requests.push(request); if (request.image === catalogCloudApi.environmentImage && request.digest === catalogCloudApi.environmentDigest) { return { status: "present", method: "HEAD", statusCode: 200, digest: catalogCloudApi.environmentDigest }; } return { status: "missing", method: "HEAD", statusCode: 404 }; } }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.environmentInputChanged, true); assert.equal(cloudApi.effectiveEnvironmentInputChanged, false); assert.equal(cloudApi.environmentMetadataHashDrift, true); assert.equal(cloudApi.environmentSourceUnchanged, true); assert.equal(cloudApi.envChanged, false); assert.equal(cloudApi.codeChanged, true); assert.equal(cloudApi.affected, true); assert.equal(cloudApi.buildRequired, false); assert.equal(cloudApi.environmentImage, catalogCloudApi.environmentImage); assert.equal(cloudApi.environmentDigest, catalogCloudApi.environmentDigest); assert.equal(cloudApi.reuseRegistry.reuseSource, "catalog-metadata-hash-drift"); assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]); assert.deepEqual(plan.buildServices, []); assert.equal(requests.length, 2); assert.match(requests[0].image, /hwlab-cloud-api-env:env-/u); assert.equal(requests[1].image, catalogCloudApi.environmentImage); }); test("v03 planner reuses current env hash tag when registry already has it", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })); catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input"; await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed stale v03 env catalog"]); const digest = `sha256:${"2".repeat(64)}`; let probeRequest = null; const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"], verifyReuseRegistry: true, reuseRegistryProbe: async (request) => { probeRequest = request; return { status: "present", method: "HEAD", statusCode: 200, digest }; } }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.environmentInputChanged, true); assert.equal(cloudApi.envChanged, false); assert.equal(cloudApi.buildRequired, false); assert.equal(cloudApi.environmentDigest, digest); assert.equal(cloudApi.reuse.status, "ready"); assert.equal(cloudApi.reuse.reusedFrom, cloudApi.environmentInputHash); assert.equal(probeRequest.digest, null); assert.equal(probeRequest.image, `127.0.0.1:5000/hwlab/hwlab-cloud-api-env:env-${cloudApi.environmentInputHash.slice(0, 12)}`); assert.deepEqual(plan.buildServices, []); }); test("v03 planner rebuilds when hash metadata drifts and registry lacks reusable env image", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })); catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input"; await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed stale v03 env catalog"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"], verifyReuseRegistry: true, reuseRegistryProbe: async () => ({ status: "missing", method: "HEAD", statusCode: 404 }) }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.environmentInputChanged, true); assert.equal(cloudApi.envChanged, true); assert.equal(cloudApi.buildRequired, true); assert.equal(cloudApi.reuseRegistry.status, "missing"); assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); }); test("v03 planner rebuilds env image when target registry lacks reused digest", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed fresh v03 catalog"]); await mkdir(path.join(repo, "deploy/gitops/node/d601/runtime-v03"), { recursive: true }); await writeFile(path.join(repo, "deploy/gitops/node/d601/runtime-v03/workloads.yaml"), "kind: List\nitems: []\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change generated gitops after fresh catalog"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"], verifyReuseRegistry: true, reuseRegistryProbe: async () => ({ status: "missing", method: "HEAD", statusCode: 404 }) }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.buildRequired, true); assert.equal(cloudApi.reuseRegistry.status, "missing"); assert.deepEqual(cloudApi.reason, ["reuse-registry-missing"]); assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); }); 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, /downloadDependencyScript/u); assert.match(artifactPublish, /RUN \$\{downloadEnvPrefix\}\$\{downloadDependencyScript\}/u); assert.match(artifactPublish, /goLauncherPath = "deploy\/runtime\/launcher\/hwlab-go-env-reuse-launcher\.mjs"/u); assert.match(artifactPublish, /goCacheEnabled \? \[\] : \["COPY package\.json \.\/package\.json", "COPY package-lock\.json\* \.\/"\]/u); assert.match(artifactPublish, /goCacheEnabled \? \[\] : \[`RUN \$\{downloadEnvPrefix\}\$\{downloadDependencyScript\}`\]/u); assert.match(artifactPublish, /CMD \[\\"node\\", \\"\/usr\/local\/bin\/hwlab-go-env-reuse-launcher\.mjs\\"\]/u); assert.match(artifactPublish, /ln -sf "\$\{runtimeRoot\}\/\$bun_bin" \/usr\/local\/bin\/bun/u); assert.doesNotMatch(artifactPublish, /RUN \$\{dependencyScript\}/u); assert.match(artifactPublish, /const catalogProvenance = serviceImageCatalogProvenance\(catalogService\);/u); assert.match(artifactPublish, /\.\.\.catalogProvenance,[\s\S]{0,80}buildBackend: "reused-catalog"/u); assert.match(artifactPublish, /envReuseRecipe: servicePlan\.envReuseRecipe \?\? null/u); assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u); const perServicePublish = await readFile("scripts/src/gitops-render/templates/per-service-publish.sh", "utf8"); assert.match(perServicePublish, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u); assert.doesNotMatch(perServicePublish, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u); }); test("artifact publish ignores stale external reports for envreuse services that do not need current build artifacts", async () => { const currentCommit = (await git(process.cwd(), ["rev-parse", "HEAD"])).stdout.trim(); const oldCommit = "0".repeat(40); const digest = `sha256:${"3".repeat(64)}`; const tmp = await mkdtemp(path.join(os.tmpdir(), "hwlab-artifact-stale-report-")); const reportDir = path.join(tmp, "service-results"); await mkdir(reportDir, { recursive: true }); const image = "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-test"; const deployPath = path.join(tmp, "deploy.yaml"); const catalogPath = path.join(tmp, "artifact-catalog.v03.json"); const ciPlanPath = path.join(tmp, "ci-plan.json"); const deploy = createDeployFixture({ serviceIds: ["hwlab-cloud-web"] }); deploy.lanes.v03.namespace = "hwlab-v03"; deploy.lanes.v03.endpoint = "https://hwlab.pikapython.com"; await writeStructuredFile(process.cwd(), deployPath, deploy); const catalog = createCatalogFixture("ready", { serviceIds: ["hwlab-cloud-web"], sourceCommitId: currentCommit, bootCommits: { "hwlab-cloud-web": currentCommit }, environmentInputHashes: { "hwlab-cloud-web": "e".repeat(64) }, codeInputHashes: { "hwlab-cloud-web": "c".repeat(64) } }); catalog.environment = "v03"; catalog.profile = "v03"; catalog.namespace = "hwlab-v03"; catalog.allowedProfiles = ["v03"]; catalog.forbiddenProfiles = ["dev", "prod"]; for (const service of catalog.services) { service.profile = "v03"; service.namespace = "hwlab-v03"; } await writeFile(catalogPath, JSON.stringify(catalog, null, 2)); await writeFile(ciPlanPath, JSON.stringify({ planVersion: "v1", sourceCommitId: currentCommit, shortCommitId: currentCommit.slice(0, 12), baseRef: "HEAD~1", targetRef: "HEAD", compatibility: { lane: "v03" }, changedPathSummary: {}, imageBuildRequired: false, affectedServices: [], buildServices: ["hwlab-cloud-web"], rolloutServices: [], reusedServices: ["hwlab-cloud-web"], buildSkippedCount: 1, ciCdPlan: {}, services: [{ serviceId: "hwlab-cloud-web", runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, affected: false, buildRequired: false, envChanged: false, codeChanged: false, environmentImage: image, environmentDigest: digest, environmentInputHash: "e".repeat(64), codeInputHash: "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: currentCommit, bootSh: "deploy/runtime/boot/hwlab-cloud-web.sh", reason: ["component-inputs-unchanged"], reuse: { status: "ready", image, digest, reusedFrom: "e".repeat(64) } }] }, null, 2)); await writeFile(path.join(reportDir, "hwlab-cloud-web.json"), JSON.stringify({ artifactPublish: { services: [{ serviceId: "hwlab-cloud-web", status: "reused", commitId: oldCommit, sourceCommitId: oldCommit, image: "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-old", imageTag: "env-old", digest, repositoryDigest: `127.0.0.1:5000/hwlab/hwlab-cloud-web-env@${digest}`, runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true }] } }, null, 2)); const { stdout } = await execFileAsync(process.execPath, [ "scripts/artifact-publish.mjs", "--build", "--lane", "v03", "--services", "hwlab-cloud-web", "--deploy-config", deployPath, "--catalog-path", catalogPath, "--ci-plan-path", ciPlanPath, "--external-service-report-dir", reportDir, "--registry-prefix", "127.0.0.1:5000/hwlab", "--no-report" ], { cwd: process.cwd(), env: { ...process.env, HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY: "false" } }); const result = JSON.parse(stdout); const blockerText = JSON.stringify(result.blockers ?? []); assert.equal(blockerText.includes(oldCommit), false); assert.equal(result.status, "built"); assert.equal(result.services[0].serviceId, "hwlab-cloud-web"); assert.equal(result.services[0].status, "reused"); assert.equal(result.services[0].sourceCommitId, currentCommit); }); test("artifact publish accepts stale reused envreuse external reports for unchanged services in buildServices", async () => { const currentCommit = (await git(process.cwd(), ["rev-parse", "HEAD"])).stdout.trim(); const oldCommit = "1".repeat(40); const digest = `sha256:${"4".repeat(64)}`; const tmp = await mkdtemp(path.join(os.tmpdir(), "hwlab-artifact-reused-report-")); const reportDir = path.join(tmp, "service-results"); await mkdir(reportDir, { recursive: true }); const image = "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-stable"; const deployPath = path.join(tmp, "deploy.yaml"); const catalogPath = path.join(tmp, "artifact-catalog.v03.json"); const ciPlanPath = path.join(tmp, "ci-plan.json"); const deploy = createDeployFixture({ serviceIds: ["hwlab-cloud-web"] }); deploy.lanes.v03.namespace = "hwlab-v03"; deploy.lanes.v03.endpoint = "https://hwlab.pikapython.com"; await writeStructuredFile(process.cwd(), deployPath, deploy); const catalog = createCatalogFixture("ready", { serviceIds: ["hwlab-cloud-web"], sourceCommitId: oldCommit, bootCommits: { "hwlab-cloud-web": oldCommit }, environmentInputHashes: { "hwlab-cloud-web": "e".repeat(64) }, codeInputHashes: { "hwlab-cloud-web": "c".repeat(64) } }); catalog.environment = "v03"; catalog.profile = "v03"; catalog.namespace = "hwlab-v03"; catalog.allowedProfiles = ["v03"]; catalog.forbiddenProfiles = ["dev", "prod"]; for (const service of catalog.services) { service.profile = "v03"; service.namespace = "hwlab-v03"; } await writeFile(catalogPath, JSON.stringify(catalog, null, 2)); await writeFile(ciPlanPath, JSON.stringify({ planVersion: "v1", sourceCommitId: currentCommit, shortCommitId: currentCommit.slice(0, 12), baseRef: "HEAD~1", targetRef: "HEAD", compatibility: { lane: "v03" }, changedPathSummary: {}, imageBuildRequired: true, affectedServices: [], buildServices: ["hwlab-cloud-web"], rolloutServices: [], reusedServices: [], buildSkippedCount: 0, ciCdPlan: {}, services: [{ serviceId: "hwlab-cloud-web", runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, affected: false, ciAffected: false, buildRequired: true, envChanged: false, codeChanged: false, environmentImage: image, environmentDigest: digest, environmentInputHash: "e".repeat(64), codeInputHash: "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: oldCommit, bootSh: "deploy/runtime/boot/hwlab-cloud-web.sh", reason: ["component-inputs-unchanged"], reuse: { status: "ready", image, digest, reusedFrom: "e".repeat(64) } }] }, null, 2)); await writeFile(path.join(reportDir, "hwlab-cloud-web.json"), JSON.stringify({ artifactPublish: { services: [{ serviceId: "hwlab-cloud-web", status: "reused", commitId: oldCommit, sourceCommitId: oldCommit, image, imageTag: "env-stable", digest, repositoryDigest: `${image}@${digest}`, runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, affected: false, ciAffected: false, codeChanged: false, envChanged: false }] } }, null, 2)); const { stdout } = await execFileAsync(process.execPath, [ "scripts/artifact-publish.mjs", "--build", "--lane", "v03", "--services", "hwlab-cloud-web", "--deploy-config", deployPath, "--catalog-path", catalogPath, "--ci-plan-path", ciPlanPath, "--external-service-report-dir", reportDir, "--registry-prefix", "127.0.0.1:5000/hwlab", "--no-report" ], { cwd: process.cwd(), env: { ...process.env, HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY: "false" } }); const result = JSON.parse(stdout); const blockerText = JSON.stringify(result.blockers ?? []); assert.equal(blockerText.includes(oldCommit), false); assert.equal(result.status, "built"); assert.equal(result.services[0].serviceId, "hwlab-cloud-web"); assert.equal(result.services[0].status, "reused"); assert.equal(result.services[0].sourceCommitId, oldCommit); }); test("component model uses service paths declared in deploy config", () => { const deploy = createDeployFixture(); const models = componentModelsForServices(["hwlab-cloud-api"], deploy.lanes.v02); assert.deepEqual(models[0].componentPaths, [ "cmd/hwlab-cloud-api/", "cmd/hwlab-codex-api-responses-forwarder/", "cmd/hwlab-deepseek-responses-bridge/", "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh", "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh", "internal/agent/agentrun-dispatch.mjs", "internal/agent/prompts/", "internal/audit/", "internal/cloud/", "internal/db/", "skills/hwlab-agent-runtime/" ]); assert.deepEqual(models[0].sharedPaths, ["internal/build-metadata.mjs", "internal/protocol/"]); assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]); }); test("v02 planner rejects missing service declarations instead of falling back to an internal model", async () => { const repo = await createFixtureRepo(); const deployPath = path.join(repo, "deploy/deploy.yaml"); const deploy = await readStructuredFile(repo, deployPath); delete deploy.lanes.v02.serviceDeclarations; await writeStructuredFile(repo, deployPath, deploy); await git(repo, ["add", "deploy/deploy.yaml"]); await git(repo, ["commit", "-m", "remove v02 declarations"]); await assert.rejects( () => createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api"] }), /deploy\.lanes\.v02\.serviceDeclarations is required/u ); }); test("v02 planner rejects missing boot script declarations instead of guessing paths", async () => { const repo = await createFixtureRepo(); const deployPath = path.join(repo, "deploy/deploy.yaml"); const deploy = await readStructuredFile(repo, deployPath); delete deploy.lanes.v02.bootScripts["hwlab-cloud-api"]; await writeStructuredFile(repo, deployPath, deploy); await git(repo, ["add", "deploy/deploy.yaml"]); await git(repo, ["commit", "-m", "remove v02 cloud-api boot script"]); await assert.rejects( () => createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api"] }), /deploy\.lanes\.v02\.bootScripts\.hwlab-cloud-api is required/u ); }); test("v02 planner keeps hwpod-spec changes out of runtime service env reuse", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: changed-preinstalled\nspec:\n nodeBinding:\n nodeId: node-changed\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8"); await git(repo, ["add", ".hwlab/hwpod-spec.yaml"]); await git(repo, ["commit", "-m", "change preinstalled hwpod spec"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false); }); test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", async () => { const repo = await createFixtureRepo(); await mkdir(path.join(repo, "internal/agent/prompts"), { recursive: true }); await writeFile(path.join(repo, "internal/agent/agentrun-dispatch.mjs"), "export const prompt = true;\n"); await writeFile(path.join(repo, "internal/agent/prompts/hwlab-v02-runtime.md"), "prompt v1\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "seed agentrun prompt"]); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, cloudWebCodeInputHash: initialCloudWeb.codeInputHash, cloudWebBootCommit: initialPlan.sourceCommitId })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog with agentrun prompt"]); await writeFile(path.join(repo, "internal/agent/prompts/hwlab-v02-runtime.md"), "prompt v2\n"); await git(repo, ["add", "internal/agent/prompts/hwlab-v02-runtime.md"]); await git(repo, ["commit", "-m", "change agentrun prompt"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); assert.deepEqual(plan.buildServices, []); assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-api"]); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.deepEqual(cloudApi.changedPaths, ["internal/agent/prompts/hwlab-v02-runtime.md"]); assert.equal(cloudApi.envChanged, false); assert.equal(cloudApi.codeChanged, true); assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]); }); test("planner scopes cloud-web runtime changes to cloud-web", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 2;\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change cloud web runtime"]); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]); assert.equal(plan.services.some((service) => service.serviceId === "hwlab-agent-worker"), false); const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]); }); test("v02 planner treats shared trace renderer changes as cloud-web code rollout inputs", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, cloudWebCodeInputHash: initialCloudWeb.codeInputHash, cloudWebBootCommit: initialPlan.sourceCommitId })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog with trace renderer"]); await mkdir(path.join(repo, "tools/src/hwlab-cli"), { recursive: true }); await writeFile(path.join(repo, "tools/src/hwlab-cli/trace-renderer.ts"), "export const traceRenderer = 2;\n"); await git(repo, ["add", "tools/src/hwlab-cli/trace-renderer.ts"]); await git(repo, ["commit", "-m", "change shared trace renderer"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]); assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]); assert.deepEqual(plan.buildServices, []); assert.equal(cloudApi.affected, false); assert.equal(cloudWeb.codeChanged, true); assert.deepEqual(cloudWeb.codeChangedPaths, ["tools/src/hwlab-cli/trace-renderer.ts"]); assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]); }); test("planner ignores package script cleanup for runtime images", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate" }, dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "seed package scripts"]); await refreshFixtureCatalogForHead(repo); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate", check: "node scripts/check-runner.mjs --profile check" }, dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "change package scripts"]); assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.buildSkippedCount, 2); }); test("planner treats dependency changes as runtime image inputs", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", dependencies: { "@openai/codex": "^0.128.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "seed dependencies"]); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", dependencies: { "@openai/codex": "^0.129.0" } }, null, 2)); await git(repo, ["add", "package.json"]); await git(repo, ["commit", "-m", "change dependencies"]); assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true); const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.equal(plan.imageBuildRequired, true); }); test("global change classifier distinguishes gitops-only and test-only", () => { assert.equal(classifyGlobalChange(["deploy/gitops/node/runtime-dev/workloads.yaml"]).gitopsOnly, true); assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/runtime-options.test.ts"]).testOnly, true); }); test("v02 planner marks cloud-web code-only change as env reuse rollout", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-web"] }); const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, cloudWebCodeInputHash: initialCloudWeb.codeInputHash, cloudWebBootCommit: initialPlan.sourceCommitId }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 cloud web catalog"]); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change cloud web code"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-web"] }); const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); assert.equal(cloudWeb.runtimeMode, "env-reuse-git-mirror-checkout"); assert.equal(cloudWeb.envChanged, false); assert.equal(cloudWeb.codeChanged, true); assert.equal(cloudWeb.buildRequired, false); assert.equal(cloudWeb.bootSh, "deploy/runtime/boot/hwlab-cloud-web.sh"); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]); assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); }); test("v02 planner enables env reuse for all retained runtime services", async () => { const repo = await createFixtureRepo({ serviceIds: V02_RUNTIME_SERVICE_IDS }); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: V02_RUNTIME_SERVICE_IDS }); const environmentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.environmentInputHash])); const codeInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.codeInputHash])); const bootCommits = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, initialPlan.sourceCommitId])); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { sourceCommitId: initialPlan.sourceCommitId, environmentInputHashes, codeInputHashes, bootCommits, serviceIds: V02_RUNTIME_SERVICE_IDS }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed all v02 env reuse catalogs"]); await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway v2');\n"); await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge v2');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change all v02 service code"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: V02_RUNTIME_SERVICE_IDS }); assert.deepEqual(plan.compatibility.v02EnvReuseServiceIds, V02_RUNTIME_SERVICE_IDS); assert.deepEqual(plan.affectedServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS)); assert.deepEqual(plan.rolloutServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS)); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); for (const service of plan.services) { assert.equal(service.runtimeMode, "env-reuse-git-mirror-checkout", service.serviceId); assert.equal(service.envReuse, true, service.serviceId); assert.equal(service.envChanged, false, service.serviceId); assert.equal(service.codeChanged, true, service.serviceId); assert.equal(service.buildRequired, false, service.serviceId); assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId); } }); test("v02 planner rejects removed HWLAB-owned agent worker service selections", async () => { const repo = await createFixtureRepo(); await assert.rejects( () => createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-agent-worker"] }), /unknown service IDs for node CI plan: hwlab-agent-worker/u ); }); test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); await writeFile(path.join(repo, "tools/src/hwlab-cli-lib.ts"), "export const harness = true;\n"); await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module", scripts: { validate: "node scripts/check-runner.mjs --profile validate", check: "node scripts/check-runner.mjs --profile check" } }, null, 2)); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change cli and host asset"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); assert.deepEqual(plan.buildServices, []); assert.deepEqual(plan.affectedServices, []); assert.equal(plan.buildSkippedCount, 3); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); }); test("v02 planner scopes hwpod skill bundle changes to the skills service", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = true;\n"); await writeFile(path.join(repo, "skills/hwpod-cli/SKILL.md"), "hwpod skill v2\n"); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "change hwpod runtime cli"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills"]); assert.deepEqual(plan.buildServices, []); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false); const skillsService = plan.services.find((service) => service.serviceId === "hwlab-agent-skills"); assert.equal(skillsService.codeChanged, true); assert.equal(skillsService.envChanged, false); assert.deepEqual(skillsService.reason, ["code-input-changed", "component-path-changed"]); }); test("v02 planner reads env reuse declarations and recipe from deploy config", async () => { const deploy = createDeployFixture(); deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("custom/web-plugin/"); deploy.lanes.v02.envRecipe.bunVersion = "1.3.14"; deploy.lanes.v02.envRecipe.additionalEnvPaths.push("custom/env-tool.mjs"); const models = componentModelsForServices(["hwlab-cloud-web"], deploy.lanes.v02); assert.equal(models[0].runtimeKind, "cloud-web"); assert.equal(models[0].entrypoint, "web/hwlab-cloud-web/index.html"); assert.equal(models[0].componentPaths.includes("custom/web-plugin/"), true); assert.equal(models[0].buildSystemPaths.includes("custom/env-tool.mjs"), true); const recipe = envReuseRecipeForLane(deploy, "v02"); assert.equal(recipe.bunVersion, "1.3.14"); assert.equal(recipe.downloadStack.npmRegistry, "https://registry.npmjs.org/"); assert.equal(recipe.downloadStack.bunPackages.x64, "@oven/bun-linux-x64"); assert.equal(recipe.launcherInputPaths.includes("deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), true); assert.equal(recipe.launcherInputPaths.includes("custom/env-tool.mjs"), true); }); test("planner keeps YAML download proxy authority under Tekton env", async () => { const repo = await createFixtureRepo(); const previousEnv = { HWLAB_TEKTON_PIPELINERUN: process.env.HWLAB_TEKTON_PIPELINERUN, HTTP_PROXY: process.env.HTTP_PROXY, HTTPS_PROXY: process.env.HTTPS_PROXY, NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy }; process.env.HWLAB_TEKTON_PIPELINERUN = "test-pipelinerun"; process.env.HTTP_PROXY = "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808"; process.env.HTTPS_PROXY = "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808"; process.env.NO_PROXY = [ "127.0.0.1", "localhost", "10.43.0.0/16", "git-mirror-http.devops-infra.svc.cluster.local", "deb.debian.org", ".debian.org", "goproxy.cn", ".goproxy.cn", "hyueapi.com", ".hyueapi.com" ].join(","); delete process.env.no_proxy; try { const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-cloud-api"] }); const noProxy = plan.services[0].envReuseRecipe.downloadStack.noProxy; assert.equal(plan.services[0].envReuseRecipe.downloadStack.httpProxy, "http://127.0.0.1:10808"); assert.equal(plan.services[0].envReuseRecipe.downloadStack.httpsProxy, "http://127.0.0.1:10808"); assert.deepEqual(noProxy, [ "127.0.0.1", "localhost", "::1", "hyueapi.com", ".hyueapi.com" ]); } finally { for (const [key, value] of Object.entries(previousEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } }); test("v03 planner reads env reuse declarations and catalog from v03 lane config", async () => { const repo = await createFixtureRepo(); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: "HEAD", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.equal(plan.compatibility.lane, "v03"); assert.equal(plan.compatibility.artifactCatalog, "deploy/artifact-catalog.v03.json"); assert.equal(plan.compatibility.serviceIdSource, "cli-services"); assert.equal(plan.compatibility.serviceDeclarationSource, "deploy.lanes.v03.serviceDeclarations"); assert.equal(plan.compatibility.envRecipeSource, "deploy.lanes.v03.envRecipe"); assert.deepEqual(plan.compatibility.envReuseServiceIds, ["hwlab-cloud-api", "hwlab-cloud-web"]); assert.equal(plan.services.every((service) => service.envReuseRecipe.downloadStack.npmFetchTimeoutMs === 300000), true); for (const service of plan.services) { assert.equal(service.runtimeMode, "env-reuse-git-mirror-checkout", service.serviceId); assert.equal(service.envReuse, true, service.serviceId); assert.equal(service.bootRepo, "git@github.com:pikasTech/HWLAB.git", service.serviceId); assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId); } }); test("v03 TaskTree API and worker share one TypeScript environment artifact", async () => { const reuse = await readStructuredFile(process.cwd(), "gitops/reuse.ymal"); const group = reuse.spec?.envArtifactGroups?.["hwlab-ts-runtime-env"]; const services = reuse.spec?.services ?? {}; const tasktreeServiceIds = ["hwlab-tasktree-api", "hwlab-tasktree-worker"]; const expectedEnvIdentity = [ "package.json", "package-lock.json", "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts", "internal/dev-entrypoint/artifact-runtime.mjs" ]; assert.ok(group, "shared TypeScript environment group"); for (const serviceId of tasktreeServiceIds) { assert.equal(group.services?.includes(serviceId), true, `${serviceId} shared environment membership`); assert.deepEqual(services[serviceId]?.runtimeReuse?.envIdentity?.paths, expectedEnvIdentity, `${serviceId} runtime env identity`); assert.deepEqual(services[serviceId]?.envReuse?.envIdentityFiles, expectedEnvIdentity, `${serviceId} env reuse identity`); assert.equal(services[serviceId]?.envReuse?.mode, "env-reuse-git-mirror-checkout", `${serviceId} runtime mode`); } assert.deepEqual(services["hwlab-tasktree-api"].runtimeReuse.codeIdentity.paths, [ "cmd/hwlab-tasktree-api", "internal/tasktree", "tools/src/tasktree-cli.ts", "deploy/runtime/boot/hwlab-tasktree-api.sh" ]); assert.deepEqual(services["hwlab-tasktree-worker"].runtimeReuse.codeIdentity.paths, [ "cmd/hwlab-tasktree-worker", "internal/tasktree", "deploy/runtime/boot/hwlab-tasktree-worker.sh" ]); }); test("v03 planner builds one shared Go env artifact for compatible Go services", async () => { const repo = await createFixtureRepo({ catalog: false }); await addGoEnvReuseFixture(repo); await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.2\n"); await git(repo, ["add", "go.mod"]); await git(repo, ["commit", "-m", "change go env dependency"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", services: ["hwlab-user-billing", "hwlab-workbench-runtime"] }); const group = plan.envArtifactGroups.find((item) => item.id === "hwlab-go-runtime-env"); assert.equal(group.buildRequired, true); assert.deepEqual(group.buildServices, ["hwlab-user-billing"]); assert.deepEqual(group.consumerServices, ["hwlab-workbench-runtime"]); assert.deepEqual(plan.buildServices, ["hwlab-user-billing"]); const userBilling = plan.services.find((service) => service.serviceId === "hwlab-user-billing"); const workbenchRuntime = plan.services.find((service) => service.serviceId === "hwlab-workbench-runtime"); assert.equal(userBilling.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env"); assert.equal(workbenchRuntime.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env"); assert.equal(userBilling.envReuseRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim"); assert.equal(workbenchRuntime.envReuseRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim"); assert.equal(userBilling.environmentImage, `127.0.0.1:5000/hwlab/hwlab-go-runtime-env:env-${userBilling.environmentInputHash.slice(0, 12)}`); assert.equal(workbenchRuntime.environmentImage, userBilling.environmentImage); assert.equal(workbenchRuntime.sharedEnvBuildSkipped, true); assert.equal(workbenchRuntime.buildRequired, false); }); test("v03 planner ignores Go launcher comment-only changes for env identity", async () => { const repo = await createFixtureRepo({ catalog: false }); await addGoEnvReuseFixture(repo); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD", targetRef: initialSha, artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", services: ["hwlab-user-billing", "hwlab-workbench-runtime"] }); await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha, serviceIds: ["hwlab-user-billing", "hwlab-workbench-runtime"] })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed go env reuse catalog"]); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// changed explanatory comment\nexport const goEnvLauncher = true;\n"); await git(repo, ["add", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"]); await git(repo, ["commit", "-m", "change go launcher comment only"]); const plan = await createCiPlan({ repoRoot: repo, lane: "v03", baseRef: "HEAD~1", targetRef: "HEAD", artifactCatalogPath: "deploy/artifact-catalog.v03.json", services: ["hwlab-user-billing", "hwlab-workbench-runtime"] }); assert.deepEqual(plan.affectedServices, []); assert.deepEqual(plan.buildServices, []); assert.equal(plan.imageBuildRequired, false); for (const service of plan.services) { assert.equal(service.runtimeReuseDecision.envIdentityHit, true, service.serviceId); assert.equal(service.environmentInputChanged, false, service.serviceId); assert.equal(service.buildRequired, false, service.serviceId); } }); test("Go env build path declares base image, stable timing, and late volatile labels", async () => { const deploy = await readStructuredFile(process.cwd(), path.join(process.cwd(), "deploy/deploy.yaml")); const recipe = envReuseRecipeForLane(deploy, "v03"); assert.equal( deploy.lanes?.v03?.envRecipe?.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim" ); assert.equal(recipe.publicRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim"); const source = await readFile(path.join(process.cwd(), "scripts/artifact-publish.mjs"), "utf8"); const envDockerfileSource = source.slice( source.indexOf("function envReuseDockerfile"), source.indexOf("async function copyRepoFileToContext") ); assert.match(source, /const effectiveBaseImage = goBaseImageEnabled \? normalizedRecipe\.goBaseImage : baseImage/u); assert.match(envDockerfileSource, /timed_stage "go-mod-download"/u); assert.match(envDockerfileSource, /timed_stage "env-readiness"/u); assert.match(source, /goBaseImage: artifact\.goBaseImage \?\? null/u); assert.match(source, /"go-base-image-status": service\.goBaseImageStatus \?\? ""/u); const labelIndex = envDockerfileSource.indexOf("...labels.map(([name, value]) => `LABEL"); const readinessIndex = envDockerfileSource.indexOf('timed_stage "env-readiness"'); assert.ok(readinessIndex >= 0, "env readiness timing should be emitted from the Dockerfile"); assert.ok(labelIndex > readinessIndex, "volatile labels should not invalidate expensive env build layers"); }); async function createFixtureRepo(options = {}) { const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"]; const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS; const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-ci-plan-")); await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-gateway"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-edge-proxy"), { recursive: true }); await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true }); await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true }); await mkdir(path.join(repo, "internal/sim"), { recursive: true }); await mkdir(path.join(repo, "tools/hwlab-cli"), { recursive: true }); await mkdir(path.join(repo, "tools/src"), { recursive: true }); await mkdir(path.join(repo, "skills/hwpod-cli"), { recursive: true }); await mkdir(path.join(repo, "skills/hwpod-ctl"), { recursive: true }); await mkdir(path.join(repo, "skills/hwlab-agent-runtime"), { recursive: true }); await mkdir(path.join(repo, ".hwlab"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true }); await mkdir(path.join(repo, "deploy"), { recursive: true }); await writeStructuredFile(repo, "deploy/deploy.yaml", createDeployFixture({ serviceIds: laneServiceIds })); const catalogMode = options.catalog ?? "ready"; 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"); await writeFile(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder/main.ts"), "console.log('forwarder');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n"); await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway');\n"); await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "export const proxy = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n"); await writeFile(path.join(repo, "internal/sim/http.mjs"), "export const simHttp = 1;\n"); await writeFile(path.join(repo, "tools/hwpod-cli.ts"), "console.log('hwpod cli');\n"); await writeFile(path.join(repo, "tools/hwpod-ctl.ts"), "console.log('hwpod ctl');\n"); await writeFile(path.join(repo, "tools/hwpod-compiler-cli.ts"), "console.log('hwpod compiler');\n"); await writeFile(path.join(repo, "tools/hwpod-node.ts"), "console.log('hwpod node');\n"); await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = false;\n"); await writeFile(path.join(repo, "tools/src/hwpod-node-lib.ts"), "export const node = true;\n"); await writeFile(path.join(repo, "tools/src/hwpod-node-ops-contract.ts"), "export const ops = true;\n"); await writeFile(path.join(repo, "tools/src/runtime-endpoint-resolver.ts"), "export const endpoint = true;\n"); await writeFile(path.join(repo, "skills/hwpod-cli/SKILL.md"), "hwpod cli skill\n"); await writeFile(path.join(repo, "skills/hwpod-ctl/SKILL.md"), "hwpod ctl skill\n"); await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: d601-f103-v2\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-api.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-cloud-api/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-codex-api-responses-forwarder/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-deepseek-responses-bridge/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-gateway.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-gateway/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-edge-proxy.sh"), "#!/bin/sh\nexec node cmd/hwlab-edge-proxy/main.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-agent-skills.sh"), "#!/bin/sh\nexec bun .hwlab-agent-skills-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); 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 createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", services: serviceIds }); const catalog = createCatalogFixture(catalogMode, catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha, serviceIds })); 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 writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "seed artifact catalogs"]); } return repo; } async function refreshFixtureCatalogForHead(repo) { const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const services = ["hwlab-cloud-api", "hwlab-cloud-web"]; const plan = await createCiPlan({ repoRoot: repo, lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", services }); const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(plan, { sourceCommitId, serviceIds: services })); 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 writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]); await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]); } function planV02CoreServices(repo, options) { return createCiPlan({ repoRoot: repo, lane: "v02", artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"], ...options }); } async function addGoEnvReuseFixture(repo) { await mkdir(path.join(repo, "cmd/hwlab-user-billing"), { recursive: true }); await mkdir(path.join(repo, "cmd/hwlab-workbench-runtime"), { recursive: true }); await mkdir(path.join(repo, "internal/userbilling"), { recursive: true }); await mkdir(path.join(repo, "internal/workbenchruntime"), { recursive: true }); await mkdir(path.join(repo, "gitops"), { recursive: true }); await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.1\n"); await writeFile(path.join(repo, "go.sum"), "example.com/direct v0.0.1 h1:fixture\n"); await writeFile(path.join(repo, "cmd/hwlab-user-billing/main.go"), "package main\n\nfunc main() {}\n"); await writeFile(path.join(repo, "cmd/hwlab-workbench-runtime/main.go"), "package main\n\nfunc main() {}\n"); await writeFile(path.join(repo, "internal/userbilling/billing.go"), "package userbilling\n"); await writeFile(path.join(repo, "internal/workbenchruntime/runtime.go"), "package workbenchruntime\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-user-billing.sh"), "#!/bin/sh\nexec /app/hwlab-user-billing\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-workbench-runtime.sh"), "#!/bin/sh\nexec /app/hwlab-workbench-runtime\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// initial explanatory comment\nexport const goEnvLauncher = true;\n"); const deployPath = path.join(repo, "deploy/deploy.yaml"); const deploy = await readStructuredFile(repo, deployPath); deploy.lanes.v03.envReuseServices = uniquePreserveOrder([ ...deploy.lanes.v03.envReuseServices, "hwlab-user-billing", "hwlab-workbench-runtime" ]); deploy.lanes.v03.bootScripts["hwlab-user-billing"] = "deploy/runtime/boot/hwlab-user-billing.sh"; deploy.lanes.v03.bootScripts["hwlab-workbench-runtime"] = "deploy/runtime/boot/hwlab-workbench-runtime.sh"; deploy.lanes.v03.envRecipe.goOsPackages = ["golang-go"]; deploy.lanes.v03.envRecipe.goBaseImage = "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim"; deploy.lanes.v03.envRecipe.runtimeGoModCachePath = "/opt/hwlab-env/go/pkg/mod"; deploy.lanes.v03.envRecipe.runtimeGoBuildCachePath = "/opt/hwlab-env/go/cache"; deploy.lanes.v03.envRecipe.goToolchain = "local"; deploy.lanes.v03.serviceDeclarations["hwlab-user-billing"] = { runtimeKind: "go-service", entrypoint: "cmd/hwlab-user-billing/main.go", artifactKind: "go-service", healthPath: "/health/live", healthPort: 7101, componentPaths: ["cmd/hwlab-user-billing/", "internal/userbilling/"], env: {}, observable: true }; deploy.lanes.v03.serviceDeclarations["hwlab-workbench-runtime"] = { runtimeKind: "go-service", entrypoint: "cmd/hwlab-workbench-runtime/main.go", artifactKind: "go-service", healthPath: "/health/live", healthPort: 7102, componentPaths: ["cmd/hwlab-workbench-runtime/", "internal/workbenchruntime/"], env: {}, observable: true }; await writeStructuredFile(repo, deployPath, deploy); await writeStructuredFile(repo, "gitops/reuse.ymal", { apiVersion: "unidesk.pikapython.com/v1alpha1", kind: "RuntimeReuseConfig", metadata: { name: "hwlab-v03-reuse", ownerRepository: "pikasTech/HWLAB" }, spec: { envArtifactGroups: { "hwlab-go-runtime-env": { enabled: true, mode: "env-reuse-git-mirror-checkout", buildService: "hwlab-user-billing", imageRepository: "hwlab-go-runtime-env", cacheRepository: "cache/hwlab-go-runtime-env", services: ["hwlab-user-billing", "hwlab-workbench-runtime"] } }, services: Object.fromEntries(["hwlab-user-billing", "hwlab-workbench-runtime"].map((serviceId) => [serviceId, { runtimeReuse: { enabled: true, codeIdentity: { paths: [serviceId === "hwlab-user-billing" ? "cmd/hwlab-user-billing" : "cmd/hwlab-workbench-runtime"] }, envIdentity: { paths: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"] } }, envReuse: { enabled: true, mode: "env-reuse-git-mirror-checkout", envIdentityFiles: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"] } }])) } }); await git(repo, ["add", "."]); await git(repo, ["commit", "-m", "add go env reuse fixture"]); } function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) { const v02 = { sourceRepo: "git@github.com:pikasTech/HWLAB.git", artifactCatalog: "deploy/artifact-catalog.v02.json", envReuseServices: [...serviceIds], bootScripts: { "hwlab-cloud-api": "deploy/runtime/boot/hwlab-cloud-api.sh", "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh", "hwlab-gateway": "deploy/runtime/boot/hwlab-gateway.sh", "hwlab-edge-proxy": "deploy/runtime/boot/hwlab-edge-proxy.sh", "hwlab-agent-skills": "deploy/runtime/boot/hwlab-agent-skills.sh" }, serviceDeclarations: { "hwlab-cloud-api": { runtimeKind: "bun-command", entrypoint: "cmd/hwlab-cloud-api/main.ts", artifactKind: "bun-command", healthPath: "/health/live", healthPort: 6667, componentPaths: [ "cmd/hwlab-cloud-api/", "cmd/hwlab-codex-api-responses-forwarder/", "cmd/hwlab-deepseek-responses-bridge/", "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh", "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh", "internal/cloud/", "internal/db/", "internal/audit/", "internal/agent/agentrun-dispatch.mjs", "internal/agent/prompts/", "skills/hwlab-agent-runtime/" ], env: {}, observable: true }, "hwlab-cloud-web": { runtimeKind: "cloud-web", entrypoint: "web/hwlab-cloud-web/index.html", artifactKind: "cloud-web", healthPath: "/health/live", healthPort: 8080, componentPaths: [ "web/hwlab-cloud-web/", "internal/dev-entrypoint/cloud-web-runtime.mjs", "internal/dev-entrypoint/cloud-web-proxy.mjs", "internal/dev-entrypoint/cloud-web-routes.mjs", "tools/src/hwlab-cli/trace-renderer.ts" ], env: {}, observable: true }, "hwlab-gateway": { runtimeKind: "bun-command", entrypoint: "cmd/hwlab-gateway/main.ts", artifactKind: "bun-command", healthPath: "/health/live", healthPort: 7001, componentPaths: ["cmd/hwlab-gateway/", "internal/sim/"], env: {}, observable: false }, "hwlab-edge-proxy": { runtimeKind: "node-command", entrypoint: "cmd/hwlab-edge-proxy/main.mjs", artifactKind: "node-command", healthPath: "/health", healthPort: 6667, componentPaths: ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"], env: {}, observable: true }, "hwlab-agent-skills": { runtimeKind: "skills-bundle", entrypoint: "skills/hwlab-agent-runtime/SKILL.md", artifactKind: "skills-bundle", healthPath: "/health/live", healthPort: 7430, componentPaths: ["skills/"], env: {}, observable: true } }, envRecipe: { osPackages: ["ca-certificates", "git"], bunVersion: "1.3.13", downloadStack: { httpProxy: "http://127.0.0.1:10808", httpsProxy: "http://127.0.0.1:10808", noProxy: ["127.0.0.1", "localhost", "::1", "hyueapi.com", ".hyueapi.com"], npmRegistry: "https://registry.npmjs.org/", npmFetchTimeoutMs: 300000, bunPackages: { x64: "@oven/bun-linux-x64", arm64: "@oven/bun-linux-aarch64" } }, launcherPath: "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts", runtimeNodeModulesPath: "/opt/hwlab-env/node_modules", additionalEnvPaths: [ "internal/dev-entrypoint/artifact-runtime.mjs" ], hwpodAliases: [ { command: "hwpod", script: "tools/hwpod-cli.ts" }, { command: "hwpod-ctl", script: "tools/hwpod-ctl.ts" }, { command: "hwpod-compiler", script: "tools/hwpod-compiler-cli.ts" } ] }, bootConfig: { template: "default", overrides: {} } }; const v03 = JSON.parse(JSON.stringify(v02)); v03.artifactCatalog = "deploy/artifact-catalog.v03.json"; const deploy = { lanes: { v02, v03 } }; return deploy; } function createCatalogFixture(mode, options = {}) { const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published"; const envReuseCatalogServiceIds = new Set([ ...V02_RUNTIME_SERVICE_IDS, ...Object.keys(options.environmentInputHashes ?? {}), ...Object.keys(options.codeInputHashes ?? {}) ]); const serviceIds = options.serviceIds ?? uniquePreserveOrder([ "hwlab-cloud-api", "hwlab-cloud-web", ...Object.keys(options.componentInputHashes ?? {}), ...Object.keys(options.environmentInputHashes ?? {}), ...Object.keys(options.codeInputHashes ?? {}) ]); return { services: serviceIds.map((serviceId) => ({ serviceId, commitId: "abc1234", sourceCommitId: options.sourceCommitId ?? "abc1234", image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`, imageTag: "abc1234", digest, ...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? { componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId] } : {}), ...(envReuseCatalogServiceIds.has(serviceId) ? { runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`, environmentDigest: digest, environmentInputHash: options.environmentInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebEnvironmentInputHash : null) ?? "e".repeat(64), codeInputHash: options.codeInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebCodeInputHash : null) ?? "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: options.bootCommits?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebBootCommit : null) ?? "abc1234", bootSh: `deploy/runtime/boot/${serviceId}.sh` } : {}) })) }; } function catalogOptionsFromPlan(plan, extra = {}) { const serviceIds = plan.services.map((service) => service.serviceId); return { sourceCommitId: plan.sourceCommitId, serviceIds, componentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash])), environmentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.environmentInputHash]).filter(([, value]) => value)), codeInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.codeInputHash]).filter(([, value]) => value)), bootCommits: Object.fromEntries(plan.services.map((service) => [service.serviceId, plan.sourceCommitId])), ...extra }; } function sortedServiceIds(values) { return [...values].sort(); } function uniquePreserveOrder(values) { const seen = new Set(); const result = []; for (const value of values) { if (!value || seen.has(value)) continue; seen.add(value); result.push(value); } return result; } async function runCiPlanCli(repo, args) { const { stdout } = await execFileAsync(process.execPath, [path.resolve("scripts/ci-plan.mjs"), ...args], { cwd: repo }); return JSON.parse(stdout); } async function git(cwd, args) { return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd }); }