#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { readArtifactCatalogSummary } from "./src/artifact-catalog-authority.mjs"; import { attachCiPlanIdentity } from "./src/ci-plan-identity.mjs"; 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) { const artifactCatalog = await noDepsArtifactCatalogSummary(args); if (catalogAllowsNoDepsReuse(artifactCatalog, args.services)) { noDepsPlan.artifactCatalog = artifactCatalog; noDepsPlan.compatibility.artifactCatalog = artifactCatalog.catalogPath; noDepsPlan.compatibility.artifactCatalogAuthority = artifactCatalog.authority; const identifiedPlan = attachCiPlanIdentity(noDepsPlan, args); process.stdout.write(args.pretty ? `${JSON.stringify(identifiedPlan, null, 2)}\n` : `${JSON.stringify(identifiedPlan)}\n`); process.exit(0); } } const { createCiPlan } = await import("./src/ci-plan-lib.mjs"); const plan = attachCiPlanIdentity(await createCiPlan(args), args); process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`); function tryCreateNoDepsPlan(options) { if (options.verifyReuseRegistry === true) return null; const services = Array.isArray(options.services) ? options.services.filter(Boolean) : []; if (services.length === 0) return null; const targetRef = options.targetRef ?? "HEAD"; 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; const sourceCommitId = gitValue(["rev-parse", targetRef]); const baseCommitId = gitValue(["rev-parse", baseRef]); const shortCommitId = gitValue(["rev-parse", "--short=7", targetRef]); return { planVersion: "v1", sourceCommitId, baseCommitId, 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: [], rolloutWithoutImageBuildServices: [], reusedServices: services, serviceReusedCount: services.length, imageBuildSkippedServices: services, buildSkippedCount: services.length, services: services.map((serviceId) => ({ serviceId, affected: false, buildRequired: false, skipImageBuild: true, runtimeMode: "env-reuse-gitea-checkout", envReuse: true, envChanged: false, codeChanged: false, reason: [summary.summary === "no-source-diff" ? "no-source-diff" : `${summary.summary}-no-image-build`] })), ciCdPlan: { willBuild: [], willRollout: [], willRolloutWithoutImageBuild: [], willReuse: services, willRunGitopsPromote: summary.gitopsOnly, noImageBuildReason: summary.summary } }; } 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); 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 tryGitValue(args) { try { return gitValue(args); } catch { return ""; } } 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) { 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 === "--deploy-config") parsed.deployConfigPath = readOption(argv, ++index, arg); else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg); else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg); else if (arg === "--verify-reuse-registry") parsed.verifyReuseRegistry = true; else if (arg === "--no-verify-reuse-registry") parsed.verifyReuseRegistry = false; else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg); else if (arg === "--release-target") parsed.releaseTarget = readOption(argv, ++index, arg); else if (arg === "--release-consumer") parsed.releaseConsumer = 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/ci-plan.mjs [--lane v02|v03] [--base-ref REF] [--target-ref REF] [--verify-reuse-registry] [--pretty]", "", "Plan node monorepo image builds without mutating CI/CD state.", "For runtime lane env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.", "", "examples:", " node scripts/ci-plan.mjs --lane v02 --base-ref HEAD~1 --target-ref HEAD --pretty", " node scripts/ci-plan.mjs --lane v03 --base-ref HEAD~1 --target-ref HEAD --pretty", " node scripts/ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty" ].join("\n"); }