Merge pull request #2483 from pikasTech/fix/2482-pac-env-reuse
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
[NC01/v0.3] 修复 PaC 环境复用计划语义
This commit is contained in:
@@ -718,7 +718,7 @@
|
||||
"value": "hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc,git-mirror-http,git-mirror-http.devops-infra,git-mirror-http.devops-infra.svc,git-mirror-http.devops-infra.svc.cluster.local,git-mirror-write,git-mirror-write.devops-infra,git-mirror-write.devops-infra.svc,git-mirror-write.devops-infra.svc.cluster.local"
|
||||
}
|
||||
],
|
||||
"script": "#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps/yaml\" ] && [ ! -e node_modules/yaml ]; then\n mkdir -p node_modules\n ln -s \"$ci_node_deps/yaml\" node_modules/yaml\nfi\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nHWLAB_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\nnode 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\nnode - <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(\"/workspace/source/ci-plan.json\", \"utf8\"));\nconst selected = String(process.env.HWLAB_SELECTED_SERVICES || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst allServices = selected.length > 0 ? selected : [\"hwlab-cloud-api\",\"hwlab-cloud-web\",\"hwlab-gateway\",\"hwlab-edge-proxy\",\"hwlab-agent-skills\"];\nconst affected = new Set(plan.affectedServices || []);\nconst plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null;\nconst selectedSet = new Set(selected);\nconst byService = new Map((plan.services || []).map((service) => [service.serviceId, service]));\nconst entries = allServices.map((serviceId) => {\n const service = byService.get(serviceId) || {};\n const serviceSelected = selectedSet.has(serviceId);\n const rolloutAffected = serviceSelected && affected.has(serviceId);\n const envReuse = service.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.envReuse === true;\n const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected));\n return {\n serviceId,\n selected: serviceSelected,\n affected: rolloutAffected,\n buildRequired,\n rolloutAffected,\n runtimeMode: service.runtimeMode || \"service-image\",\n envChanged: service.envChanged ?? null,\n codeChanged: service.codeChanged ?? null\n };\n});\nfor (const entry of entries) {\n fs.writeFileSync(\"/tekton/results/affected-\" + entry.serviceId, entry.affected ? \"true\" : \"false\");\n fs.writeFileSync(\"/tekton/results/build-\" + entry.serviceId, entry.buildRequired ? \"true\" : \"false\");\n}\nfs.writeFileSync(\"/workspace/source/affected-services.json\", JSON.stringify({\n sourceCommitId: plan.sourceCommitId,\n affectedServices: plan.affectedServices || [],\n rolloutServices: plan.rolloutServices || plan.affectedServices || [],\n buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),\n reusedServices: plan.reusedServices || [],\n buildSkippedCount: plan.buildSkippedCount || 0,\n envArtifactGroups: plan.envArtifactGroups || [],\n changedPathSummary: plan.changedPathSummary || null,\n ciCdPlan: plan.ciCdPlan || null,\n services: plan.services || [],\n entries\n}, null, 2) + String.fromCharCode(10));\nconsole.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 || [] }));\nNODE\n"
|
||||
"script": "#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps/yaml\" ] && [ ! -e node_modules/yaml ]; then\n mkdir -p node_modules\n ln -s \"$ci_node_deps/yaml\" node_modules/yaml\nfi\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nHWLAB_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\nnode 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\nnode - <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(\"/workspace/source/ci-plan.json\", \"utf8\"));\nconst selected = String(process.env.HWLAB_SELECTED_SERVICES || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst allServices = selected.length > 0 ? selected : [\"hwlab-cloud-api\",\"hwlab-cloud-web\",\"hwlab-gateway\",\"hwlab-edge-proxy\",\"hwlab-agent-skills\"];\nconst affected = new Set(plan.affectedServices || []);\nconst plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null;\nconst selectedSet = new Set(selected);\nconst byService = new Map((plan.services || []).map((service) => [service.serviceId, service]));\nconst entries = allServices.map((serviceId) => {\n const service = byService.get(serviceId) || {};\n const serviceSelected = selectedSet.has(serviceId);\n const rolloutAffected = serviceSelected && affected.has(serviceId);\n const envReuse = service.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.envReuse === true;\n const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected));\n return {\n serviceId,\n selected: serviceSelected,\n affected: rolloutAffected,\n buildRequired,\n rolloutAffected,\n runtimeMode: service.runtimeMode || \"service-image\",\n envChanged: service.envChanged ?? null,\n codeChanged: service.codeChanged ?? null\n };\n});\nfor (const entry of entries) {\n fs.writeFileSync(\"/tekton/results/affected-\" + entry.serviceId, entry.affected ? \"true\" : \"false\");\n fs.writeFileSync(\"/tekton/results/build-\" + entry.serviceId, entry.buildRequired ? \"true\" : \"false\");\n}\nfs.writeFileSync(\"/workspace/source/affected-services.json\", JSON.stringify({\n sourceCommitId: plan.sourceCommitId,\n affectedServices: plan.affectedServices || [],\n rolloutServices: plan.rolloutServices || plan.affectedServices || [],\n buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),\n rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [],\n reusedServices: plan.reusedServices || [],\n serviceReusedCount: plan.serviceReusedCount || 0,\n imageBuildSkippedServices: plan.imageBuildSkippedServices || [],\n buildSkippedCount: plan.buildSkippedCount || 0,\n artifactCatalog: plan.artifactCatalog || null,\n envArtifactGroups: plan.envArtifactGroups || [],\n changedPathSummary: plan.changedPathSummary || null,\n ciCdPlan: plan.ciCdPlan || null,\n services: plan.services || [],\n entries\n}, null, 2) + String.fromCharCode(10));\nconsole.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 || [] }));\nNODE\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
};
|
||||
|
||||
+51
-3
@@ -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,8 +27,14 @@ if (args.help) {
|
||||
|
||||
const noDepsPlan = tryCreateNoDepsPlan(args);
|
||||
if (noDepsPlan) {
|
||||
process.stdout.write(args.pretty ? `${JSON.stringify(noDepsPlan, null, 2)}\n` : `${JSON.stringify(noDepsPlan)}\n`);
|
||||
process.exit(0);
|
||||
const artifactCatalog = await noDepsArtifactCatalogSummary(args);
|
||||
if (catalogAllowsNoDepsReuse(artifactCatalog, args.services)) {
|
||||
noDepsPlan.artifactCatalog = artifactCatalog;
|
||||
noDepsPlan.compatibility.artifactCatalog = 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);
|
||||
}
|
||||
}
|
||||
|
||||
const { createCiPlan } = await import("./src/ci-plan-lib.mjs");
|
||||
@@ -37,7 +45,8 @@ function tryCreateNoDepsPlan(options) {
|
||||
const services = Array.isArray(options.services) ? options.services.filter(Boolean) : [];
|
||||
if (services.length === 0) return null;
|
||||
const targetRef = options.targetRef ?? "HEAD";
|
||||
const baseRef = options.baseRef ?? (gitValue(["rev-parse", `${targetRef}^`]) || targetRef);
|
||||
const inferredBaseRef = options.baseRef ?? tryGitValue(["rev-parse", `${targetRef}^`]);
|
||||
const baseRef = inferredBaseRef || targetRef;
|
||||
const changedPaths = gitValue(["diff", "--name-only", baseRef, targetRef]).split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const summary = classifyNoDepsGlobalChange(changedPaths);
|
||||
if (!summary.gitopsOnly && !summary.docsOnly && !summary.testOnly && summary.summary !== "no-source-diff") return null;
|
||||
@@ -73,7 +82,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 +101,7 @@ function tryCreateNoDepsPlan(options) {
|
||||
ciCdPlan: {
|
||||
willBuild: [],
|
||||
willRollout: [],
|
||||
willRolloutWithoutImageBuild: [],
|
||||
willReuse: services,
|
||||
willRunGitopsPromote: summary.gitopsOnly,
|
||||
noImageBuildReason: summary.summary
|
||||
@@ -96,6 +109,33 @@ 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,
|
||||
coverage: null,
|
||||
bootstrapReason: null
|
||||
};
|
||||
}
|
||||
return readArtifactCatalogSummary({ repoRoot: process.cwd(), catalogPath });
|
||||
}
|
||||
|
||||
function catalogAllowsNoDepsReuse(artifactCatalog, selectedServices) {
|
||||
if (artifactCatalog?.status !== "hydrated" || artifactCatalog.authority !== "gitops-branch") return false;
|
||||
const catalogServiceIds = artifactCatalog.coverage?.catalogServiceIds;
|
||||
if (!Array.isArray(catalogServiceIds)) return false;
|
||||
return selectedServices.every((serviceId) => catalogServiceIds.includes(serviceId));
|
||||
}
|
||||
|
||||
function classifyNoDepsGlobalChange(changedPaths) {
|
||||
const relevant = changedPaths.filter(Boolean);
|
||||
const testOnly = relevant.length > 0 && relevant.every(isNoDepsTestOnlyPath);
|
||||
@@ -150,6 +190,14 @@ function gitValue(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).trim();
|
||||
}
|
||||
|
||||
function tryGitValue(args) {
|
||||
try {
|
||||
return gitValue(args);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function booleanEnv(value) {
|
||||
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
@@ -54,6 +54,342 @@ 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"), "<html>v2</html>\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 = JSON.parse(await readFile(target.pipelinePath, "utf8"));
|
||||
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 readFile(target.pacPath, "utf8");
|
||||
assert.ok(pac.includes(`pipelinesascode.tekton.dev/pipeline: "${target.pipelinePath}"`), `${target.node} PaC remote Pipeline path`);
|
||||
assert.ok(pac.includes(`pipelineRef:\n 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");
|
||||
@@ -127,6 +463,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 () => {
|
||||
@@ -1630,6 +1968,11 @@ function uniquePreserveOrder(values) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
// Restore the node/lane artifact catalog from the GitOps branch before CI planning.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import {
|
||||
artifactCatalogAuthorityPath,
|
||||
artifactCatalogServiceCoverage,
|
||||
createBootstrapArtifactCatalogAuthority,
|
||||
createHydratedArtifactCatalogAuthority
|
||||
} from "../src/artifact-catalog-authority.mjs";
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
function env(name) {
|
||||
@@ -12,6 +19,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 +35,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(),
|
||||
@@ -43,35 +44,160 @@ function git(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function gitProbe(args, options = {}) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: options.timeoutMs ?? 45_000
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return {
|
||||
status: result.status ?? 1,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const selectedServiceCoverageInput = uniqueSorted(selectedServices);
|
||||
if (selectedServiceCoverageInput.length !== selectedServices.length) {
|
||||
throw new Error("selected services contain duplicate service ids");
|
||||
}
|
||||
const branchProbe = gitProbe(["ls-remote", "--exit-code", "--heads", remote, `refs/heads/${gitopsBranch}`], { timeoutMs: 45_000 });
|
||||
if (branchProbe.status === 2) {
|
||||
restoreBootstrapOrMissing({ reason: "gitops-branch-missing", selectedServices: selectedServiceCoverageInput });
|
||||
process.exit(0);
|
||||
}
|
||||
if (branchProbe.status !== 0) {
|
||||
throw new Error(`gitops branch probe failed: ${boundedError(branchProbe.stderr)}`);
|
||||
}
|
||||
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 catalogProbe = gitProbe(["ls-tree", "-z", "--full-tree", "--name-only", "FETCH_HEAD", "--", catalogPath], { timeoutMs: 15_000 });
|
||||
if (catalogProbe.status !== 0) {
|
||||
throw new Error(`gitops catalog tree probe failed: ${boundedError(catalogProbe.stderr)}`);
|
||||
}
|
||||
if (catalogProbe.stdout.length === 0) {
|
||||
restoreBootstrapOrMissing({ reason: "gitops-catalog-missing", selectedServices: selectedServiceCoverageInput });
|
||||
process.exit(0);
|
||||
}
|
||||
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 coverage = artifactCatalogServiceCoverage(parsed, selectedServiceCoverageInput);
|
||||
const authorityPath = artifactCatalogAuthorityPath(catalogPath);
|
||||
const authority = createHydratedArtifactCatalogAuthority({
|
||||
catalog: parsed,
|
||||
catalogPath,
|
||||
gitopsBranch,
|
||||
gitopsCommitId,
|
||||
selectedServices: selectedServiceCoverageInput
|
||||
});
|
||||
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,
|
||||
coverage,
|
||||
bytes: Buffer.byteLength(content)
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
status: "skipped",
|
||||
status: "failed",
|
||||
reason: "gitops-catalog-unavailable",
|
||||
error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500)
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function restoreBootstrapOrMissing({ reason, selectedServices }) {
|
||||
const authorityPath = artifactCatalogAuthorityPath(catalogPath);
|
||||
if (!existsSync(catalogPath)) {
|
||||
if (existsSync(authorityPath)) throw new Error(`artifact catalog authority exists without catalog ${catalogPath}`);
|
||||
emit({
|
||||
status: "missing",
|
||||
reason,
|
||||
authority: "none",
|
||||
selectedServiceCount: selectedServices.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
const sourceCommitId = git(["rev-parse", "HEAD"], { timeoutMs: 15_000 }).trim();
|
||||
validateBootstrapCatalog({ catalog, sourceCommitId, selectedServices });
|
||||
const authority = createBootstrapArtifactCatalogAuthority({
|
||||
catalog,
|
||||
catalogPath,
|
||||
sourceCommitId,
|
||||
reason,
|
||||
selectedServices
|
||||
});
|
||||
writeFileSync(authorityPath, JSON.stringify(authority, null, 2) + "\n");
|
||||
emit({
|
||||
status: "bootstrap",
|
||||
reason,
|
||||
authority: authority.authority,
|
||||
authorityPath,
|
||||
sourceCommitId,
|
||||
serviceCount: authority.serviceCount,
|
||||
catalogSha256: authority.catalogSha256,
|
||||
coverage: authority.coverage
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function validateBootstrapCatalog({ catalog, sourceCommitId, selectedServices }) {
|
||||
if (catalog?.catalogVersion !== "v1" || catalog?.kind !== "hwlab-artifact-catalog" || catalog?.artifactState !== "contract-skeleton" || catalog?.environment !== "v02" || catalog?.profile !== "v02") {
|
||||
throw new Error(`existing source catalog is not an allowed v02 contract skeleton for ${catalogPath}`);
|
||||
}
|
||||
if (catalog?.publish?.sourceCommitId !== sourceCommitId) {
|
||||
throw new Error(`v02 contract skeleton source commit does not match HEAD for ${catalogPath}`);
|
||||
}
|
||||
const coverage = artifactCatalogServiceCoverage(catalog, selectedServices);
|
||||
if (coverage.status !== "exact") {
|
||||
throw new Error(`v02 contract skeleton service coverage is ${coverage.status} for ${catalogPath}`);
|
||||
}
|
||||
const unsafe = catalog.services.filter((service) => (
|
||||
service?.sourceCommitId !== sourceCommitId
|
||||
|| service?.buildBackend !== "contract-skeleton"
|
||||
|| service?.digest !== null
|
||||
|| (service?.environmentDigest !== undefined && service?.environmentDigest !== null)
|
||||
));
|
||||
if (unsafe.length > 0) throw new Error(`v02 contract skeleton contains reusable artifacts for ${catalogPath}`);
|
||||
}
|
||||
|
||||
function boundedError(value) {
|
||||
const result = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
return result ? result.slice(0, 300) : "exit-without-stderr";
|
||||
}
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
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 artifactCatalogServiceCoverage(catalog, selectedServices) {
|
||||
if (!Array.isArray(catalog?.services)) throw new Error("artifact catalog services must be an array");
|
||||
const selectedServiceIds = normalizedServiceIds(selectedServices, "selected services");
|
||||
const catalogServiceIds = normalizedServiceIds(catalog.services.map((service) => service?.serviceId), "artifact catalog services");
|
||||
const missingSelectedServices = selectedServiceIds.filter((serviceId) => !catalogServiceIds.includes(serviceId));
|
||||
const extraCatalogServices = catalogServiceIds.filter((serviceId) => !selectedServiceIds.includes(serviceId));
|
||||
return {
|
||||
status: missingSelectedServices.length > 0
|
||||
? (extraCatalogServices.length > 0 ? "divergent" : "partial")
|
||||
: (extraCatalogServices.length > 0 ? "superset" : "exact"),
|
||||
selectedServiceCount: selectedServiceIds.length,
|
||||
selectedServicePresentCount: selectedServiceIds.length - missingSelectedServices.length,
|
||||
catalogServiceCount: catalogServiceIds.length,
|
||||
selectedServiceIds,
|
||||
catalogServiceIds,
|
||||
missingSelectedServices,
|
||||
extraCatalogServices
|
||||
};
|
||||
}
|
||||
|
||||
export function createHydratedArtifactCatalogAuthority({ catalog, catalogPath, gitopsBranch, gitopsCommitId, selectedServices }) {
|
||||
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),
|
||||
coverage: artifactCatalogServiceCoverage(catalog, selectedServices)
|
||||
};
|
||||
}
|
||||
|
||||
export function createBootstrapArtifactCatalogAuthority({ catalog, catalogPath, sourceCommitId, reason, selectedServices }) {
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
status: "bootstrap",
|
||||
authority: "source-bootstrap",
|
||||
catalogPath: normalizeCatalogPath(catalogPath),
|
||||
sourceCommitId: text(sourceCommitId),
|
||||
bootstrapReason: text(reason),
|
||||
catalogSourceCommitId: artifactCatalogSourceCommitId(catalog),
|
||||
serviceCount: Array.isArray(catalog?.services) ? catalog.services.length : 0,
|
||||
catalogSha256: artifactCatalogSha256(catalog),
|
||||
coverage: artifactCatalogServiceCoverage(catalog, selectedServices)
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
coverage: null,
|
||||
bootstrapReason: 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,
|
||||
coverage: null,
|
||||
bootstrapReason: null
|
||||
};
|
||||
}
|
||||
|
||||
const coverage = validateCatalogAuthority({ authority, catalog: loadedCatalog, catalogPath: normalizedCatalogPath, serviceCount, catalogSha256 });
|
||||
if (authority.authority === "gitops-branch") {
|
||||
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,
|
||||
coverage,
|
||||
bootstrapReason: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "bootstrap",
|
||||
authority: "source-bootstrap",
|
||||
catalogPath: normalizedCatalogPath,
|
||||
authorityPath: normalizeCatalogPath(authorityPath),
|
||||
gitopsBranch: null,
|
||||
gitopsCommitId: null,
|
||||
sourceCommitId: text(authority.sourceCommitId),
|
||||
catalogSourceCommitId: text(authority.catalogSourceCommitId) || catalogSourceCommitId,
|
||||
serviceCount,
|
||||
catalogSha256,
|
||||
coverage,
|
||||
bootstrapReason: text(authority.bootstrapReason)
|
||||
};
|
||||
}
|
||||
|
||||
function validateCatalogAuthority({ authority, catalog, catalogPath, serviceCount, catalogSha256 }) {
|
||||
const gitopsAuthority = authority.schemaVersion === "v1" && authority.status === "hydrated" && authority.authority === "gitops-branch";
|
||||
const bootstrapAuthority = authority.schemaVersion === "v1" && authority.status === "bootstrap" && authority.authority === "source-bootstrap";
|
||||
if (!gitopsAuthority && !bootstrapAuthority) {
|
||||
throw new Error(`artifact catalog authority for ${catalogPath} is not a supported authority 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}`);
|
||||
}
|
||||
const coverage = artifactCatalogServiceCoverage(catalog, authority.coverage?.selectedServiceIds);
|
||||
validateCoverageRecord(authority.coverage, coverage, catalogPath);
|
||||
if (gitopsAuthority && (!text(authority.gitopsBranch) || !/^[a-f0-9]{40}$/u.test(text(authority.gitopsCommitId) ?? ""))) {
|
||||
throw new Error(`artifact catalog authority gitops provenance is incomplete for ${catalogPath}`);
|
||||
}
|
||||
if (bootstrapAuthority) {
|
||||
const sourceCommitId = text(authority.sourceCommitId);
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceCommitId ?? "") || !text(authority.bootstrapReason)) {
|
||||
throw new Error(`artifact catalog bootstrap authority is incomplete for ${catalogPath}`);
|
||||
}
|
||||
if (sourceCommitId !== artifactCatalogSourceCommitId(catalog)) {
|
||||
throw new Error(`artifact catalog bootstrap source commit mismatch for ${catalogPath}`);
|
||||
}
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
function normalizedServiceIds(values, label) {
|
||||
if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`);
|
||||
const normalized = values.map(text);
|
||||
if (normalized.some((value) => value === null)) throw new Error(`${label} contains an empty service id`);
|
||||
const deduplicated = unique(normalized);
|
||||
if (deduplicated.length !== normalized.length) throw new Error(`${label} contains duplicate service ids`);
|
||||
return deduplicated.sort();
|
||||
}
|
||||
|
||||
function validateCoverageRecord(actual, expected, catalogPath) {
|
||||
const scalarFields = ["status", "selectedServiceCount", "selectedServicePresentCount", "catalogServiceCount"];
|
||||
const arrayFields = ["selectedServiceIds", "catalogServiceIds", "missingSelectedServices", "extraCatalogServices"];
|
||||
const expectedFields = [...scalarFields, ...arrayFields].sort();
|
||||
const actualFields = actual && typeof actual === "object" && !Array.isArray(actual)
|
||||
? Object.keys(actual).sort()
|
||||
: [];
|
||||
if (!sameArray(actualFields, expectedFields)) {
|
||||
throw new Error(`artifact catalog authority coverage fields mismatch for ${catalogPath}`);
|
||||
}
|
||||
for (const field of scalarFields) {
|
||||
if (actual[field] !== expected[field]) {
|
||||
throw new Error(`artifact catalog authority coverage ${field} mismatch for ${catalogPath}`);
|
||||
}
|
||||
}
|
||||
for (const field of arrayFields) {
|
||||
if (!sameArray(actual[field], expected[field])) {
|
||||
throw new Error(`artifact catalog authority coverage ${field} mismatch for ${catalogPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameArray(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user