#!/usr/bin/env node import { createHash } from "node:crypto"; import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const serviceId = "agentrun-mgr"; const envIdentityExcludedPaths = new Set([ "scripts/ci-plan.mjs", "scripts/ci-plan.test.mjs", "scripts/ci/verify-reviewed-plan.mjs", "scripts/ci/verify-reviewed-plan.test.mjs", ]); if (import.meta.url === pathToFileURL(process.argv[1]).href) { const options = parseArgs(process.argv.slice(2)); const root = resolve(options.root); const sourceCommitId = git(root, ["rev-parse", options.targetRef]); const baseCommitId = git(root, ["rev-parse", options.baseRef]); const sourceChangedPaths = git(root, ["diff", "--name-only", baseCommitId, sourceCommitId]).split("\n").filter(Boolean); const materializationPaths = Array.isArray(options.materializationPaths) ? options.materializationPaths : []; const changedPaths = materializationPaths.length === 0 ? sourceChangedPaths : sourceChangedPaths.filter((changedPath) => materializationPaths.some((inputPath) => changedPath === inputPath || changedPath.startsWith(`${inputPath}/`))); const envIdentity = calculateEnvIdentity({ root, envIdentityFiles: JSON.parse(options.envIdentityFilesJson), buildArgs: JSON.parse(options.buildArgsJson), containerHttpProxy: options.containerHttpProxy, containerHttpsProxy: options.containerHttpsProxy, containerNoProxy: options.containerNoProxy, }); const registryProbe = await probeRegistry({ registryProbeBase: options.registryProbeBase, imageRepository: options.imageRepository, envIdentity, }); process.stdout.write(`${JSON.stringify(createPlan({ sourceCommitId, baseCommitId, target: options.target, consumer: options.consumer, lane: options.lane, changedPaths, envIdentity, imageRepository: options.imageRepository, registryProbe, }))}\n`); } export function calculateEnvIdentity(input) { const root = resolve(input.root); const hash = createHash("sha256"); for (const item of input.buildArgs) { hash.update("build-arg\0"); hash.update(item); hash.update("\0"); } for (const item of [ `HTTP_PROXY=${input.containerHttpProxy || ""}`, `HTTPS_PROXY=${input.containerHttpsProxy || ""}`, `NO_PROXY=${input.containerNoProxy || ""}`, ]) { hash.update("build-container-proxy\0"); hash.update(item); hash.update("\0"); } for (const inputPath of input.envIdentityFiles) { for (const entry of collectFiles(root, inputPath)) { hash.update(entry.path); hash.update("\0"); if (!entry.missing) hash.update(readFileSync(join(root, entry.path))); hash.update("\0"); } } return hash.digest("hex").slice(0, 24); } export function createPlan(input) { const affectedServices = input.changedPaths.length > 0 ? [serviceId] : []; const rolloutServices = [...affectedServices]; const buildServices = affectedServices.length > 0 && input.registryProbe.status !== "present" ? [serviceId] : []; const registryProbe = { enabled: true, services: [{ serviceId, imageRepository: input.imageRepository, envIdentity: input.envIdentity, status: input.registryProbe.status, digest: input.registryProbe.digest ?? null, }], }; const identityInput = { version: "v1", inputs: { target: input.target, consumer: input.consumer, lane: input.lane, sourceCommitId: input.sourceCommitId, baseCommitId: input.baseCommitId, catalog: null, registryProbe, }, scope: { buildServices, rolloutServices, affectedServices }, }; return { sourceCommitId: input.sourceCommitId, baseCommitId: input.baseCommitId, changedPaths: input.changedPaths, services: [{ serviceId, changedPaths: input.changedPaths }], envIdentity: input.envIdentity, imageRepository: input.imageRepository, registryProbe, envArtifactGroups: [{ id: "agentrun-runtime-env", buildService: serviceId, imageRepository: input.imageRepository, buildRequired: buildServices.length > 0, services: [serviceId], buildServices, consumerServices: [serviceId], }], reusedServices: input.registryProbe.status === "present" ? [serviceId] : [], serviceReusedCount: input.registryProbe.status === "present" ? 1 : 0, imageBuildSkippedServices: input.registryProbe.status === "present" ? [serviceId] : [], buildServices, rolloutServices, affectedServices, planIdentity: { value: `sha256:${createHash("sha256").update(stable(identityInput)).digest("hex")}`, inputs: identityInput.inputs, scope: identityInput.scope, }, valuesPrinted: false, }; } async function probeRegistry({ registryProbeBase, imageRepository, envIdentity }) { const base = registryProbeBase.replace(/\/+$/u, ""); const repositoryPath = imageRepository.replace(/^[^/]+\//u, ""); let response; try { response = await fetch(`${base}/v2/${repositoryPath}/manifests/${envIdentity}`, { method: "HEAD", headers: { Accept: "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" }, signal: AbortSignal.timeout(5000), }); } catch (error) { throw new Error(`registry probe failed: ${error instanceof Error ? error.message : String(error)}`); } if (response.status === 404) return { status: "missing", digest: null }; if (!response.ok) throw new Error(`registry probe failed with HTTP ${response.status}`); const digest = response.headers.get("docker-content-digest"); if (!digest) throw new Error("registry probe succeeded without docker-content-digest"); return { status: "present", digest }; } function collectFiles(root, inputPath) { const absolute = join(root, inputPath); if (!existsSync(absolute)) return [{ path: inputPath, missing: true }]; const stat = lstatSync(absolute); if (stat.isFile()) return [{ path: inputPath, missing: false }]; if (!stat.isDirectory()) return [{ path: inputPath, missing: true }]; const skip = new Set([".git", ".worktree", ".state", "node_modules", "coverage", "tmp", ".tmp"]); const files = []; const stack = [absolute]; while (stack.length > 0) { const directory = stack.pop(); for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { if (entry.isDirectory() && skip.has(entry.name)) continue; const child = join(directory, entry.name); if (entry.isDirectory()) stack.push(child); else if (entry.isFile()) files.push({ path: relative(root, child), missing: false }); } } return files .filter((entry) => !envIdentityExcludedPaths.has(entry.path)) .sort((left, right) => left.path.localeCompare(right.path)); } function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`; return JSON.stringify(value); } function git(root, args) { return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); } function parseArgs(argv) { const result = { root: "." }; for (let index = 0; index < argv.length; index += 1) { const name = argv[index]; if (!name.startsWith("--")) throw new Error(`unexpected argument ${name}`); const key = name.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase()); const value = argv[++index]; if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); result[key] = value; } if (typeof result.configFile === "string") { const configured = JSON.parse(readFileSync(result.configFile, "utf8")); for (const [key, value] of Object.entries(configured)) { if (result[key] === undefined) result[key] = value; } } for (const key of ["baseRef", "targetRef", "target", "consumer", "lane", "imageRepository", "registryProbeBase", "envIdentityFilesJson", "buildArgsJson", "containerHttpProxy", "containerHttpsProxy", "containerNoProxy"]) { if (typeof result[key] !== "string") throw new Error(`--${key.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)} is required`); } return result; }