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