// 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 { artifactCatalogAuthorityPath, readArtifactCatalogSummary } from "./artifact-catalog-authority.mjs"; 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", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs" ]); const COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS = new Set([ "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs" ]); 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 const DEFAULT_GITOPS_RENDER_PATHS = Object.freeze([ "deploy/deploy.yaml", "deploy/deploy.schema.json", "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 catalogAuthorityPath = options.artifactCatalogAuthorityPath ?? artifactCatalogAuthorityPath(artifactCatalogPath); const artifactCatalogSummary = await readArtifactCatalogSummary({ repoRoot, catalogPath: artifactCatalogPath, authorityPath: catalogAuthorityPath, catalog: artifactCatalog }); const runtimeReuseConfigPath = options.runtimeReuseConfigPath ?? "gitops/reuse.ymal"; const runtimeReuseConfig = await readStructuredFileIfPresent(repoRoot, runtimeReuseConfigPath, null); const runtimeReuseByService = runtimeReuseServices(runtimeReuseConfig); 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 envArtifactGroupByService = envArtifactGroupsByService(runtimeReuseConfig, 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 envArtifactGroup = envReuse ? envArtifactGroupByService.get(model.serviceId) ?? null : null; 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 currentEnvironmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash, envArtifactGroup) : null; const catalogEnvironmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null; const catalogEnvironmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null; const catalogEnvironmentSourceCommitId = envReuse ? text(catalogRecord?.sourceCommitId) : ""; const catalogEnvironmentInputHashAtSource = envReuse && catalogEnvironmentSourceCommitId ? await hashEnvReuseInputsIfCommitExists(repoRoot, catalogEnvironmentSourceCommitId, envInputPaths, serviceEnvReuseRecipe, { skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath), semanticPackageJson: true }) : null; const environmentSourceUnchanged = envReuse && Boolean(catalogEnvironmentInputHashAtSource) && catalogEnvironmentInputHashAtSource === environmentInputHash; let environmentImage = currentEnvironmentImage; let environmentDigest = envReuse ? environmentDigestFromCatalogForHash(catalogRecord, environmentInputHash) : null; const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null; const currentReuseRegistry = envReuse && reuseRegistryProbe && currentEnvironmentImage ? await reuseRegistryProbe({ serviceId: model.serviceId, image: currentEnvironmentImage, digest: environmentDigest, registryPrefix, timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000 }) : null; let reuseRegistry = currentReuseRegistry; let environmentMetadataHashDrift = false; const catalogEnvironmentReusable = envReuse && environmentInputChanged === true && environmentSourceUnchanged && Boolean(catalogEnvironmentImage) && /^sha256:[a-f0-9]{64}$/u.test(catalogEnvironmentDigest ?? ""); if (catalogEnvironmentReusable && currentReuseRegistry?.status !== "present") { const catalogReuseRegistry = reuseRegistryProbe && catalogEnvironmentImage ? await reuseRegistryProbe({ serviceId: model.serviceId, image: catalogEnvironmentImage, digest: catalogEnvironmentDigest, registryPrefix, timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000 }) : null; if (!reuseRegistryProbe || catalogReuseRegistry?.status === "present") { environmentImage = catalogEnvironmentImage; environmentDigest = catalogReuseRegistry?.digest ?? catalogEnvironmentDigest; reuseRegistry = catalogReuseRegistry ? { ...catalogReuseRegistry, reuseSource: "catalog-metadata-hash-drift" } : null; environmentMetadataHashDrift = true; } } 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 effectiveEnvironmentInputChanged = environmentInputChanged === true && !environmentMetadataHashDrift; const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !effectiveEnvironmentInputChanged && 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 plannedAffected = envReuse ? Boolean(envChanged || codeChanged || runtimeConfigChanged) : imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest"; const plannedBuildRequired = envReuse ? envChanged === true : plannedAffected; const runtimeReuseDecision = await runtimeReuseDecisionForService({ repoRoot, baseRef, targetRef, serviceId: model.serviceId, config: runtimeReuseByService.get(model.serviceId) ?? null }); const reuseArtifactReady = !envReuse || environmentReady === true; const affected = runtimeReuseDecision.runtimeReuseHit === true && reuseArtifactReady ? false : runtimeReuseDecision.envReuseHit === true && reuseArtifactReady ? runtimeReuseDecision.sourceIdentityHit === false || runtimeConfigChanged === true : plannedAffected; const buildRequired = runtimeReuseDecision.skipImageBuild === true && reuseArtifactReady ? false : plannedBuildRequired; 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, envArtifactGroup: envArtifactGroup ? { id: envArtifactGroup.id, buildService: envArtifactGroup.buildService, imageRepository: envArtifactGroup.imageRepository, cacheRepository: envArtifactGroup.cacheRepository, services: envArtifactGroup.services } : null, envArtifactGroupId: envArtifactGroup?.id ?? null, envArtifactBuildServiceId: envArtifactGroup?.buildService ?? null, envArtifactSourceServiceId: envArtifactGroup?.buildService ?? model.serviceId, envArtifactCacheRef: envArtifactGroup ? envReuseCacheRef(registryPrefix, envArtifactGroup) : null, componentCommitId, componentInputHash, catalogSourceCommitId: catalogSourceCommitId || null, catalogComponentInputHash: catalogComponentInputHash || null, catalogComponentInputHashAtSource, catalogComponentProvenance, dockerfileHash, baseImageReference: baseImage, baseImageDigest: digestFromImageReference(baseImage), buildArgsHash, changedPaths: imageRelevantChangedPaths, affected, buildRequired, skipImageBuild: !buildRequired, componentInputChanged, runtimeReuseDecision, runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image", envReuse, envChanged, runtimeConfigChanged, environmentInputChanged, effectiveEnvironmentInputChanged, environmentMetadataHashDrift, catalogEnvironmentInputHashAtSource, environmentSourceUnchanged, 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: effectiveEnvironmentInputChanged, codeChanged, reuseRegistry }) : reasonForUnchanged(globalChange), reuse: catalogReuse }); } const envArtifactGroupPlans = applyEnvArtifactGroupBuildPlan(services); const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId); const buildServices = services.filter((service) => service.buildRequired).map((service) => service.serviceId); const rolloutWithoutImageBuildServices = services.filter((service) => service.affected && !service.buildRequired).map((service) => service.serviceId); const reusedServices = services.filter((service) => !service.affected && !service.buildRequired).map((service) => service.serviceId); const imageBuildSkippedServices = services.filter((service) => !service.buildRequired).map((service) => service.serviceId); const gitopsRenderChanged = hasGitOpsRenderChange(normalizedChangedPaths); return { planVersion: CI_PLAN_VERSION, sourceCommitId, shortCommitId, baseRef, targetRef, compatibility: { deployConfig: deployConfigPath, artifactCatalog: artifactCatalogSummary.status === "missing" ? null : artifactCatalogPath, artifactCatalogAuthority: artifactCatalogSummary.authority, 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], envArtifactGroups: envArtifactGroupPlans, v02EnvReuseServiceIds: lane === "v02" ? [...envReuseServices] : [], defaultComponentLazyBuild: true, envReuseCodeOnlyFastLane: true, fullBuildFallback: false }, inputs: { registryPrefix, baseImage, serviceCount: services.length, verifyReuseRegistry }, changedPaths: normalizedChangedPaths, changedPathSummary: globalChange, artifactCatalog: artifactCatalogSummary, imageBuildRequired: buildServices.length > 0, affectedServices, rolloutServices: affectedServices, buildServices, rolloutWithoutImageBuildServices, reusedServices, serviceReusedCount: reusedServices.length, imageBuildSkippedServices, buildSkippedCount: imageBuildSkippedServices.length, envArtifactGroups: envArtifactGroupPlans, services, ciCdPlan: { willBuild: buildServices, willRollout: affectedServices, willRolloutWithoutImageBuild: rolloutWithoutImageBuildServices, willReuse: reusedServices, envArtifactGroups: envArtifactGroupPlans, willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0 || gitopsRenderChanged, noImageBuildReason: buildServices.length === 0 ? (affectedServices.length > 0 ? "env-reuse-code-only-rollout" : gitopsRenderChanged ? "gitops-render-only-change" : 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); const goBaseImage = normalizeDeclarationString(configured.goBaseImage); 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, goBaseImage, additionalEnvPaths, launcherInputPaths, downloadStack, hwpodAliases, publicRecipe: { osPackages, goOsPackages, bunVersion, launcherPath, runtimeNodeModulesPath, runtimeGoModCachePath, runtimeGoBuildCachePath, goToolchain, goBaseImage, 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, goBaseImage: _goBaseImage, ...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 (COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(filePath)) { if (await envIdentityTextChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath); continue; } if (filePath !== "package.json") continue; if (await packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath); } return result; } async function envIdentityTextChanged(repoRoot, baseRef, targetRef, filePath) { const [before, after] = await Promise.all([ readTextFromGit(repoRoot, baseRef, filePath, null), readTextFromGit(repoRoot, targetRef, filePath, null) ]); if (before === null || after === null) return true; return stableJson(normalizeEnvIdentityText(filePath, before)) !== stableJson(normalizeEnvIdentityText(filePath, after)); } 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 readTextFromGit(repoRoot, ref, filePath, ""); return JSON.parse(value); } catch { return fallback; } } async function readTextFromGit(repoRoot, ref, filePath, fallback) { try { return await gitValue(repoRoot, ["show", `${ref}:${filePath}`]); } 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))); } function runtimeReuseServices(config) { const raw = config?.spec?.services ?? config?.services; const entries = Array.isArray(raw) ? raw.map((value, index) => [String(value?.id ?? index), value]) : Object.entries(raw ?? {}); return new Map(entries.flatMap(([id, value]) => { const serviceId = String(value?.id ?? id ?? "").trim(); return serviceId ? [[serviceId, value ?? {}]] : []; })); } function envArtifactGroupsByService(config, allowedServiceIds) { const allowed = new Set(allowedServiceIds); const raw = config?.spec?.envArtifactGroups ?? config?.envArtifactGroups; const entries = Array.isArray(raw) ? raw.map((value, index) => [String(value?.id ?? index), value]) : Object.entries(raw ?? {}); const byService = new Map(); for (const [id, value] of entries) { if (value?.enabled === false) continue; const groupId = String(value?.id ?? id ?? "").trim(); const services = uniqueSorted(normalizeStringList(value?.services, [])).filter((serviceId) => allowed.has(serviceId)); if (!groupId || services.length === 0) continue; const configuredBuildService = String(value?.buildService ?? value?.sourceService ?? services[0] ?? "").trim(); const buildService = services.includes(configuredBuildService) ? configuredBuildService : services[0]; const group = { id: groupId, buildService, services, imageRepository: normalizeRepositoryPath(value?.imageRepository) ?? groupId, cacheRepository: normalizeRepositoryPath(value?.cacheRepository) ?? `cache/${groupId}` }; for (const serviceId of services) byService.set(serviceId, group); } return byService; } function normalizeRepositoryPath(value) { const text = String(value ?? "").trim(); return text && /^[a-z0-9][a-z0-9._/-]*$/u.test(text) && !text.includes("..") ? text : null; } function applyEnvArtifactGroupBuildPlan(services) { const groups = new Map(); for (const service of services) { const group = service.envArtifactGroup; if (!service.envReuse || !group?.id) continue; if (!groups.has(group.id)) groups.set(group.id, { ...group, services: [] }); groups.get(group.id).services.push(service); } const plans = []; for (const group of groups.values()) { const members = group.services; const buildNeeded = members.some((service) => service.buildRequired === true); const source = members.find((service) => service.serviceId === group.buildService) ?? members.find((service) => service.buildRequired === true) ?? members[0]; for (const service of members) { service.envArtifactSourceServiceId = source.serviceId; service.envArtifactBuildServiceId = source.serviceId; service.envArtifactGroupBuildRequired = buildNeeded && service.serviceId === source.serviceId; if (buildNeeded && service.serviceId !== source.serviceId && service.buildRequired === true) { service.buildRequired = false; service.skipImageBuild = true; service.sharedEnvBuildSkipped = true; service.reason = uniqueSorted([...(Array.isArray(service.reason) ? service.reason : []), "shared-env-artifact-consumer"]); } } plans.push({ id: group.id, buildService: source.serviceId, imageRepository: group.imageRepository, cacheRepository: group.cacheRepository, buildRequired: buildNeeded, services: members.map((service) => service.serviceId), buildServices: buildNeeded ? [source.serviceId] : [], consumerServices: buildNeeded ? members.filter((service) => service.serviceId !== source.serviceId).map((service) => service.serviceId) : members.map((service) => service.serviceId) }); } return plans.sort((a, b) => a.id.localeCompare(b.id)); } async function runtimeReuseDecisionForService({ repoRoot, baseRef, targetRef, serviceId, config }) { const runtimeReuse = config?.runtimeReuse ?? null; const envReuse = config?.envReuse ?? null; const codePaths = runtimeReusePaths(runtimeReuse?.codeIdentity?.paths ?? runtimeReuse?.codeIdentityPaths); const envPaths = uniqueSorted([ ...runtimeReusePaths(runtimeReuse?.envIdentity?.paths ?? runtimeReuse?.envIdentityPaths), ...runtimeReusePaths(envReuse?.envIdentityFiles ?? envReuse?.envIdentity?.paths) ]); const sourceIdentityHit = codePaths.length > 0 ? await identityHit(repoRoot, baseRef, targetRef, codePaths) : null; const envIdentityHit = envPaths.length > 0 ? await identityHit(repoRoot, baseRef, targetRef, envPaths) : null; const runtimeReuseEnabled = runtimeReuse?.enabled !== false; const envReuseEnabled = envReuse?.enabled !== false; const runtimeReuseHit = runtimeReuseEnabled && sourceIdentityHit === true && envIdentityHit === true; const envReuseHit = envReuseEnabled && envIdentityHit === true; return { source: "gitops/reuse.ymal", serviceId, configured: config !== null, sourceIdentityHit, envIdentityHit, runtimeReuseHit, envReuseHit, skipImageBuild: runtimeReuseHit || envReuseHit, buildDecision: runtimeReuseHit || envReuseHit ? "skipImageBuild" : "buildImage", reason: runtimeReuseHit ? "runtime-reuse-hit" : envReuseHit ? "env-reuse-hit" : "reuse-miss" }; } function runtimeReusePaths(value) { return Array.isArray(value) ? uniqueSorted(value.map(normalizeRepoPath).filter(Boolean)) : []; } async function identityHit(repoRoot, baseRef, targetRef, paths) { const [baseHash, targetHash] = await Promise.all([ hashGitPaths(repoRoot, baseRef, paths), hashGitPaths(repoRoot, targetRef, paths) ]); return baseHash === targetHash; } 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 function isGitOpsRenderPath(filePath) { const normalized = normalizeRepoPath(filePath); return DEFAULT_GITOPS_RENDER_PATHS.some((pattern) => pathMatches(pattern, normalized)); } export function hasGitOpsRenderChange(changedPaths) { return changedPaths.some((item) => isGitOpsRenderPath(item)); } 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}`; } if (COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(filePath)) { const source = await readTextFromGit(repoRoot, targetRef, filePath, ""); return `100644 blob ${stableHash(normalizeEnvIdentityText(filePath, source))}\t${filePath}`; } return line; }))) .filter(Boolean) .sort(); return stableHash({ entries: lines }); } function normalizeEnvIdentityText(filePath, source) { const normalizedPath = normalizeRepoPath(filePath); const text = String(source ?? "").replace(/\r\n?/gu, "\n"); if (!COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(normalizedPath)) return text; return text .split("\n") .filter((line) => { const trimmed = line.trim(); return trimmed && !trimmed.startsWith("//"); }) .join("\n"); } async function hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options = {}) { return stableHash({ gitPaths: await hashGitPaths(repoRoot, targetRef, paths, options), envRecipe: envReuseIdentityRecipe(recipe) }); } async function hashEnvReuseInputsIfCommitExists(repoRoot, targetRef, paths, recipe, options = {}) { try { await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]); return await hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options); } catch { return null; } } 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, group = null) { const tag = String(environmentInputHash || "unknown").slice(0, 12); const repository = group?.imageRepository ?? `${serviceId}-env`; return `${registryPrefix}/${repository}:env-${tag}`; } function envReuseCacheRef(registryPrefix, group) { return `${registryPrefix}/${group.cacheRepository}`; } 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; }