Files
pikasTech-HWLAB/scripts/ci-plan.mjs
T

245 lines
9.2 KiB
JavaScript

#!/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([
"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;
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 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 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: [],
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 === "--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");
}