From beacbdd37f4ed70f57acde6b2899a48ff70d83a9 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Jul 2026 22:31:16 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PaC=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=A4=8D=E7=94=A8=E8=AE=A1=E5=88=92=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/artifact-publish.mjs | 4 + scripts/ci-plan.mjs | 28 ++++ scripts/ci-plan.test.mjs | 113 ++++++++++++++++ scripts/ci/restore-artifact-catalog.mjs | 86 ++++++++---- scripts/gitops-render.test.ts | 4 + scripts/src/artifact-catalog-authority.mjs | 123 ++++++++++++++++++ scripts/src/ci-plan-lib.mjs | 25 +++- .../gitops-render/templates/plan-artifacts.sh | 8 +- 8 files changed, 362 insertions(+), 29 deletions(-) create mode 100644 scripts/src/artifact-catalog-authority.mjs diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 5e128f9b..b23d0650 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -2331,10 +2331,14 @@ function ciPlanReportSummary(ciPlan) { compatibility: ciPlan.compatibility, changedPathSummary: ciPlan.changedPathSummary, imageBuildRequired: ciPlan.imageBuildRequired, + artifactCatalog: ciPlan.artifactCatalog, affectedServices: ciPlan.affectedServices, rolloutServices: ciPlan.rolloutServices ?? ciPlan.affectedServices, buildServices: ciPlan.buildServices ?? ciPlan.affectedServices, + rolloutWithoutImageBuildServices: ciPlan.rolloutWithoutImageBuildServices ?? [], reusedServices: ciPlan.reusedServices, + serviceReusedCount: ciPlan.serviceReusedCount, + imageBuildSkippedServices: ciPlan.imageBuildSkippedServices, buildSkippedCount: ciPlan.buildSkippedCount, ciCdPlan: ciPlan.ciCdPlan }; diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 440ee000..ad9ac663 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; +import { readArtifactCatalogSummary } from "./src/artifact-catalog-authority.mjs"; + const args = parseArgs(process.argv.slice(2)); const noDepsGitOpsOnlyPaths = Object.freeze([ @@ -25,6 +27,10 @@ if (args.help) { const noDepsPlan = tryCreateNoDepsPlan(args); if (noDepsPlan) { + const artifactCatalog = await noDepsArtifactCatalogSummary(args); + noDepsPlan.artifactCatalog = artifactCatalog; + noDepsPlan.compatibility.artifactCatalog = artifactCatalog.status === "missing" ? null : artifactCatalog.catalogPath; + noDepsPlan.compatibility.artifactCatalogAuthority = artifactCatalog.authority; process.stdout.write(args.pretty ? `${JSON.stringify(noDepsPlan, null, 2)}\n` : `${JSON.stringify(noDepsPlan)}\n`); process.exit(0); } @@ -73,7 +79,10 @@ function tryCreateNoDepsPlan(options) { affectedServices: [], rolloutServices: [], buildServices: [], + rolloutWithoutImageBuildServices: [], reusedServices: services, + serviceReusedCount: services.length, + imageBuildSkippedServices: services, buildSkippedCount: services.length, services: services.map((serviceId) => ({ serviceId, @@ -89,6 +98,7 @@ function tryCreateNoDepsPlan(options) { ciCdPlan: { willBuild: [], willRollout: [], + willRolloutWithoutImageBuild: [], willReuse: services, willRunGitopsPromote: summary.gitopsOnly, noImageBuildReason: summary.summary @@ -96,6 +106,24 @@ function tryCreateNoDepsPlan(options) { }; } +async function noDepsArtifactCatalogSummary(options) { + const catalogPath = options.artifactCatalogPath; + if (!catalogPath) { + return { + status: "missing", + authority: "none", + catalogPath: null, + authorityPath: null, + gitopsBranch: null, + gitopsCommitId: null, + catalogSourceCommitId: null, + serviceCount: 0, + catalogSha256: null + }; + } + return readArtifactCatalogSummary({ repoRoot: process.cwd(), catalogPath }); +} + function classifyNoDepsGlobalChange(changedPaths) { const relevant = changedPaths.filter(Boolean); const testOnly = relevant.length > 0 && relevant.every(isNoDepsTestOnlyPath); diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 9529f7cb..bda40fd8 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -54,6 +54,117 @@ test("node CI planner treats docs-only changes as no image build", async () => { 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("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.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.compatibility.artifactCatalogAuthority, "gitops-branch"); +}); + +test("artifact catalog restore rejects a GitOps service-set mismatch", 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]); + + 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-mismatch", + HWLAB_GIT_READ_URL: repo, + HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web" + } + }), + (error) => { + assert.equal(error.code, 1); + assert.match(error.stderr, /"reason":"gitops-catalog-service-ids-mismatch"/u); + assert.match(error.stderr, /missing=hwlab-cloud-web/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"); @@ -127,6 +238,8 @@ test("v02 planner rolls cloud-api service code changes without rebuilding servic 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 () => { diff --git a/scripts/ci/restore-artifact-catalog.mjs b/scripts/ci/restore-artifact-catalog.mjs index 77d2791d..904285a3 100644 --- a/scripts/ci/restore-artifact-catalog.mjs +++ b/scripts/ci/restore-artifact-catalog.mjs @@ -2,9 +2,14 @@ // Restore the node/lane artifact catalog from the GitOps branch before CI planning. import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { + artifactCatalogAuthorityPath, + createHydratedArtifactCatalogAuthority +} from "../src/artifact-catalog-authority.mjs"; + const startedAt = Date.now(); function env(name) { @@ -12,6 +17,10 @@ function env(name) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function csvEnv(name) { + return (env(name) ?? "").split(",").map((value) => value.trim()).filter(Boolean); +} + function emit(payload) { process.stderr.write(JSON.stringify({ event: "artifact-catalog-restore", @@ -24,16 +33,6 @@ function emit(payload) { }) + "\n"); } -function catalogHasServices(filePath) { - if (!existsSync(filePath)) return false; - try { - const parsed = JSON.parse(readFileSync(filePath, "utf8")); - return Array.isArray(parsed?.services) && parsed.services.length > 0; - } catch { - return false; - } -} - function git(args, options = {}) { return execFileSync("git", args, { cwd: process.cwd(), @@ -46,32 +45,71 @@ function git(args, options = {}) { const catalogPath = env("HWLAB_CATALOG_PATH"); const gitopsBranch = env("HWLAB_GITOPS_BRANCH"); const remote = env("HWLAB_GIT_READ_URL") ?? env("HWLAB_GIT_URL"); +const selectedServices = csvEnv("HWLAB_SERVICES"); -if (!catalogPath || !gitopsBranch || !remote) { - emit({ status: "skipped", reason: "required-input-missing", hasCatalogPath: Boolean(catalogPath), hasGitopsBranch: Boolean(gitopsBranch), hasRemote: Boolean(remote) }); - process.exit(0); -} - -if (catalogHasServices(catalogPath)) { - emit({ status: "source-present", reason: "catalog-already-present" }); - process.exit(0); +if (!catalogPath || !gitopsBranch || !remote || selectedServices.length === 0) { + emit({ + status: "failed", + reason: "required-input-missing", + hasCatalogPath: Boolean(catalogPath), + hasGitopsBranch: Boolean(gitopsBranch), + hasRemote: Boolean(remote), + selectedServiceCount: selectedServices.length + }); + process.exit(2); } try { git(["fetch", "--depth=1", remote, `refs/heads/${gitopsBranch}`], { capture: false, timeoutMs: 60_000 }); + const gitopsCommitId = git(["rev-parse", "FETCH_HEAD"], { timeoutMs: 15_000 }).trim(); const content = git(["show", `FETCH_HEAD:${catalogPath}`], { timeoutMs: 30_000 }); const parsed = JSON.parse(content); if (!Array.isArray(parsed?.services) || parsed.services.length === 0) { - emit({ status: "skipped", reason: "gitops-catalog-has-no-services" }); - process.exit(0); + throw new Error("gitops catalog has no services"); } + const actualServices = parsed.services.map((service) => nonEmptyText(service?.serviceId)).filter(Boolean); + const expected = uniqueSorted(selectedServices); + const actual = uniqueSorted(actualServices); + const missing = expected.filter((serviceId) => !actual.includes(serviceId)); + const extra = actual.filter((serviceId) => !expected.includes(serviceId)); + const invalidEntryCount = parsed.services.length - actualServices.length; + const duplicateCount = actualServices.length - actual.length; + if (missing.length > 0 || extra.length > 0 || invalidEntryCount > 0 || duplicateCount > 0) { + const error = new Error(`gitops catalog service ids mismatch: missing=${missing.join(",") || "none"} extra=${extra.join(",") || "none"} invalid=${invalidEntryCount} duplicate=${duplicateCount}`); + error.code = "catalog-service-ids-mismatch"; + throw error; + } + const authorityPath = artifactCatalogAuthorityPath(catalogPath); + const authority = createHydratedArtifactCatalogAuthority({ catalog: parsed, catalogPath, gitopsBranch, gitopsCommitId }); mkdirSync(dirname(catalogPath), { recursive: true }); writeFileSync(catalogPath, JSON.stringify(parsed, null, 2) + "\n"); - emit({ status: "restored", reason: "gitops-catalog", serviceCount: parsed.services.length, bytes: Buffer.byteLength(content) }); + writeFileSync(authorityPath, JSON.stringify(authority, null, 2) + "\n"); + emit({ + status: "hydrated", + reason: "gitops-catalog", + authority: authority.authority, + authorityPath, + gitopsCommitId, + catalogSourceCommitId: authority.catalogSourceCommitId, + catalogSha256: authority.catalogSha256, + serviceCount: parsed.services.length, + selectedServiceCount: selectedServices.length, + bytes: Buffer.byteLength(content) + }); } catch (error) { emit({ - status: "skipped", - reason: "gitops-catalog-unavailable", + status: "failed", + reason: error?.code === "catalog-service-ids-mismatch" ? "gitops-catalog-service-ids-mismatch" : "gitops-catalog-unavailable", error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) }); + process.exit(1); +} + +function nonEmptyText(value) { + const result = typeof value === "string" ? value.trim() : ""; + return result || null; +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); } diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 62e7ada9..9dba2d0b 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -155,6 +155,10 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots const planArtifactsScript = planArtifacts.taskSpec.steps[0].script; assert.match(planArtifactsScript, /node scripts\/ci\/restore-artifact-catalog\.mjs/u); assert.match(planArtifactsScript, /HWLAB_GIT_READ_URL="\$\(params\.git-read-url\)"/u); + assert.match(planArtifactsScript, /HWLAB_SERVICES="\$\(params\.services\)"/u); + assert.match(planArtifactsScript, /rolloutWithoutImageBuildServices/u); + assert.match(planArtifactsScript, /serviceReusedCount/u); + assert.match(planArtifactsScript, /artifactCatalog: plan\.artifactCatalog/u); assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "git-read-url")); assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "gitops-branch")); assert.ok(planArtifacts.params.some((param) => param.name === "lane" && param.value === "$(params.lane)")); diff --git a/scripts/src/artifact-catalog-authority.mjs b/scripts/src/artifact-catalog-authority.mjs new file mode 100644 index 00000000..3b17f85b --- /dev/null +++ b/scripts/src/artifact-catalog-authority.mjs @@ -0,0 +1,123 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export function artifactCatalogAuthorityPath(catalogPath) { + return `${catalogPath}.authority.json`; +} + +export function artifactCatalogSha256(catalog) { + return createHash("sha256").update(JSON.stringify(catalog)).digest("hex"); +} + +export function artifactCatalogSourceCommitId(catalog) { + const direct = text(catalog?.publish?.sourceCommitId) || text(catalog?.sourceCommitId) || text(catalog?.commitId); + if (direct) return direct; + const serviceCommits = unique((catalog?.services ?? []).map((service) => text(service?.sourceCommitId)).filter(Boolean)); + return serviceCommits.length === 1 ? serviceCommits[0] : null; +} + +export function createHydratedArtifactCatalogAuthority({ catalog, catalogPath, gitopsBranch, gitopsCommitId }) { + return { + schemaVersion: "v1", + status: "hydrated", + authority: "gitops-branch", + catalogPath: normalizeCatalogPath(catalogPath), + gitopsBranch: text(gitopsBranch), + gitopsCommitId: text(gitopsCommitId), + catalogSourceCommitId: artifactCatalogSourceCommitId(catalog), + serviceCount: Array.isArray(catalog?.services) ? catalog.services.length : 0, + catalogSha256: artifactCatalogSha256(catalog) + }; +} + +export async function readArtifactCatalogSummary({ repoRoot, catalogPath, authorityPath = artifactCatalogAuthorityPath(catalogPath), catalog = undefined }) { + const loadedCatalog = catalog === undefined ? await readJsonIfPresent(repoRoot, catalogPath) : catalog; + const authority = await readJsonIfPresent(repoRoot, authorityPath); + const normalizedCatalogPath = normalizeCatalogPath(catalogPath); + if (!loadedCatalog) { + if (authority) throw new Error(`artifact catalog authority exists without catalog ${normalizedCatalogPath}`); + return { + status: "missing", + authority: "none", + catalogPath: normalizedCatalogPath, + authorityPath: null, + gitopsBranch: null, + gitopsCommitId: null, + catalogSourceCommitId: null, + serviceCount: 0, + catalogSha256: null + }; + } + + const serviceCount = Array.isArray(loadedCatalog.services) ? loadedCatalog.services.length : 0; + const catalogSha256 = artifactCatalogSha256(loadedCatalog); + const catalogSourceCommitId = artifactCatalogSourceCommitId(loadedCatalog); + if (!authority) { + return { + status: "loaded", + authority: "workspace-file", + catalogPath: normalizedCatalogPath, + authorityPath: null, + gitopsBranch: null, + gitopsCommitId: null, + catalogSourceCommitId, + serviceCount, + catalogSha256 + }; + } + + validateHydratedAuthority({ authority, catalogPath: normalizedCatalogPath, serviceCount, catalogSha256 }); + return { + status: "hydrated", + authority: "gitops-branch", + catalogPath: normalizedCatalogPath, + authorityPath: normalizeCatalogPath(authorityPath), + gitopsBranch: text(authority.gitopsBranch), + gitopsCommitId: text(authority.gitopsCommitId), + catalogSourceCommitId: text(authority.catalogSourceCommitId) || catalogSourceCommitId, + serviceCount, + catalogSha256 + }; +} + +function validateHydratedAuthority({ authority, catalogPath, serviceCount, catalogSha256 }) { + if (authority.schemaVersion !== "v1" || authority.status !== "hydrated" || authority.authority !== "gitops-branch") { + throw new Error(`artifact catalog authority for ${catalogPath} is not a hydrated gitops-branch record`); + } + if (normalizeCatalogPath(authority.catalogPath) !== catalogPath) { + throw new Error(`artifact catalog authority path mismatch: expected ${catalogPath}, got ${authority.catalogPath ?? "missing"}`); + } + if (authority.serviceCount !== serviceCount) { + throw new Error(`artifact catalog authority service count mismatch for ${catalogPath}: expected ${serviceCount}, got ${authority.serviceCount ?? "missing"}`); + } + if (authority.catalogSha256 !== catalogSha256) { + throw new Error(`artifact catalog authority fingerprint mismatch for ${catalogPath}`); + } + if (!text(authority.gitopsBranch) || !/^[a-f0-9]{40}$/u.test(text(authority.gitopsCommitId) ?? "")) { + throw new Error(`artifact catalog authority gitops provenance is incomplete for ${catalogPath}`); + } +} + +async function readJsonIfPresent(repoRoot, filePath) { + const resolved = path.isAbsolute(filePath) ? filePath : path.join(repoRoot, filePath); + try { + return JSON.parse(await readFile(resolved, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function normalizeCatalogPath(value) { + return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").trim(); +} + +function text(value) { + const result = typeof value === "string" ? value.trim() : ""; + return result || null; +} + +function unique(values) { + return [...new Set(values)]; +} diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 68197617..181e3b1f 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -6,6 +6,10 @@ import * as https from "node:https"; import path from "node:path"; import { promisify } from "node:util"; +import { + artifactCatalogAuthorityPath, + readArtifactCatalogSummary +} from "./artifact-catalog-authority.mjs"; import { parseStructuredConfig, readStructuredFileIfPresent } from "./structured-config.mjs"; const execFileAsync = promisify(execFile); @@ -86,6 +90,13 @@ export async function createCiPlan(options = {}) { assertLaneEnvReuseConfig(laneConfig, lane); const artifactCatalogPath = options.artifactCatalogPath ?? defaultArtifactCatalogPath(laneConfig, lane); const artifactCatalog = await readStructuredFileIfPresent(repoRoot, artifactCatalogPath, null); + const catalogAuthorityPath = options.artifactCatalogAuthorityPath ?? artifactCatalogAuthorityPath(artifactCatalogPath); + const artifactCatalogSummary = await readArtifactCatalogSummary({ + repoRoot, + catalogPath: artifactCatalogPath, + authorityPath: catalogAuthorityPath, + catalog: artifactCatalog + }); const runtimeReuseConfigPath = options.runtimeReuseConfigPath ?? "gitops/reuse.ymal"; const runtimeReuseConfig = await readStructuredFileIfPresent(repoRoot, runtimeReuseConfigPath, null); const runtimeReuseByService = runtimeReuseServices(runtimeReuseConfig); @@ -339,7 +350,9 @@ export async function createCiPlan(options = {}) { const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId); const buildServices = services.filter((service) => service.buildRequired).map((service) => service.serviceId); - const reusedServices = services.filter((service) => !service.buildRequired).map((service) => service.serviceId); + const rolloutWithoutImageBuildServices = services.filter((service) => service.affected && !service.buildRequired).map((service) => service.serviceId); + const reusedServices = services.filter((service) => !service.affected && !service.buildRequired).map((service) => service.serviceId); + const imageBuildSkippedServices = services.filter((service) => !service.buildRequired).map((service) => service.serviceId); const gitopsRenderChanged = hasGitOpsRenderChange(normalizedChangedPaths); return { planVersion: CI_PLAN_VERSION, @@ -349,7 +362,8 @@ export async function createCiPlan(options = {}) { targetRef, compatibility: { deployConfig: deployConfigPath, - artifactCatalog: artifactCatalog ? artifactCatalogPath : null, + artifactCatalog: artifactCatalogSummary.status === "missing" ? null : artifactCatalogPath, + artifactCatalogAuthority: artifactCatalogSummary.authority, lane, ciContractSource: "scripts/gitops-render.mjs", mode: "advisory-read-only", @@ -373,17 +387,22 @@ export async function createCiPlan(options = {}) { }, changedPaths: normalizedChangedPaths, changedPathSummary: globalChange, + artifactCatalog: artifactCatalogSummary, imageBuildRequired: buildServices.length > 0, affectedServices, rolloutServices: affectedServices, buildServices, + rolloutWithoutImageBuildServices, reusedServices, - buildSkippedCount: services.length - buildServices.length, + serviceReusedCount: reusedServices.length, + imageBuildSkippedServices, + buildSkippedCount: imageBuildSkippedServices.length, envArtifactGroups: envArtifactGroupPlans, services, ciCdPlan: { willBuild: buildServices, willRollout: affectedServices, + willRolloutWithoutImageBuild: rolloutWithoutImageBuildServices, willReuse: reusedServices, envArtifactGroups: envArtifactGroupPlans, willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0 || gitopsRenderChanged, diff --git a/scripts/src/gitops-render/templates/plan-artifacts.sh b/scripts/src/gitops-render/templates/plan-artifacts.sh index 8de35581..522411d8 100644 --- a/scripts/src/gitops-render/templates/plan-artifacts.sh +++ b/scripts/src/gitops-render/templates/plan-artifacts.sh @@ -9,7 +9,7 @@ if [ -d "$ci_node_deps/yaml" ] && [ ! -e node_modules/yaml ]; then ln -s "$ci_node_deps/yaml" node_modules/yaml fi test "$(git rev-parse HEAD)" = "$(params.revision)" -HWLAB_CATALOG_PATH="$(params.catalog-path)" HWLAB_GIT_URL="$(params.git-url)" HWLAB_GIT_READ_URL="$(params.git-read-url)" HWLAB_GITOPS_BRANCH="$(params.gitops-branch)" node scripts/ci/restore-artifact-catalog.mjs +HWLAB_CATALOG_PATH="$(params.catalog-path)" HWLAB_GIT_URL="$(params.git-url)" HWLAB_GIT_READ_URL="$(params.git-read-url)" HWLAB_GITOPS_BRANCH="$(params.gitops-branch)" HWLAB_SERVICES="$(params.services)" node scripts/ci/restore-artifact-catalog.mjs node scripts/ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --verify-reuse-registry > /workspace/source/ci-plan.json node - <<'NODE' const fs = require("node:fs"); @@ -46,13 +46,17 @@ fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({ affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId), + rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], + serviceReusedCount: plan.serviceReusedCount || 0, + imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, + artifactCatalog: plan.artifactCatalog || null, envArtifactGroups: plan.envArtifactGroups || [], changedPathSummary: plan.changedPathSummary || null, ciCdPlan: plan.ciCdPlan || null, services: plan.services || [], entries }, null, 2) + String.fromCharCode(10)); -console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], reusedServices: plan.reusedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, envArtifactGroups: plan.envArtifactGroups || [] })); +console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] })); NODE