252 lines
9.7 KiB
JavaScript
252 lines
9.7 KiB
JavaScript
#!/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-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: [],
|
|
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");
|
|
}
|