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

1043 lines
46 KiB
JavaScript

// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import * as http from "node:http";
import * as https from "node:https";
import path from "node:path";
import { promisify } from "node:util";
import { parseStructuredConfig, 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 GO_RUNTIME_DEP_PATHS = Object.freeze([
"go.mod",
"go.sum"
]);
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 verifyReuseRegistry = options.verifyReuseRegistry ?? booleanEnv(process.env.HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY);
const reuseRegistryProbe = options.reuseRegistryProbe ?? (verifyReuseRegistry ? probeRegistryManifest : null);
const registryProbeTimeoutMs = Number.parseInt(String(options.registryProbeTimeoutMs ?? process.env.HWLAB_CI_PLAN_REGISTRY_PROBE_TIMEOUT_MS ?? 3000), 10);
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 hasGoService = componentModels.some((model) => model.runtimeKind === "go-service");
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...envReuseRecipe.additionalEnvPaths,
...DEFAULT_RUNTIME_DEP_PATHS,
...(hasGoService ? GO_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 serviceEnvReuseRecipe = envReuse ? envReuseRecipeForService(envReuseRecipe.publicRecipe, model) : null;
const runtimeConfigChanged = envReuse ? await serviceRuntimeConfigChanged(repoRoot, deployConfigPath, baseRef, targetRef, lane, model.serviceId) : 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, serviceEnvReuseRecipe, {
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 ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash) : null;
let environmentDigest = envReuse ? environmentDigestFromCatalogForHash(catalogRecord, environmentInputHash) : null;
const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null;
const reuseRegistry = envReuse && reuseRegistryProbe && environmentImage
? await reuseRegistryProbe({
serviceId: model.serviceId,
image: environmentImage,
digest: environmentDigest,
registryPrefix,
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
})
: null;
if (reuseRegistry?.status === "present" && /^sha256:[a-f0-9]{64}$/u.test(reuseRegistry.digest ?? "")) {
environmentDigest = reuseRegistry.digest;
}
const reuseRegistryUnavailable = Boolean(reuseRegistry && reuseRegistry.status !== "present");
const digestReady = /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "");
const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !environmentInputChanged && digestReady);
const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || reuseRegistryUnavailable) : 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, environmentImage, environmentDigest, environmentInputHash })
: reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged);
const affected = envReuse
? Boolean(envChanged || codeChanged || runtimeConfigChanged)
: 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,
healthProbe: model.healthProbe,
componentPaths: model.componentPaths,
sharedPaths: model.sharedPaths,
runtimeDeps: model.runtimeDeps,
buildSystemPaths: model.buildSystemPaths,
envReuseRecipe: serviceEnvReuseRecipe,
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,
runtimeConfigChanged,
environmentInputChanged,
codeChanged,
reuseRegistry,
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, runtimeConfigChanged, environmentInputChanged, codeChanged, reuseRegistry })
: 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,
verifyReuseRegistry
},
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,
healthProbe: declaration.healthProbe,
env: declaration.env,
observable: declaration.observable,
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
runtimeDeps: runtimeDependencyPathsForDeclaration(declaration),
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig, lane).additionalEnvPaths)
};
});
}
function runtimeDependencyPathsForDeclaration(declaration) {
const paths = [...DEFAULT_RUNTIME_DEP_PATHS];
if (declaration.runtimeKind === "go-service" || declaration.artifactKind === "go-service") {
paths.push(...GO_RUNTIME_DEP_PATHS);
}
return uniqueSorted(paths.map(normalizeRepoPath));
}
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,
healthProbe: normalizeServiceHealthProbe(configured?.healthProbe),
componentPaths: configured.componentPaths,
env: normalizeServiceEnv(configured?.env),
observable: configured?.observable === true
};
}
function normalizeServiceEnv(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([key, raw]) => [String(key), raw == null ? null : String(raw)]).sort(([left], [right]) => left.localeCompare(right)));
}
function normalizeServiceHealthProbe(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result = {};
for (const key of ["readiness", "liveness", "startup"]) {
const timing = normalizeProbeTiming(value[key]);
if (Object.keys(timing).length > 0) result[key] = timing;
}
return result;
}
function normalizeProbeTiming(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result = {};
for (const field of ["initialDelaySeconds", "periodSeconds", "timeoutSeconds", "failureThreshold", "successThreshold"]) {
const raw = value[field];
const minimum = field === "initialDelaySeconds" ? 0 : 1;
if (Number.isInteger(raw) && raw >= minimum) result[field] = raw;
}
return result;
}
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 goOsPackages = normalizeStringList(configured.goOsPackages, []);
const bunVersion = requireDeclarationString(configured.bunVersion, lane, "envRecipe.bunVersion");
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, lane, "envRecipe.launcherPath"));
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
const runtimeGoModCachePath = normalizeAbsolutePath(configured.runtimeGoModCachePath);
const runtimeGoBuildCachePath = normalizeAbsolutePath(configured.runtimeGoBuildCachePath);
const goToolchain = normalizeDeclarationString(configured.goToolchain);
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 ((runtimeGoModCachePath || runtimeGoBuildCachePath) && goOsPackages.length === 0) {
throw new Error(`deploy.lanes.${lane}.envRecipe.goOsPackages is required when Go cache paths are configured`);
}
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,
goOsPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
runtimeGoModCachePath,
runtimeGoBuildCachePath,
goToolchain,
additionalEnvPaths,
launcherInputPaths,
downloadStack,
hwpodAliases,
publicRecipe: {
osPackages,
goOsPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
runtimeGoModCachePath,
runtimeGoBuildCachePath,
goToolchain,
additionalEnvPaths,
downloadStack,
hwpodAliases
}
};
}
function envReuseRecipeForService(recipe, model) {
if (model?.runtimeKind === "go-service" || model?.artifactKind === "go-service") return recipe;
const {
goOsPackages: _goOsPackages,
runtimeGoModCachePath: _runtimeGoModCachePath,
runtimeGoBuildCachePath: _runtimeGoBuildCachePath,
goToolchain: _goToolchain,
...nodeRecipe
} = recipe;
return nodeRecipe;
}
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 goProxy = normalizeDeclarationString(configured.goProxy);
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,
goProxy,
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;
}
async function serviceRuntimeConfigChanged(repoRoot, deployConfigPath, baseRef, targetRef, lane, serviceId) {
const [before, after] = await Promise.all([
readDeployConfigFromGit(repoRoot, baseRef, deployConfigPath),
readDeployConfigFromGit(repoRoot, targetRef, deployConfigPath)
]);
if (!before || !after) return true;
return stableJson(serviceRuntimeConfigIdentity(before, lane, serviceId)) !== stableJson(serviceRuntimeConfigIdentity(after, lane, serviceId));
}
async function readDeployConfigFromGit(repoRoot, ref, deployConfigPath) {
try {
return parseStructuredConfig(await gitValue(repoRoot, ["show", `${ref}:${deployConfigPath}`]), deployConfigPath);
} catch {
return null;
}
}
function serviceRuntimeConfigIdentity(deployJson, lane, serviceId) {
const laneConfig = laneDeployConfig(deployJson, lane);
const declaration = laneConfig?.serviceDeclarations?.[serviceId] ?? {};
return {
namespace: normalizeDeclarationString(laneConfig?.namespace),
serviceId,
bootScript: normalizeRepoPath(laneConfig?.bootScripts?.[serviceId]),
runtimeKind: normalizeDeclarationString(declaration.runtimeKind),
artifactKind: normalizeDeclarationString(declaration.artifactKind),
entrypoint: normalizeRepoPath(declaration.entrypoint),
healthPath: normalizeDeclarationString(declaration.healthPath),
healthPort: Number.isInteger(declaration.healthPort) ? declaration.healthPort : null,
healthProbe: normalizeServiceHealthProbe(declaration.healthProbe),
env: normalizeServiceEnv(declaration.env),
observable: declaration.observable === true
};
}
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: envReuseIdentityRecipe(recipe)
});
}
function envReuseIdentityRecipe(recipe) {
const identity = JSON.parse(JSON.stringify(recipe ?? {}));
if (identity.downloadStack && typeof identity.downloadStack === "object") {
delete identity.downloadStack.httpProxy;
delete identity.downloadStack.httpsProxy;
delete identity.downloadStack.noProxy;
}
return identity;
}
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, runtimeConfigChanged = null, environmentInputChanged = null, codeChanged = null, reuseRegistry = null }) {
const reasons = [];
if (envReuse && environmentInputChanged) reasons.push("environment-input-mismatch");
if (envReuse && reuseRegistry && reuseRegistry.status !== "present") reasons.push("reuse-registry-missing");
if (envReuse && envChanged && reasons.length === 0) reasons.push("environment-input-changed");
if (envReuse && runtimeConfigChanged) reasons.push("runtime-config-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, environmentImage, environmentDigest, environmentInputHash }) {
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
if (environmentImage && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "")) {
return {
status: "ready",
image: environmentImage,
digest: environmentDigest,
reusedFrom: environmentInputHash ?? catalogRecord?.environmentInputHash ?? catalogRecord?.commitId ?? catalogRecord?.imageTag ?? null
};
}
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 environmentDigestFromCatalogForHash(catalogRecord, environmentInputHash) {
return catalogRecord?.environmentInputHash === environmentInputHash ? environmentDigestFromCatalog(catalogRecord) : null;
}
function envReuseImageRef(registryPrefix, serviceId, environmentInputHash) {
const tag = String(environmentInputHash || "unknown").slice(0, 12);
return `${registryPrefix}/${serviceId}-env:env-${tag}`;
}
function digestFromImageReference(image) {
const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u);
return match ? match[1] : null;
}
function booleanEnv(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
}
async function probeRegistryManifest({ image, digest, timeoutMs = 3000 }) {
const request = registryManifestRequest(image, digest);
if (!request) return { status: "invalid-reference", image, digest, reason: "registry-reference-unparseable" };
const result = await registryManifestHttpProbe(request, "HEAD", timeoutMs);
if (result.status === "present" || result.status === "missing") return { ...result, image, digest: result.digest ?? digest ?? null };
const fallback = await registryManifestHttpProbe(request, "GET", timeoutMs);
return { ...fallback, image, digest: fallback.digest ?? digest ?? null };
}
function registryManifestRequest(image, digest) {
const parsed = parseTaggedImage(image);
const reference = /^sha256:[a-f0-9]{64}$/u.test(digest ?? "") ? digest : parsed?.tag;
if (!parsed || !reference) return null;
const protocol = parsed.host === "127.0.0.1" || parsed.host.startsWith("127.") || parsed.host === "localhost" || parsed.host.includes(":5000")
? "http:"
: "https:";
return {
protocol,
host: parsed.host,
path: `/v2/${parsed.repository}/manifests/${reference}`
};
}
function parseTaggedImage(image) {
const text = String(image ?? "").trim();
const slash = text.indexOf("/");
if (slash <= 0) return null;
const host = text.slice(0, slash);
const rest = text.slice(slash + 1);
const tagSeparator = rest.lastIndexOf(":");
if (!host || tagSeparator <= 0 || rest.includes("@")) return null;
return {
host,
repository: rest.slice(0, tagSeparator),
tag: rest.slice(tagSeparator + 1)
};
}
function registryManifestHttpProbe(request, method, timeoutMs) {
const client = request.protocol === "http:" ? http : https;
return new Promise((resolve) => {
const req = client.request({
protocol: request.protocol,
host: request.host.includes(":") ? request.host.slice(0, request.host.lastIndexOf(":")) : request.host,
port: request.host.includes(":") ? request.host.slice(request.host.lastIndexOf(":") + 1) : undefined,
method,
path: request.path,
timeout: timeoutMs,
headers: {
Accept: [
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json"
].join(", ")
}
}, (res) => {
res.resume();
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
const digest = String(res.headers["docker-content-digest"] ?? "").trim();
resolve({
status: "present",
method,
statusCode: res.statusCode,
digest: /^sha256:[a-f0-9]{64}$/u.test(digest) ? digest : null
});
} else if (res.statusCode === 404) {
resolve({ status: "missing", method, statusCode: res.statusCode });
} else {
resolve({ status: "unknown", method, statusCode: res.statusCode ?? 0, reason: "unexpected-status" });
}
});
});
req.on("timeout", () => {
req.destroy();
resolve({ status: "unknown", method, statusCode: 0, reason: "timeout" });
});
req.on("error", (error) => {
resolve({ status: "unknown", method, statusCode: 0, reason: error.message });
});
req.end();
});
}
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;
}