diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 8070dc90..440ee000 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -1,16 +1,159 @@ #!/usr/bin/env node -import { createCiPlan } from "./src/ci-plan-lib.mjs"; +import { execFileSync } from "node:child_process"; const args = parseArgs(process.argv.slice(2)); +const noDepsGitOpsOnlyPaths = Object.freeze([ + "deploy/gitops/", + "deploy/frp/", + "scripts/ci-plan.mjs", + "scripts/gitops-render.mjs", + "scripts/src/runtime-lane.ts", + "tsconfig.gitops.json" +]); + +const noDepsDocsOnlyPaths = Object.freeze([ + "docs/", + "README.md", + "AGENTS.md" +]); + if (args.help) { process.stdout.write(`${usage()}\n`); process.exit(0); } +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 { createCiPlan } = await import("./src/ci-plan-lib.mjs"); const plan = await createCiPlan(args); process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`); +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 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; + const sourceCommitId = gitValue(["rev-parse", targetRef]); + const shortCommitId = gitValue(["rev-parse", "--short=7", targetRef]); + return { + planVersion: "v1", + sourceCommitId, + shortCommitId, + baseRef, + targetRef, + compatibility: { + deployConfig: options.deployConfigPath ?? "deploy/deploy.yaml", + artifactCatalog: options.artifactCatalogPath ?? null, + lane: options.lane ?? "v02", + ciContractSource: "scripts/ci-plan.mjs", + mode: "no-deps-global-change-fast-path", + currentRenderCompatible: true, + plannerMutatesDeployJson: false, + serviceIdSource: "cli-services", + defaultComponentLazyBuild: true, + envReuseCodeOnlyFastLane: true, + fullBuildFallback: false + }, + inputs: { + registryPrefix: options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab", + serviceCount: services.length, + verifyReuseRegistry: options.verifyReuseRegistry ?? booleanEnv(process.env.HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY) + }, + changedPaths, + changedPathSummary: summary, + imageBuildRequired: false, + affectedServices: [], + rolloutServices: [], + buildServices: [], + reusedServices: services, + buildSkippedCount: services.length, + services: services.map((serviceId) => ({ + serviceId, + affected: false, + buildRequired: false, + skipImageBuild: true, + runtimeMode: "env-reuse-git-mirror-checkout", + envReuse: true, + envChanged: false, + codeChanged: false, + reason: [summary.summary === "no-source-diff" ? "no-source-diff" : `${summary.summary}-no-image-build`] + })), + ciCdPlan: { + willBuild: [], + willRollout: [], + willReuse: services, + willRunGitopsPromote: summary.gitopsOnly, + noImageBuildReason: summary.summary + } + }; +} + +function classifyNoDepsGlobalChange(changedPaths) { + const relevant = changedPaths.filter(Boolean); + const testOnly = relevant.length > 0 && relevant.every(isNoDepsTestOnlyPath); + const docsOnly = relevant.length > 0 && relevant.every((item) => isNoDepsDocsOnlyPath(item) || isNoDepsTestOnlyPath(item)); + const gitopsOnly = relevant.length > 0 && relevant.every((item) => isNoDepsGitOpsOnlyPath(item) || isNoDepsDocsOnlyPath(item) || isNoDepsTestOnlyPath(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" + }; +} + +function isNoDepsGitOpsOnlyPath(filePath) { + return noDepsGitOpsOnlyPaths.some((pattern) => pathMatches(pattern, filePath)); +} + +function isNoDepsDocsOnlyPath(filePath) { + const normalized = normalizeRepoPath(filePath); + if (normalized.startsWith("internal/agent/prompts/")) return false; + if (normalized.startsWith("skills/")) return false; + return noDepsDocsOnlyPaths.some((pattern) => pathMatches(pattern, normalized)) || normalized.endsWith(".md"); +} + +function isNoDepsTestOnlyPath(filePath) { + const normalized = normalizeRepoPath(filePath); + return /\.test\.(mjs|ts)$/u.test(normalized) || normalized.includes("/__tests__/") || normalized.includes("/fixtures/"); +} + +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; +} + +function normalizeRepoPath(value) { + return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").replace(/^\/+/, "").trim(); +} + +function gitValue(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).trim(); +} + +function booleanEnv(value) { + return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase()); +} + function parseArgs(argv) { const parsed = { pretty: false }; for (let index = 0; index < argv.length; index += 1) {