785 lines
35 KiB
JavaScript
785 lines
35 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { readStructuredFileIfPresent } from "./structured-config.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export const CI_PLAN_VERSION = "v1";
|
|
export const ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
|
|
export const V02_ENV_REUSE_RUNTIME_MODE = ENV_REUSE_RUNTIME_MODE;
|
|
|
|
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
|
|
"package.json",
|
|
"package-lock.json"
|
|
]);
|
|
|
|
export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([
|
|
"dependencies",
|
|
"optionalDependencies",
|
|
"peerDependencies",
|
|
"overrides",
|
|
"resolutions",
|
|
"engines",
|
|
"packageManager",
|
|
"type"
|
|
]);
|
|
|
|
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/gitops-render.mjs",
|
|
"scripts/src/runtime-lane.ts",
|
|
"tsconfig.gitops.json"
|
|
]);
|
|
|
|
export async function createCiPlan(options = {}) {
|
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
const deployConfigPath = options.deployConfigPath ?? "deploy/deploy.yaml";
|
|
const targetRef = options.targetRef ?? "HEAD";
|
|
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
|
|
const lane = options.lane ?? "v02";
|
|
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 deployJson = await readStructuredFileIfPresent(repoRoot, deployConfigPath, {});
|
|
const laneConfig = laneDeployConfig(deployJson, lane);
|
|
assertLaneEnvReuseConfig(laneConfig, lane);
|
|
const artifactCatalogPath = options.artifactCatalogPath ?? defaultArtifactCatalogPath(laneConfig, lane);
|
|
const artifactCatalog = await readStructuredFileIfPresent(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 semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
|
|
const serviceIdResolution = resolveServiceIds({ deployJson, options, laneConfig, lane });
|
|
const envReuseRecipe = envReuseRecipeForLane(deployJson, lane);
|
|
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig, lane);
|
|
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
|
|
const globalChange = classifyGlobalChange(normalizedChangedPaths);
|
|
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
|
|
...envReuseRecipe.additionalEnvPaths,
|
|
...DEFAULT_RUNTIME_DEP_PATHS
|
|
], { semanticPackageJson: true });
|
|
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 rawRuntimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
|
|
const runtimeDepMatches = rawRuntimeDepMatches.filter((item) => semanticRuntimeDepPaths.has(item));
|
|
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
|
|
const envReuse = envReuseServices.has(model.serviceId);
|
|
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId, lane) : null;
|
|
const envInputPaths = envReuse ? uniqueSorted([
|
|
...model.runtimeDeps,
|
|
...envReuseRecipe.launcherInputPaths
|
|
]) : [];
|
|
const codeInputPaths = envReuse ? uniqueSorted([
|
|
...model.componentPaths,
|
|
...model.sharedPaths,
|
|
bootSh
|
|
]) : [];
|
|
const envMatches = envReuse
|
|
? matchingPaths(normalizedChangedPaths, envInputPaths)
|
|
.filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item))
|
|
: [];
|
|
const codeMatches = envReuse ? matchingPaths(normalizedChangedPaths, codeInputPaths) : [];
|
|
const relevantEnvMatches = envMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
|
|
const relevantCodeMatches = codeMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
|
|
const imageRelevantChangedPaths = uniqueSorted([
|
|
...directMatches,
|
|
...sharedMatches,
|
|
...runtimeDepMatches,
|
|
...buildSystemMatches
|
|
].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)));
|
|
const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null;
|
|
const environmentInputHash = envReuse ? await hashEnvReuseInputs(repoRoot, targetRef, envInputPaths, envReuseRecipe.publicRecipe, {
|
|
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
|
|
semanticPackageJson: true
|
|
}) : null;
|
|
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
|
|
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
|
|
}) : null;
|
|
const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
|
|
const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
|
|
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
|
|
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null;
|
|
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
|
|
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),
|
|
semanticPackageJson: true
|
|
});
|
|
const buildArgsHash = stableHash({
|
|
serviceId: model.serviceId,
|
|
baseImage,
|
|
registryPrefix,
|
|
runtimeKind: model.runtimeKind,
|
|
entrypoint: model.entrypoint
|
|
});
|
|
const catalogSourceCommitId = !envReuse ? text(catalogRecord?.sourceCommitId) : "";
|
|
const catalogComponentInputHash = !envReuse ? text(catalogRecord?.componentInputHash) : "";
|
|
const catalogComponentInputHashAtSource = !envReuse && catalogSourceCommitId
|
|
? await hashGitPathsIfCommitExists(repoRoot, catalogSourceCommitId, componentInputPaths, {
|
|
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
|
|
semanticPackageJson: true
|
|
})
|
|
: null;
|
|
const catalogComponentProvenance = !envReuse ? catalogComponentProvenanceStatus({
|
|
catalogRecord,
|
|
catalogSourceCommitId,
|
|
catalogComponentInputHash,
|
|
catalogComponentInputHashAtSource,
|
|
componentInputHash,
|
|
dockerfileHash,
|
|
buildArgsHash
|
|
}) : null;
|
|
const catalogRecordForReuse = catalogRecord && catalogComponentProvenance ? {
|
|
...catalogRecord,
|
|
componentProvenanceStatus: catalogComponentProvenance.status,
|
|
componentProvenanceReason: catalogComponentProvenance.reason
|
|
} : catalogRecord;
|
|
const componentInputChanged = !envReuse && catalogComponentProvenance?.safeToReuse !== true;
|
|
const catalogReuse = envReuse
|
|
? envReuseCandidate(catalogRecord, envChanged)
|
|
: reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged);
|
|
const affected = envReuse
|
|
? Boolean(envChanged || codeChanged)
|
|
: imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
|
|
const buildRequired = envReuse ? envChanged === true : affected;
|
|
services.push({
|
|
serviceId: model.serviceId,
|
|
runtimeKind: model.runtimeKind,
|
|
entrypoint: model.entrypoint,
|
|
artifactKind: model.artifactKind,
|
|
healthPath: model.healthPath,
|
|
healthPort: model.healthPort,
|
|
componentPaths: model.componentPaths,
|
|
sharedPaths: model.sharedPaths,
|
|
runtimeDeps: model.runtimeDeps,
|
|
buildSystemPaths: model.buildSystemPaths,
|
|
envReuseRecipe: envReuse ? envReuseRecipe.publicRecipe : null,
|
|
componentCommitId,
|
|
componentInputHash,
|
|
catalogSourceCommitId: catalogSourceCommitId || null,
|
|
catalogComponentInputHash: catalogComponentInputHash || null,
|
|
catalogComponentInputHashAtSource,
|
|
catalogComponentProvenance,
|
|
dockerfileHash,
|
|
baseImageReference: baseImage,
|
|
baseImageDigest: digestFromImageReference(baseImage),
|
|
buildArgsHash,
|
|
changedPaths: imageRelevantChangedPaths,
|
|
affected,
|
|
buildRequired,
|
|
componentInputChanged,
|
|
runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image",
|
|
envReuse,
|
|
envChanged,
|
|
codeChanged,
|
|
environmentInputHash,
|
|
codeInputHash,
|
|
bootRepo: envReuse ? canonicalBootRepo(deployJson, lane) : null,
|
|
bootCommit: envReuse ? sourceCommitId : null,
|
|
bootSh,
|
|
environmentImage,
|
|
environmentDigest,
|
|
envChangedPaths: relevantEnvMatches,
|
|
codeChangedPaths: relevantCodeMatches,
|
|
reason: affected
|
|
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, codeChanged })
|
|
: reasonForUnchanged(globalChange),
|
|
reuse: catalogReuse
|
|
});
|
|
}
|
|
|
|
const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId);
|
|
const buildServices = services.filter((service) => service.buildRequired).map((service) => service.serviceId);
|
|
const reusedServices = services.filter((service) => !service.affected).map((service) => service.serviceId);
|
|
return {
|
|
planVersion: CI_PLAN_VERSION,
|
|
sourceCommitId,
|
|
shortCommitId,
|
|
baseRef,
|
|
targetRef,
|
|
compatibility: {
|
|
deployConfig: deployConfigPath,
|
|
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
|
|
lane,
|
|
ciContractSource: "scripts/gitops-render.mjs",
|
|
mode: "advisory-read-only",
|
|
currentRenderCompatible: true,
|
|
plannerMutatesDeployJson: false,
|
|
serviceIdSource: serviceIdResolution.source,
|
|
serviceDeclarationSource: `deploy.lanes.${lane}.serviceDeclarations`,
|
|
envRecipeSource: `deploy.lanes.${lane}.envRecipe`,
|
|
envReuseServiceIds: [...envReuseServices],
|
|
v02EnvReuseServiceIds: lane === "v02" ? [...envReuseServices] : [],
|
|
defaultComponentLazyBuild: true,
|
|
envReuseCodeOnlyFastLane: true,
|
|
fullBuildFallback: false
|
|
},
|
|
inputs: {
|
|
registryPrefix,
|
|
baseImage,
|
|
serviceCount: services.length
|
|
},
|
|
changedPaths: normalizedChangedPaths,
|
|
changedPathSummary: globalChange,
|
|
imageBuildRequired: buildServices.length > 0,
|
|
affectedServices,
|
|
rolloutServices: affectedServices,
|
|
buildServices,
|
|
reusedServices,
|
|
buildSkippedCount: services.length - buildServices.length,
|
|
services,
|
|
ciCdPlan: {
|
|
willBuild: buildServices,
|
|
willRollout: affectedServices,
|
|
willReuse: reusedServices,
|
|
willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0,
|
|
noImageBuildReason: buildServices.length === 0
|
|
? (affectedServices.length > 0 ? "env-reuse-code-only-rollout" : globalChange.summary)
|
|
: null
|
|
}
|
|
};
|
|
}
|
|
|
|
export function componentModelsForServices(serviceIds, laneConfig = null, lane = "v02") {
|
|
return serviceIds.map((serviceId) => {
|
|
const declaration = serviceDeclarationFor(laneConfig, serviceId, lane);
|
|
return {
|
|
serviceId,
|
|
runtimeKind: declaration.runtimeKind,
|
|
entrypoint: declaration.entrypoint,
|
|
artifactKind: declaration.artifactKind,
|
|
healthPath: declaration.healthPath,
|
|
healthPort: declaration.healthPort,
|
|
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
|
|
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
|
|
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
|
|
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig, lane).additionalEnvPaths)
|
|
};
|
|
});
|
|
}
|
|
|
|
function serviceDeclarationFor(laneConfig, serviceId, lane) {
|
|
const configured = laneConfig?.serviceDeclarations?.[serviceId];
|
|
const runtimeKind = requireDeclarationString(configured?.runtimeKind, lane, `serviceDeclarations.${serviceId}.runtimeKind`);
|
|
const entrypoint = requireDeclarationString(configured?.entrypoint, lane, `serviceDeclarations.${serviceId}.entrypoint`);
|
|
const artifactKind = normalizeDeclarationString(configured?.artifactKind) || runtimeKind;
|
|
if (!Array.isArray(configured?.componentPaths) || configured.componentPaths.length === 0) {
|
|
throw new Error(`deploy.lanes.${lane}.serviceDeclarations.${serviceId}.componentPaths is required`);
|
|
}
|
|
return {
|
|
runtimeKind,
|
|
entrypoint,
|
|
artifactKind,
|
|
healthPath: normalizeDeclarationString(configured?.healthPath) || "/health/live",
|
|
healthPort: Number.isInteger(configured?.healthPort) ? configured.healthPort : null,
|
|
componentPaths: configured.componentPaths
|
|
};
|
|
}
|
|
|
|
function requireDeclarationString(value, lane, label) {
|
|
const text = normalizeDeclarationString(value);
|
|
if (!text) throw new Error(`deploy.lanes.${lane}.${label} is required`);
|
|
return text;
|
|
}
|
|
|
|
function normalizeDeclarationString(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text || null;
|
|
}
|
|
|
|
function laneDeployConfig(deployJson, lane) {
|
|
return deployJson?.lanes?.[lane] ?? null;
|
|
}
|
|
|
|
function defaultArtifactCatalogPath(laneConfig, lane) {
|
|
return normalizeDeclarationString(laneConfig?.artifactCatalog) ?? `deploy/artifact-catalog.${lane}.json`;
|
|
}
|
|
|
|
export function envReuseRecipeForLane(deployJson, lane = "v02") {
|
|
return envReuseRecipeForLaneConfig(laneDeployConfig(deployJson, lane), lane);
|
|
}
|
|
|
|
function envReuseRecipeForLaneConfig(laneConfig, lane = "v02") {
|
|
const configured = laneConfig?.envRecipe;
|
|
if (!configured) throw new Error(`deploy.lanes.${lane}.envRecipe is required`);
|
|
const osPackages = normalizeStringList(configured.osPackages, []);
|
|
const bunVersion = requireDeclarationString(configured.bunVersion, lane, "envRecipe.bunVersion");
|
|
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, lane, "envRecipe.launcherPath"));
|
|
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
|
|
if (!runtimeNodeModulesPath) throw new Error(`deploy.lanes.${lane}.envRecipe.runtimeNodeModulesPath must be absolute`);
|
|
const additionalEnvPaths = uniqueSorted(normalizeStringList(configured.additionalEnvPaths, []).map(normalizeRepoPath));
|
|
const launcherInputPaths = uniqueSorted([
|
|
launcherPath,
|
|
...additionalEnvPaths
|
|
]);
|
|
const hwpodAliases = normalizeHwpodAliases(configured.hwpodAliases);
|
|
const downloadStack = normalizeDownloadStack(configured.downloadStack, lane);
|
|
if (osPackages.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.osPackages is required`);
|
|
if (additionalEnvPaths.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.additionalEnvPaths is required`);
|
|
if (hwpodAliases.length === 0) throw new Error(`deploy.lanes.${lane}.envRecipe.hwpodAliases is required`);
|
|
return {
|
|
osPackages,
|
|
bunVersion,
|
|
launcherPath,
|
|
runtimeNodeModulesPath,
|
|
additionalEnvPaths,
|
|
launcherInputPaths,
|
|
downloadStack,
|
|
hwpodAliases,
|
|
publicRecipe: {
|
|
osPackages,
|
|
bunVersion,
|
|
launcherPath,
|
|
runtimeNodeModulesPath,
|
|
additionalEnvPaths,
|
|
downloadStack,
|
|
hwpodAliases
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeDownloadStack(value, lane) {
|
|
const configured = value && typeof value === "object" ? value : null;
|
|
if (!configured) throw new Error(`deploy.lanes.${lane}.envRecipe.downloadStack is required`);
|
|
const httpProxy = normalizeOptionalHttpUrl(configured.httpProxy, `deploy.lanes.${lane}.envRecipe.downloadStack.httpProxy`);
|
|
const httpsProxy = normalizeOptionalHttpUrl(configured.httpsProxy, `deploy.lanes.${lane}.envRecipe.downloadStack.httpsProxy`);
|
|
const npmRegistry = normalizeOptionalHttpUrl(configured.npmRegistry, `deploy.lanes.${lane}.envRecipe.downloadStack.npmRegistry`);
|
|
const npmFetchTimeoutMs = Number(configured.npmFetchTimeoutMs);
|
|
const bunPackages = configured.bunPackages && typeof configured.bunPackages === "object" ? configured.bunPackages : null;
|
|
const x64 = normalizePackageName(bunPackages?.x64);
|
|
const arm64 = normalizePackageName(bunPackages?.arm64);
|
|
if (!npmRegistry) throw new Error(`deploy.lanes.${lane}.envRecipe.downloadStack.npmRegistry is required`);
|
|
if (!Number.isInteger(npmFetchTimeoutMs) || npmFetchTimeoutMs <= 0) {
|
|
throw new Error(`deploy.lanes.${lane}.envRecipe.downloadStack.npmFetchTimeoutMs must be a positive integer`);
|
|
}
|
|
if (!x64 || !arm64) throw new Error(`deploy.lanes.${lane}.envRecipe.downloadStack.bunPackages.x64/arm64 are required`);
|
|
return {
|
|
httpProxy,
|
|
httpsProxy,
|
|
noProxy: normalizeStringList(configured.noProxy, []),
|
|
npmRegistry,
|
|
npmFetchTimeoutMs,
|
|
bunPackages: { x64, arm64 }
|
|
};
|
|
}
|
|
|
|
function normalizeOptionalHttpUrl(value, label) {
|
|
const text = normalizeDeclarationString(value);
|
|
if (!text) return null;
|
|
try {
|
|
const url = new URL(text);
|
|
if (url.protocol === "http:" || url.protocol === "https:") return text;
|
|
} catch {}
|
|
throw new Error(`${label} must be an http(s) URL`);
|
|
}
|
|
|
|
function normalizePackageName(value) {
|
|
const text = normalizeDeclarationString(value);
|
|
return text && !text.includes(" ") ? text : null;
|
|
}
|
|
|
|
function normalizeStringList(value, fallback) {
|
|
const list = Array.isArray(value) ? value : fallback;
|
|
return list.map((item) => String(item ?? "").trim()).filter(Boolean);
|
|
}
|
|
|
|
function normalizeAbsolutePath(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text.startsWith("/") && !text.includes("..") ? text : null;
|
|
}
|
|
|
|
function normalizeHwpodAliases(value) {
|
|
const list = Array.isArray(value) ? value : [];
|
|
return list.map((item) => ({
|
|
command: normalizeDeclarationString(item?.command),
|
|
script: normalizeRepoPath(item?.script)
|
|
})).filter((item) => item.command && item.script);
|
|
}
|
|
|
|
function assertLaneEnvReuseConfig(laneConfig, lane) {
|
|
if (!laneConfig?.serviceDeclarations || Object.keys(laneConfig.serviceDeclarations).length === 0) {
|
|
throw new Error(`deploy.lanes.${lane}.serviceDeclarations is required for ${lane} env reuse planning`);
|
|
}
|
|
if (!laneConfig?.envRecipe) throw new Error(`deploy.lanes.${lane}.envRecipe is required for ${lane} env reuse planning`);
|
|
}
|
|
|
|
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
|
|
const result = new Set();
|
|
for (const filePath of changedPaths) {
|
|
if (filePath === "package-lock.json") {
|
|
result.add(filePath);
|
|
continue;
|
|
}
|
|
if (filePath !== "package.json") continue;
|
|
if (await packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath = "package.json") {
|
|
const [before, after] = await Promise.all([
|
|
readJsonFromGit(repoRoot, baseRef, filePath, null),
|
|
readJsonFromGit(repoRoot, targetRef, filePath, null)
|
|
]);
|
|
if (!before || !after) return true;
|
|
return stableJson(pickRuntimePackageJson(before)) !== stableJson(pickRuntimePackageJson(after));
|
|
}
|
|
|
|
function pickRuntimePackageJson(packageJson) {
|
|
if (!packageJson || typeof packageJson !== "object") return null;
|
|
const picked = {};
|
|
for (const field of DEFAULT_RUNTIME_PACKAGE_FIELDS) {
|
|
if (Object.prototype.hasOwnProperty.call(packageJson, field)) picked[field] = packageJson[field];
|
|
}
|
|
return picked;
|
|
}
|
|
|
|
async function readJsonFromGit(repoRoot, ref, filePath, fallback) {
|
|
try {
|
|
const value = await gitValue(repoRoot, ["show", `${ref}:${filePath}`]);
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
|
|
const configured = deployJson?.lanes?.[lane]?.envReuseServices;
|
|
const values = Array.isArray(configured) ? configured : [];
|
|
const allowed = new Set(serviceIds);
|
|
return new Set(values.filter((serviceId) => allowed.has(serviceId)));
|
|
}
|
|
|
|
export function bootShForService(deployJson, serviceId, lane = "v02") {
|
|
const configured = deployJson?.lanes?.[lane]?.bootScripts?.[serviceId];
|
|
if (!configured) throw new Error(`deploy.lanes.${lane}.bootScripts.${serviceId} is required`);
|
|
const bootSh = normalizeRepoPath(configured);
|
|
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
|
|
throw new Error(`invalid ${lane} boot script for ${serviceId}: ${configured}`);
|
|
}
|
|
return bootSh;
|
|
}
|
|
|
|
function canonicalBootRepo(deployJson, lane) {
|
|
return deployJson?.lanes?.[lane]?.sourceRepo || "git@github.com:pikasTech/HWLAB.git";
|
|
}
|
|
|
|
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 /\.test\.(mjs|ts)$/u.test(normalized) || normalized.includes("/__tests__/") || normalized.includes("/fixtures/");
|
|
}
|
|
|
|
export function isDocsOnlyPath(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
if (normalized.startsWith("internal/agent/prompts/")) return false;
|
|
if (normalized.startsWith("skills/")) return false;
|
|
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 = (await Promise.all(output.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map(async (line) => {
|
|
const filePath = line.slice(line.indexOf("\t") + 1);
|
|
if (options.skipPath?.(filePath)) return null;
|
|
if (options.semanticPackageJson === true && filePath === "package.json") {
|
|
const packageJson = await readJsonFromGit(repoRoot, targetRef, filePath, null);
|
|
return `100644 blob ${stableHash(pickRuntimePackageJson(packageJson))}\t${filePath}`;
|
|
}
|
|
return line;
|
|
})))
|
|
.filter(Boolean)
|
|
.sort();
|
|
return stableHash({ entries: lines });
|
|
}
|
|
|
|
async function hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options = {}) {
|
|
return stableHash({
|
|
gitPaths: await hashGitPaths(repoRoot, targetRef, paths, options),
|
|
envRecipe: recipe
|
|
});
|
|
}
|
|
|
|
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({ options, laneConfig, lane }) {
|
|
const allowedServiceIds = knownServiceIds(laneConfig, lane);
|
|
if (Array.isArray(options.services) && options.services.length > 0) {
|
|
const serviceIds = uniqueSorted(options.services.map(String));
|
|
const unknownServiceIds = serviceIds.filter((serviceId) => !allowedServiceIds.has(serviceId));
|
|
if (unknownServiceIds.length > 0) {
|
|
throw new Error(`unknown service IDs for node CI plan: ${unknownServiceIds.join(", ")}`);
|
|
}
|
|
return { source: "cli-services", serviceIds };
|
|
}
|
|
const configured = Array.isArray(laneConfig?.envReuseServices) ? laneConfig.envReuseServices.filter(Boolean) : [];
|
|
if (configured.length === 0) throw new Error(`deploy.lanes.${lane}.envReuseServices is required for ${lane} CI planning`);
|
|
return { source: `deploy.lanes.${lane}.envReuseServices`, serviceIds: uniquePreserveOrder(configured) };
|
|
}
|
|
|
|
function knownServiceIds(laneConfig, lane) {
|
|
const values = new Set(Object.keys(laneConfig?.serviceDeclarations ?? {}));
|
|
for (const serviceId of laneConfig?.envReuseServices ?? []) values.add(serviceId);
|
|
return values;
|
|
}
|
|
|
|
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, catalogReuse = null, envReuse = false, envChanged = null, codeChanged = null }) {
|
|
const reasons = [];
|
|
if (envReuse && envChanged) reasons.push("environment-input-changed");
|
|
if (envReuse && codeChanged) reasons.push("code-input-changed");
|
|
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");
|
|
if (catalogReuse?.status === "component-input-mismatch") reasons.push("component-input-mismatch");
|
|
if (catalogReuse?.status === "component-source-mismatch") reasons.push("component-source-mismatch");
|
|
if (catalogReuse?.status === "component-source-unavailable") reasons.push("component-source-unavailable");
|
|
if (catalogReuse?.status === "component-provenance-missing") reasons.push("component-provenance-missing");
|
|
if (catalogReuse?.status === "component-build-mismatch") reasons.push("component-build-mismatch");
|
|
if (catalogReuse?.status === "candidate-no-catalog") reasons.push("catalog-record-missing");
|
|
if (catalogReuse?.status === "candidate-unverified-digest") reasons.push("catalog-digest-missing");
|
|
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, componentInputChanged = false) {
|
|
if (affected) return { status: "not-reused", reason: "component-affected" };
|
|
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
|
if (componentInputChanged && catalogRecord?.componentProvenanceStatus) {
|
|
return {
|
|
status: catalogRecord.componentProvenanceStatus,
|
|
reason: catalogRecord.componentProvenanceReason,
|
|
image: catalogRecord.image ?? null,
|
|
digest: catalogRecord.digest ?? null,
|
|
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.sourceCommitId ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
|
};
|
|
}
|
|
if (componentInputChanged && typeof catalogRecord?.componentInputHash === "string") {
|
|
return {
|
|
status: "component-input-mismatch",
|
|
reason: "catalog-component-input-hash-differs-from-current-plan",
|
|
image: catalogRecord.image ?? null,
|
|
digest: catalogRecord.digest ?? null,
|
|
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
|
};
|
|
}
|
|
if (componentInputChanged) {
|
|
return {
|
|
status: "component-source-mismatch",
|
|
reason: "catalog-source-commit-does-not-contain-current-component-input",
|
|
image: catalogRecord.image ?? null,
|
|
digest: catalogRecord.digest ?? null,
|
|
reusedFrom: catalogRecord.sourceCommitId ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
|
};
|
|
}
|
|
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 catalogComponentProvenanceStatus({ catalogRecord, catalogSourceCommitId, catalogComponentInputHash, catalogComponentInputHashAtSource, componentInputHash, dockerfileHash, buildArgsHash }) {
|
|
if (!catalogRecord) return { safeToReuse: false, status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
|
if (!catalogSourceCommitId) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-source-commit-missing" };
|
|
if (!catalogComponentInputHash) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-component-input-hash-missing" };
|
|
if (!catalogComponentInputHashAtSource) return { safeToReuse: false, status: "component-source-unavailable", reason: "catalog-source-commit-unavailable" };
|
|
if (catalogComponentInputHashAtSource !== catalogComponentInputHash) {
|
|
return { safeToReuse: false, status: "component-source-mismatch", reason: "catalog-component-input-hash-does-not-match-catalog-source-tree" };
|
|
}
|
|
if (catalogComponentInputHash !== componentInputHash) {
|
|
return { safeToReuse: false, status: "component-input-mismatch", reason: "catalog-component-input-hash-differs-from-current-plan" };
|
|
}
|
|
if (text(catalogRecord.dockerfileHash) && catalogRecord.dockerfileHash !== dockerfileHash) {
|
|
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-dockerfile-hash-differs-from-current-plan" };
|
|
}
|
|
if (text(catalogRecord.buildArgsHash) && catalogRecord.buildArgsHash !== buildArgsHash) {
|
|
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-build-args-hash-differs-from-current-plan" };
|
|
}
|
|
return { safeToReuse: true, status: "safe-reuse", reason: "catalog-source-tree-and-current-component-input-match" };
|
|
}
|
|
|
|
async function hashGitPathsIfCommitExists(repoRoot, targetRef, paths, options = {}) {
|
|
try {
|
|
await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]);
|
|
return await hashGitPaths(repoRoot, targetRef, paths, options);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function text(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function envReuseCandidate(catalogRecord, envChanged) {
|
|
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
|
|
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
|
const image = environmentImageFromCatalog(catalogRecord.serviceId, catalogRecord);
|
|
const digest = environmentDigestFromCatalog(catalogRecord);
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(digest ?? "")) {
|
|
return { status: "candidate-unverified-digest", image, digest };
|
|
}
|
|
return {
|
|
status: catalogRecord.environmentInputHash ? "ready" : "candidate-without-environment-metadata",
|
|
image,
|
|
digest,
|
|
reusedFrom: catalogRecord.environmentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
|
};
|
|
}
|
|
|
|
function environmentImageFromCatalog(serviceId, catalogRecord) {
|
|
const image = catalogRecord?.environmentImage ?? null;
|
|
if (image) return image;
|
|
const fallback = catalogRecord?.image ?? null;
|
|
return typeof fallback === "string" && fallback.includes(`/${serviceId}-env:`) ? fallback : null;
|
|
}
|
|
|
|
function environmentDigestFromCatalog(catalogRecord) {
|
|
return catalogRecord?.environmentDigest ?? (catalogRecord?.environmentImage ? catalogRecord?.digest : null) ?? null;
|
|
}
|
|
|
|
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;
|
|
}
|