#!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; import { createCiPlan } from "./src/ci-plan-lib.mjs"; import { BUILDABLE_IMPLEMENTATION_STATES, resolveDevArtifactServices, serviceInventoryFromServices } from "./src/dev-artifact-services.mjs"; import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; import { readStructuredFile } from "./src/structured-config.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs"; const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "node"; const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null; const defaultReportPath = tempReportPath("dev-artifacts.json"); const buildBackend = "buildkit"; const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout"; const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh"; const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null; const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.yaml"; const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u; const fatalBlockerTypes = new Set([ "contract_blocker", "environment_blocker", "network_blocker", "safety_blocker" ]); const buildableImplementationStates = new Set(BUILDABLE_IMPLEMENTATION_STATES); const sourceStates = new Set([ "source-present", "intentionally-disabled" ]); const runtimeArtifactLanePattern = /^v\d{2,}$/u; const ciArtifactIdentityEnvNames = [ "HWLAB_CI_ARTIFACT_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "BUILDKITE_BUILD_ID" ]; const servicePorts = new Map([ ["hwlab-cloud-api", 6667], ["hwlab-user-billing", 6670], ["hwlab-cloud-web", 8080], ["hwlab-gateway", 7001], ["hwlab-edge-proxy", 6667], ["hwlab-agent-skills", 7430] ]); function parseArgs(argv) { const args = { mode: "preflight", lane: defaultLane, catalogPath: defaultCatalogPath, deployPath: defaultDeployPath, imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", registryPrefix: defaultRegistryPrefix, baseImage: defaultBaseImage, reportPath: defaultReportPath, services: [...SERVICE_IDS], servicesExplicit: false, affectedOnly: true, buildBackend, buildkitCommand: defaultBuildkitCommand, buildkitAddr: defaultBuildkitAddr, externalServiceReportDir: null, externalServiceResultsEnv: false, tektonResultsDir: null, emitReport: true, quietBuild: false, concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4) }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--preflight") args.mode = "preflight"; else if (arg === "--build") args.mode = "build"; else if (arg === "--publish") args.mode = "publish"; else if (arg === "--lane") args.lane = readOption(argv, ++index, arg); else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg); else if (arg === "--deploy-config") args.deployPath = readOption(argv, ++index, arg); else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg); else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg); else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg); else if (arg === "--services") { args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); args.servicesExplicit = true; } else if (arg === "--affected-only") args.affectedOnly = true; else if (arg === "--buildkit-command") args.buildkitCommand = readOption(argv, ++index, arg); else if (arg === "--buildkit-addr") args.buildkitAddr = readOption(argv, ++index, arg); else if (arg === "--external-service-report-dir") args.externalServiceReportDir = readOption(argv, ++index, arg); else if (arg === "--external-service-results-env") args.externalServiceResultsEnv = true; else if (arg === "--tekton-results-dir") args.tektonResultsDir = readOption(argv, ++index, arg); else if (arg === "--no-report") args.emitReport = false; else if (arg === "--quiet-build") args.quietBuild = true; else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4); else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } assert.ok(args.lane === "node" || isRuntimeArtifactLane(args.lane), `unknown artifact lane ${args.lane}`); assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); return args; } function readOption(argv, index, name) { const value = argv[index]; if (!value || value.startsWith("--")) { throw new Error(`${name} requires a value`); } return value; } function parseConcurrency(value, fallback) { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(parsed)) return fallback; return Math.min(Math.max(parsed, 1), 8); } function artifactIdentityValue(env, name) { const value = env?.[name]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function artifactIdentityName(env = process.env) { return ciArtifactIdentityEnvNames.find((name) => artifactIdentityValue(env, name)); } function artifactProducer(env = process.env) { const idName = artifactIdentityName(env); if (!idName) { return { kind: "missing", runId: null, owner: null }; } return { kind: "node-ci-artifact", runId: artifactIdentityValue(env, idName), owner: artifactIdentityValue(env, "HWLAB_CI_ARTIFACT_RUN_OWNER") ?? idName }; } function artifactPublishIdentityGuardFailure({ script, mode }) { return { ok: false, status: "blocked", error: "artifact-publish-identity-required", code: "artifact-publish-identity-required", script, mode, requiredEnv: "HWLAB_CI_ARTIFACT_RUN_ID or standard CI run identity", acceptedEnv: ciArtifactIdentityEnvNames, entrypoint: `HWLAB_CI_ARTIFACT_RUN_ID= HWLAB_CI_ARTIFACT_RUN_OWNER= node scripts/artifact-publish.mjs --publish --report ${tempReportPath("dev-artifacts.json")}`, summary: `${script} ${mode} is a node artifact publish side-effect step and must run under CI artifact identity. Legacy D601 CD transaction identity is not accepted.`, devOnly: true, prodTouched: false, cdLockRequired: false, rolloutAttempted: false, mutationAttempted: false }; } function requireArtifactPublishIdentity({ env = process.env, script, mode }) { return artifactIdentityName(env) ? null : artifactPublishIdentityGuardFailure({ script, mode }); } function printHelp() { console.log([ `usage: node ${cliEntrypoint} [--preflight|--build|--publish]`, "", "Commit-pinned artifact build/publish helper for controlled CI execution.", "", "options:", " --lane LANE node or runtime lane vNN; default: node", ` --catalog-path PATH default: ${defaultCatalogPath}`, ` --deploy-config PATH default: ${defaultDeployPath}`, " --image-tag-mode MODE short or full; v02 uses full source commit IDs", " --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab", " --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local", " --services LIST comma-separated service IDs; runtime lane default comes from deploy.lanes..envReuseServices", " --affected-only default and only supported scope: build/publish affected services and reuse unchanged catalog digests", " --buildkit-command PATH buildctl-daemonless.sh/buildctl command for buildkit backend", " --buildkit-addr ADDR optional buildctl --addr endpoint for a sidecar BuildKit daemon", " --external-service-report-dir DIR collect affected service artifacts from per-service publish reports", " --external-service-results-env collect service artifacts from HWLAB_SERVICE_RESULT_* env vars", " --tekton-results-dir DIR write single-service build result files for Tekton results", ` --report PATH default: ${defaultReportPath}`, " --no-report print JSON without updating the temporary artifact JSON", " --quiet-build keep BuildKit progress quiet; default keeps build output visible", " --concurrency N parallel service build/push workers; default: 4, max: 8" ].join("\n")); } async function readConfig(relativePath) { return readStructuredFile(repoRoot, relativePath); } async function readConfigIfPresent(relativePath, fallback = null) { try { return await readConfig(relativePath); } catch (error) { if (error?.code === "ENOENT") return fallback; throw error; } } function emit(event, payload = {}) { process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`); } function ciTimingEnabled(env = process.env) { return env.HWLAB_NODE_CICD_TIMING === "1" || env.HWLAB_CI_TIMING === "1"; } function emitCiTiming(payload) { emit("node-cicd-timing", { schemaVersion: "v1", source: "scripts/artifact-publish.mjs", pipelineRun: process.env.HWLAB_TEKTON_PIPELINERUN ?? null, taskRun: process.env.HWLAB_TEKTON_TASKRUN ?? null, task: process.env.HWLAB_TEKTON_TASK ?? null, revision: process.env.HWLAB_SOURCE_REVISION ?? null, ...payload }); } function parseDurationMs(value) { const text = String(value ?? "").trim(); if (!text) return null; let total = 0; let matched = false; for (const match of text.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) { matched = true; const amount = Number(match[1]); const unit = match[2]; if (unit === "ms") total += amount; else if (unit === "s") total += amount * 1000; else if (unit === "m") total += amount * 60 * 1000; else if (unit === "h") total += amount * 60 * 60 * 1000; } return matched ? Math.round(total) : null; } function parseBuildkitPlainTimings(text) { const idToStage = new Map(); const timings = new Map(); const stageMatchers = [ { stage: "buildkit-context-transfer", pattern: /load build context|transferring context/iu }, { stage: "cache-import", pattern: /importing cache manifest/iu }, { stage: "cache-export", pattern: /exporting cache/iu } ]; for (const rawLine of String(text ?? "").split("\n")) { const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, "").trim(); const match = line.match(/^#(\d+)\s+(.*)$/u); if (!match) continue; const [, id, body] = match; const identified = stageMatchers.find((item) => item.pattern.test(body)); if (identified) idToStage.set(id, identified.stage); const stage = idToStage.get(id); if (!stage || timings.has(stage)) continue; const durationMatch = body.match(/(?:DONE|done)\s+([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)/iu) ?? body.match(/([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)\s+done/iu); const durationMs = parseDurationMs(durationMatch?.[1]); if (durationMs !== null) timings.set(stage, { stage, status: "succeeded", durationMs, source: "buildkit-plain-progress" }); } return timings; } function parseDockerfileStepTimings(text) { const timings = []; for (const rawLine of String(text ?? "").split("\n")) { const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, ""); const start = line.indexOf("{"); const end = line.lastIndexOf("}"); if (start < 0 || end <= start) continue; let parsed; try { parsed = JSON.parse(line.slice(start, end + 1)); } catch { continue; } if (parsed?.event !== "node-cicd-build-step-timing") continue; timings.push({ stage: parsed.stage, status: parsed.status ?? "unknown", durationMs: Number.isFinite(parsed.durationMs) ? parsed.durationMs : null, source: parsed.source ?? "dockerfile-run" }); } return timings; } function emitBuildkitTimingEvents({ service, result, timingEnabled }) { if (!timingEnabled) return; const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; const buildkitTimings = parseBuildkitPlainTimings(output); for (const stage of ["buildkit-context-transfer", "cache-import", "cache-export"]) { const timing = buildkitTimings.get(stage) ?? { stage, status: "not-observed", durationMs: null, source: "buildkit-plain-progress" }; emitCiTiming({ ...timing, serviceId: service.serviceId, buildkitDurationMs: result.durationMs }); } for (const timing of parseDockerfileStepTimings(output)) { emitCiTiming({ ...timing, serviceId: service.serviceId, buildkitDurationMs: result.durationMs }); } } function blocker({ type, scope, summary, next }) { return { type, scope, status: "open", summary, next }; } function buildkitBaseImagePreflight(baseImage) { const requestedImage = baseImage || defaultBaseImage || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; return { preflight: "hwlab-dev-base-image", environment: ENVIRONMENT_DEV, status: "pass", publishUsable: true, artifactPublish: { issue: "#35", usable: true, reason: "BuildKit pulls the approved builder base image from the registry during k8s-native publish." }, imageSource: "buildkit-registry-pull", requestedImage, localTag: requestedImage, imageId: null, repoDigests: [], candidates: [], rejectedCandidates: [], blockers: [], nextSteps: [], recommendation: { source: "node k8s registry image used by rootless BuildKit.", envVar: "HWLAB_DEV_BASE_IMAGE", recommendedEnvValue: requestedImage }, provision: { required: false, exactBlocker: null, recommendedEnv: { name: "HWLAB_DEV_BASE_IMAGE", value: requestedImage, assignment: `HWLAB_DEV_BASE_IMAGE=${requestedImage}` }, commands: {} }, blockedReport: null, containerEngine: "buildkit-rootless" }; } function summarizeBaseImagePreflight(result) { return { status: result.status, imageSource: result.imageSource, requestedImage: result.requestedImage, localTag: result.localTag, imageId: result.imageId, repoDigests: result.repoDigests, candidates: result.candidates, rejectedCandidates: result.rejectedCandidates, publishUsable: result.publishUsable, blockers: result.blockers, nextSteps: result.nextSteps, recommendation: result.recommendation, provision: result.provision, blockedReport: result.blockedReport, containerEngine: result.containerEngine }; } function summarizeRegistryCapabilities(result) { return { version: result.version, registryPrefix: result.registryPrefix, generatedAt: result.generatedAt, classification: result.classification, dimensions: result.dimensions, interpretation: result.interpretation }; } function baseImagePreflightBlocker(result) { return blocker({ type: "environment_blocker", scope: "base-image", summary: result.blockers.length ? result.blockers.join("; ") : "DEV builder base image preflight did not return ready.", next: result.nextSteps.length ? result.nextSteps.join(" ") : "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish." }); } function commandLine(command, args) { return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function dockerfileQuote(value) { return JSON.stringify(String(value)); } function buildkitCacheRef(registryPrefix, serviceId) { return `${registryPrefix}/cache/${serviceId}`; } function envReuseImageRef(registryPrefix, serviceId, environmentInputHash) { const tag = String(environmentInputHash || "unknown").slice(0, 12); return `${registryPrefix}/${serviceId}-env:env-${tag}`; } function envReuseCacheRef(registryPrefix, serviceId) { return `${registryPrefix}/cache/${serviceId}-env`; } function buildkitRegistryOption(registryPrefix) { const parsed = parseRegistryPrefix(registryPrefix); return parsed.host === "127.0.0.1" || parsed.host === "localhost" ? ",registry.insecure=true" : ""; } function buildkitDigestFromMetadata(value) { const keys = [ "containerimage.digest", "containerimage.descriptor.digest", "image.digest", "digest" ]; for (const key of keys) { const direct = value?.[key]; if (shaDigestPattern.test(direct ?? "")) return direct; } const nested = value?.containerimage?.digest ?? value?.containerimage?.descriptor?.digest; return shaDigestPattern.test(nested ?? "") ? nested : null; } async function buildkitCommandPreflight(command) { const result = await run("sh", ["-c", `command -v ${shellQuote(command)}`]); return { ok: result.code === 0, command, result }; } async function run(command, args, options = {}) { const startedAt = Date.now(); const child = spawn(command, args, { cwd: options.cwd ?? repoRoot, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const code = await new Promise((resolve) => { child.on("error", () => resolve(127)); child.on("close", resolve); }); return { command: commandLine(command, args), code, stdout, stderr, durationMs: Date.now() - startedAt }; } function bunExecutable() { return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; } async function inspectCloudWebDistFreshness(webRoot) { const result = await run(bunExecutable(), ["web/hwlab-cloud-web/scripts/dist-contract.ts", "--inspect", "--root", webRoot]); if (result.code !== 0) { return { status: "blocked", distPath: "web/hwlab-cloud-web/dist", buildCommand: "cd web/hwlab-cloud-web && bun run build", freshnessCommand: "cd web/hwlab-cloud-web && bun run build", files: [], mismatches: ["dist-contract-inspect"], error: tailText(`${result.stdout}\n${result.stderr}`) }; } return JSON.parse(result.stdout); } function assertDevOnlyContracts(catalog, deployManifest) { assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog.environment must be dev"); assert.equal(catalog.profile, ENVIRONMENT_DEV, "catalog.profile must be dev"); assert.equal(catalog.namespace, "hwlab-dev", "catalog.namespace must be hwlab-dev"); assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "catalog.allowedProfiles must only allow dev"); assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "catalog.forbiddenProfiles must forbid prod"); assert.equal(deployManifest.environment, ENVIRONMENT_DEV, "deploy.environment must be dev"); assert.equal(deployManifest.namespace, "hwlab-dev", "deploy.namespace must be hwlab-dev"); assert.equal(deployManifest.profiles?.dev?.enabled, true, "deploy dev profile must be enabled"); assert.equal(deployManifest.profiles?.prod?.enabled, false, "deploy prod profile must stay disabled"); const catalogIds = catalog.services.map((service) => service.serviceId); const deployIds = deployManifest.services.map((service) => service.serviceId); assert.deepEqual(catalogIds, SERVICE_IDS, "catalog services must match frozen DEV service IDs"); assert.deepEqual(deployIds, SERVICE_IDS, "deploy services must match frozen DEV service IDs"); for (const service of [...catalog.services, ...deployManifest.services]) { assert.equal(service.profile, ENVIRONMENT_DEV, `${service.serviceId}.profile must be dev`); assert.equal(service.namespace, "hwlab-dev", `${service.serviceId}.namespace must be hwlab-dev`); assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`); } } function laneDeployConfig(deployManifest, lane) { return deployManifest?.lanes?.[lane] ?? null; } function isRuntimeArtifactLane(lane) { return runtimeArtifactLanePattern.test(String(lane ?? "")); } function isHttpEndpoint(value) { if (typeof value !== "string" || value.trim().length === 0) return false; try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } function assertRuntimeLaneContracts(catalog, deployManifest, laneName) { const lane = laneDeployConfig(deployManifest, laneName); assert.ok(lane && typeof lane === "object", `deploy.lanes.${laneName} must exist for ${laneName} artifact publish`); assert.ok(String(lane.namespace ?? "").trim(), `deploy.lanes.${laneName}.namespace must not be empty`); assert.ok(isHttpEndpoint(lane.endpoint), `deploy.lanes.${laneName}.endpoint must be a non-empty http(s) URL`); const declaredServiceIds = new Set(runtimeLaneServiceIdsFromDeclarations(deployManifest, laneName)); assert.ok( runtimeLaneServiceIdsFromDeploy(deployManifest, laneName).every((serviceId) => declaredServiceIds.has(serviceId)), `deploy.lanes.${laneName}.serviceDeclarations must cover envReuseServices` ); assert.equal(catalog.environment, laneName, `catalog.environment must be ${laneName}`); assert.equal(catalog.profile, laneName, `catalog.profile must be ${laneName}`); assert.equal(catalog.namespace, lane.namespace, `catalog.namespace must be ${lane.namespace}`); if (catalog.endpoint != null) assert.ok(isHttpEndpoint(catalog.endpoint), "catalog.endpoint must be a non-empty http(s) URL"); assert.deepEqual(catalog.allowedProfiles, [laneName], `catalog.allowedProfiles must only allow ${laneName}`); assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod"); const catalogIds = (catalog.services ?? []).map((service) => service.serviceId); assert.deepEqual(catalogIds, runtimeLaneServiceIdsFromDeploy(deployManifest, laneName), `${laneName} catalog services must match deploy.lanes.${laneName}.envReuseServices`); for (const service of catalog.services ?? []) { assert.equal(service.profile, laneName, `${service.serviceId}.profile must be ${laneName}`); assert.equal(service.namespace, lane.namespace, `${service.serviceId}.namespace must be ${lane.namespace}`); assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`); } } function assertArtifactContracts(args, catalog, deployManifest) { if (isRuntimeArtifactLane(args.lane)) return assertRuntimeLaneContracts(catalog, deployManifest, args.lane); return assertDevOnlyContracts(catalog, deployManifest); } function imageTagForCommit(args, commitId) { return args.imageTagMode === "full" ? commitId : commitId.slice(0, 7); } function artifactEnvironment(args) { return isRuntimeArtifactLane(args.lane) ? args.lane : ENVIRONMENT_DEV; } function artifactCatalogSkeleton({ args, deployManifest, commitId }) { const tag = imageTagForCommit(args, commitId); const laneConfig = laneDeployConfig(deployManifest, args.lane); const laneEndpoint = isRuntimeArtifactLane(args.lane) ? laneConfig?.endpoint : "http://74.48.78.17:16667"; const namespace = isRuntimeArtifactLane(args.lane) ? laneConfig?.namespace : "hwlab-dev"; assert.ok(isHttpEndpoint(laneEndpoint), "deploy lane endpoint must be a non-empty http(s) URL"); assert.ok(String(namespace ?? "").trim(), "deploy lane namespace must not be empty"); return { catalogVersion: "v1", kind: "hwlab-artifact-catalog", environment: artifactEnvironment(args), profile: artifactEnvironment(args), namespace, endpoint: laneEndpoint, commitId: tag, artifactState: "contract-skeleton", publish: { ciPublished: false, registryVerified: false, provenance: "not_available_until_publish", note: `${artifactEnvironment(args)} artifact catalog skeleton initialized without DEV/node fallback.` }, allowedProfiles: [artifactEnvironment(args)], forbiddenProfiles: isRuntimeArtifactLane(args.lane) ? ["dev", "prod"] : ["prod"], services: serviceIdsForArtifactLane(args, deployManifest).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId })) }; } function artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }) { const laneConfig = laneDeployConfig(deployManifest, args.lane); const namespace = isRuntimeArtifactLane(args.lane) ? laneConfig?.namespace : "hwlab-dev"; const base = { serviceId, commitId: tag, sourceCommitId: commitId, image: imageRef(args.registryPrefix, serviceId, tag), imageTag: tag, digest: "not_published", publishState: "skeleton-only", profile: artifactEnvironment(args), namespace, healthPath: "/health/live", sourceState: "source-present", publishEnabled: true, artifactRequired: true, artifactScope: "required", notPublishedReason: "publish_not_run" }; const envReuseServiceIds = envReuseServiceIdsForLane(deployManifest, args.lane); if (!envReuseServiceIds.has(serviceId)) return base; const bootSh = bootShForService(deployManifest, serviceId, args.lane); const environmentImage = envReuseImageRef(args.registryPrefix, serviceId, commitId); return { ...base, runtimeMode: v02EnvReuseRuntimeMode, envReuse: true, image: environmentImage, imageTag: imageTagFromReference(environmentImage), environmentImage, environmentDigest: "not_published", environmentInputHash: null, codeInputHash: null, bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: commitId, bootSh, bootEnvEvidence: bootEnvEvidence({ service: { serviceId, bootCommit: commitId, bootSh }, commitId, environmentImage, environmentDigest: "not_published" }) }; } function envReuseServiceIdsForLane(deployManifest, lane) { if (!isRuntimeArtifactLane(lane)) return new Set(); const configured = deployManifest?.lanes?.[lane]?.envReuseServices; return new Set(normalizeServiceIdList(configured)); } function serviceIdsForArtifactLane(args, deployManifest) { if (!isRuntimeArtifactLane(args.lane)) return [...SERVICE_IDS]; return runtimeLaneServiceIdsFromDeploy(deployManifest, args.lane); } function applyDeployDefaultServices(args, deployManifest) { if (args.servicesExplicit) return; args.services = serviceIdsForArtifactLane(args, deployManifest); } function runtimeLaneServiceIdsFromDeploy(deployManifest, lane) { const serviceIds = normalizeServiceIdList(deployManifest?.lanes?.[lane]?.envReuseServices); assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`); return serviceIds; } function runtimeLaneServiceIdsFromDeclarations(deployManifest, lane) { return normalizeServiceIdList(Object.keys(deployManifest?.lanes?.[lane]?.serviceDeclarations ?? {})); } function normalizeServiceIdList(value) { return uniquePreserveOrder((Array.isArray(value) ? value : []).map((item) => String(item ?? "").trim()).filter(Boolean)); } 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; } function bootShForService(deployManifest, serviceId, lane) { const value = deployManifest?.lanes?.[lane]?.bootScripts?.[serviceId]; assert.ok(value, `deploy.lanes.${lane}.bootScripts.${serviceId} is required`); const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, ""); assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`); return text; } function normalizeCatalogForLane(catalog, lane, deployManifest) { if (!catalog || !isRuntimeArtifactLane(lane)) return catalog; const allowed = new Set(runtimeLaneServiceIdsFromDeploy(deployManifest, lane)); return { ...catalog, services: (catalog.services ?? []).filter((service) => allowed.has(service.serviceId)) }; } function parseRegistryPrefix(prefix) { const normalized = prefix.replace(/\/+$/u, ""); const [hostPort, ...pathParts] = normalized.split("/"); const host = hostPort.split(":")[0].toLowerCase(); return { normalized, hostPort, host, namespace: pathParts.join("/") }; } function isPrivateHost(host) { if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true; if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(host)) return true; if (/^192\.168\.\d{1,3}\.\d{1,3}$/u.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/u.test(host)) return true; if (host.endsWith(".local") || host.endsWith(".svc") || host.endsWith(".cluster.local")) return true; return false; } function validateRegistryPrefix(prefix) { const parsed = parseRegistryPrefix(prefix); assert.ok(parsed.hostPort, "registry prefix must include a registry host"); assert.ok(parsed.namespace, "registry prefix must include an image namespace, for example 127.0.0.1:5000/hwlab"); assert.ok(!/prod|production/iu.test(parsed.normalized), "registry prefix must not include prod"); assert.ok( !/(^|\/)(ghcr\.io|docker\.io|index\.docker\.io|quay\.io|gcr\.io|registry-1\.docker\.io)(\/|$)/iu.test(parsed.normalized), "registry prefix must not target a third-party registry" ); assert.ok(isPrivateHost(parsed.host), "registry prefix must target localhost or a private/internal host"); return parsed.normalized; } async function gitValue(args) { const result = await run("git", args); if (result.code !== 0) { throw new Error(`${result.command} failed: ${result.stderr.trim() || result.stdout.trim()}`); } return result.stdout.trim(); } function repoLabelFromRemote(remoteUrl) { const githubMatch = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/iu); if (githubMatch) { return githubMatch[1]; } try { const url = new URL(remoteUrl); url.username = ""; url.password = ""; return `${url.host}${url.pathname.replace(/\.git$/u, "")}`; } catch { return "pikasTech/HWLAB"; } } async function resolveServices(serviceIds, catalog, allowedServiceIds = SERVICE_IDS) { return resolveDevArtifactServices(repoRoot, serviceIds, allowedServiceIds, catalog); } function dockerfile(baseImage, port, labels = []) { const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="@oven/bun-linux-x64@1.3.13" ;; aarch64|arm64) bun_pkg="@oven/bun-linux-aarch64@1.3.13" ;; *) echo "unsupported bun-command architecture: $(uname -m)" >&2; false ;; esac && npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg" && test -x "node_modules/\${bun_pkg%@*}/bin/bun" && ln -sf "/app/node_modules/\${bun_pkg%@*}/bin/bun" /usr/local/bin/bun; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'node-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`; const runtimeReadinessScript = `set -eu; fail() { echo "hwlab-artifact-runtime-check-failed:$1" >&2; exit 1; }; mkdir -p /workspace /codex-home; rm -rf /workspace/hwlab; ln -s /app /workspace/hwlab; if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then test -x /usr/local/bin/bun || fail bun-missing; /usr/local/bin/bun --version >/tmp/hwlab-bun-version.txt || fail bun-version; fi; chmod -R a+rwX /app /workspace /codex-home || fail chmod; test -x /app/node_modules/.bin/codex || fail codex-bin-missing; /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt || fail codex-version; test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex || fail codex-native-missing`; return [ `FROM ${baseImage}`, "WORKDIR /app", ...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`), "ARG HWLAB_ARTIFACT_KIND", "ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills", "COPY package.json ./package.json", "COPY package-lock.json* ./", `RUN ${dependencyTimingScript}`, "COPY internal ./internal", "COPY cmd ./cmd", "COPY scripts ./scripts", "COPY web ./web", "COPY tools ./tools", "COPY skills ./skills", "COPY .hwlab ./.hwlab", "COPY deploy ./deploy", "RUN printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-cli.ts \"$@\"' > /usr/local/bin/hwpod && chmod 755 /usr/local/bin/hwpod && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-ctl.ts \"$@\"' > /usr/local/bin/hwpod-ctl && chmod 755 /usr/local/bin/hwpod-ctl && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-compiler-cli.ts \"$@\"' > /usr/local/bin/hwpod-compiler && chmod 755 /usr/local/bin/hwpod-compiler", `RUN ${runtimeReadinessScript}`, "RUN cp /app/internal/dev-entrypoint/artifact-runtime.mjs /usr/local/bin/hwlab-dev-artifact-runtime.mjs && chmod 755 /usr/local/bin/hwlab-dev-artifact-runtime.mjs", "ARG HWLAB_ENVIRONMENT", "ARG HWLAB_SERVICE_ID", "ARG HWLAB_SERVICE_ENTRYPOINT", "ARG HWLAB_COMMIT_ID", "ARG HWLAB_REVISION", "ARG HWLAB_IMAGE", "ARG HWLAB_IMAGE_TAG", "ARG HWLAB_IMAGE_DIGEST", "ARG HWLAB_BUILD_CREATED_AT", "ARG HWLAB_BUILD_SOURCE", "ARG HWLAB_BUILD_PROVENANCE", "ARG PORT", "ARG HWLAB_PORT", "ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT", "ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID", "ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND", "ENV HWLAB_SERVICE_ENTRYPOINT=$HWLAB_SERVICE_ENTRYPOINT", "ENV HWLAB_COMMIT_ID=$HWLAB_COMMIT_ID", "ENV HWLAB_REVISION=$HWLAB_REVISION", "ENV HWLAB_IMAGE=$HWLAB_IMAGE", "ENV HWLAB_IMAGE_TAG=$HWLAB_IMAGE_TAG", "ENV HWLAB_IMAGE_DIGEST=$HWLAB_IMAGE_DIGEST", "ENV HWLAB_BUILD_CREATED_AT=$HWLAB_BUILD_CREATED_AT", "ENV HWLAB_BUILD_SOURCE=$HWLAB_BUILD_SOURCE", "ENV HWLAB_BUILD_PROVENANCE=$HWLAB_BUILD_PROVENANCE", "ENV PORT=$PORT", "ENV HWLAB_PORT=$HWLAB_PORT", `EXPOSE ${port}`, "CMD [\"node\", \"/usr/local/bin/hwlab-dev-artifact-runtime.mjs\"]", "" ].join("\n"); } function envReuseDockerfile(baseImage, labels = [], recipe, service = null) { const normalizedRecipe = normalizeEnvReuseRecipe(recipe); const runtimeRoot = normalizedRecipe.runtimeNodeModulesPath.replace(/\/node_modules$/u, ""); const downloadEnvPrefix = shellEnvPrefix(downloadEnvPairs(normalizedRecipe.downloadStack)); const npmConfigScript = npmDownloadConfigScript(normalizedRecipe.downloadStack); const goService = service?.runtimeKind === "go-service" || service?.artifactKind === "go-service"; const goCacheEnabled = Boolean(goService && normalizedRecipe.runtimeGoModCachePath && normalizedRecipe.runtimeGoBuildCachePath); const goProxy = normalizedRecipe.downloadStack.goProxy || "https://proxy.golang.org,direct"; const goEnv = goCacheEnabled ? [ `ENV GOMODCACHE=${normalizedRecipe.runtimeGoModCachePath}`, `ENV GOCACHE=${normalizedRecipe.runtimeGoBuildCachePath}`, `ENV GOPROXY=${goProxy}`, `ENV GOTOOLCHAIN=${normalizedRecipe.goToolchain || "local"}` ] : []; const goBuildEnvPrefix = goCacheEnabled ? shellEnvPrefix([ ["GOMODCACHE", normalizedRecipe.runtimeGoModCachePath], ["GOCACHE", normalizedRecipe.runtimeGoBuildCachePath], ["GOPROXY", goProxy], ["GOTOOLCHAIN", normalizedRecipe.goToolchain || "local"] ]) : ""; const goDownloadScript = goCacheEnabled ? `RUN ${downloadEnvPrefix}${goBuildEnvPrefix}set -eu; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; go mod download` : null; const goReadiness = goCacheEnabled ? `; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; command -v go >/dev/null; go version >/tmp/hwlab-env-go-version.txt` : ""; const downloadDependencyScript = `set -eu; ${npmConfigScript}; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path="$(command -v bun)"; if [ "$bun_path" != "/usr/local/bin/bun" ]; then ln -sf "$bun_path" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.x64}@${normalizedRecipe.bunVersion}" ;; aarch64|arm64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.arm64}@${normalizedRecipe.bunVersion}" ;; *) echo "unsupported bun architecture: $(uname -m)" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg"; bun_bin="node_modules/\${bun_pkg%@*}/bin/bun"; test -x "$bun_bin"; ln -sf "${runtimeRoot}/$bun_bin" /usr/local/bin/bun; fi`; const osPackageScript = `set -eu; apt-get update; apt-get install -y --no-install-recommends ${normalizedRecipe.osPackages.map(shellQuote).join(" ")}; rm -rf /var/lib/apt/lists/*`; const aliasScript = normalizedRecipe.hwpodAliases.map((alias) => `printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/${alias.script} \"$@\"' > /usr/local/bin/${alias.command}; chmod 755 /usr/local/bin/${alias.command}`).join("; "); const readinessScript = `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt${goReadiness}; ${aliasScript}`; return [ `FROM ${baseImage}`, `WORKDIR ${runtimeRoot}`, ...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`), "COPY package.json ./package.json", "COPY package-lock.json* ./", ...(goCacheEnabled ? ["COPY go.mod ./go.mod", "COPY go.sum* ./"] : []), `RUN ${downloadEnvPrefix}${osPackageScript}`, `RUN ${downloadEnvPrefix}${downloadDependencyScript}`, ...(goDownloadScript ? [goDownloadScript] : []), `COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`, `RUN ${readinessScript}`, "ENV HWLAB_RUNTIME_MODE=env-reuse-git-mirror-checkout", `ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`, ...goEnv, "ENV PATH=/usr/local/bin:$PATH", "CMD [\"/usr/local/bin/bun\", \"/usr/local/bin/hwlab-env-reuse-launcher.ts\"]", "" ].join("\n"); } function normalizeEnvReuseRecipe(recipe) { assert.ok(recipe && typeof recipe === "object", "envRecipe must be provided by deploy.lanes..envRecipe"); const normalized = { osPackages: normalizeStringList(recipe.osPackages), bunVersion: String(recipe.bunVersion ?? "").trim(), launcherPath: normalizeRepoPath(recipe.launcherPath), runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath), runtimeGoModCachePath: normalizeAbsolutePath(recipe.runtimeGoModCachePath), runtimeGoBuildCachePath: normalizeAbsolutePath(recipe.runtimeGoBuildCachePath), goToolchain: String(recipe.goToolchain ?? "").trim(), downloadStack: normalizeDownloadStack(recipe.downloadStack), hwpodAliases: normalizeHwpodAliases(recipe.hwpodAliases) }; assert.ok(normalized.osPackages.length > 0, "envRecipe.osPackages must not be empty"); assert.match(normalized.bunVersion, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u, "envRecipe.bunVersion must be a semver-like version"); assert.ok(normalized.launcherPath && !normalized.launcherPath.split("/").includes(".."), "envRecipe.launcherPath must be repo-relative"); assert.ok(normalized.runtimeNodeModulesPath, "envRecipe.runtimeNodeModulesPath must be absolute"); assert.ok(normalized.hwpodAliases.length > 0, "envRecipe.hwpodAliases must not be empty"); return normalized; } function normalizeDownloadStack(value) { value = effectiveDownloadStackConfig(value, process.env); assert.ok(value && typeof value === "object", "envRecipe.downloadStack must be configured in deploy/deploy.yaml"); const normalized = { httpProxy: normalizeOptionalHttpUrl(value.httpProxy), httpsProxy: normalizeOptionalHttpUrl(value.httpsProxy), noProxy: normalizeStringList(value.noProxy), npmRegistry: normalizeOptionalHttpUrl(value.npmRegistry), npmFetchTimeoutMs: Number(value.npmFetchTimeoutMs), goProxy: String(value.goProxy ?? "").trim(), bunPackages: { x64: normalizePackageName(value.bunPackages?.x64), arm64: normalizePackageName(value.bunPackages?.arm64) } }; assert.ok(normalized.npmRegistry, "envRecipe.downloadStack.npmRegistry must be an http(s) URL"); assert.ok(Number.isInteger(normalized.npmFetchTimeoutMs) && normalized.npmFetchTimeoutMs > 0, "envRecipe.downloadStack.npmFetchTimeoutMs must be a positive integer"); assert.ok(normalized.bunPackages.x64 && normalized.bunPackages.arm64, "envRecipe.downloadStack.bunPackages.x64/arm64 are required"); return normalized; } function effectiveDownloadStackConfig(value, env = process.env) { if (!value || typeof value !== "object" || !usesK8sNativeDownloadProxy(env)) return value; const httpProxy = textEnv(env.HTTP_PROXY) || textEnv(env.http_proxy) || value.httpProxy; const httpsProxy = textEnv(env.HTTPS_PROXY) || textEnv(env.https_proxy) || httpProxy || value.httpsProxy; const noProxy = noProxyFromEnv(env) || value.noProxy; return { ...value, httpProxy, httpsProxy, noProxy }; } function usesK8sNativeDownloadProxy(env = process.env) { return env.HWLAB_DEV_REGISTRY_K8S_NATIVE === "1" || Boolean(textEnv(env.HWLAB_TEKTON_PIPELINERUN)); } function noProxyFromEnv(env = process.env) { const text = textEnv(env.NO_PROXY) || textEnv(env.no_proxy); if (!text) return null; return text.split(",").map((item) => item.trim()).filter(Boolean); } function textEnv(value) { const text = String(value ?? "").trim(); return text || null; } function normalizeOptionalHttpUrl(value) { const text = String(value ?? "").trim(); if (!text) return null; try { const url = new URL(text); if (url.protocol === "http:" || url.protocol === "https:") return text; } catch {} return null; } function normalizePackageName(value) { const text = String(value ?? "").trim(); return text && !text.includes(" ") ? text : null; } function downloadEnvPairs(stack) { return [ ["HTTP_PROXY", stack.httpProxy], ["http_proxy", stack.httpProxy], ["HTTPS_PROXY", stack.httpsProxy], ["https_proxy", stack.httpsProxy], ["NO_PROXY", stack.noProxy.join(",")], ["no_proxy", stack.noProxy.join(",")] ].filter(([, value]) => value); } function shellEnvPrefix(pairs) { return pairs.length === 0 ? "" : `${pairs.map(([name, value]) => `${name}=${shellQuote(value)}`).join(" ")} `; } function npmDownloadConfigScript(stack) { const commands = [ `npm config set registry ${shellQuote(stack.npmRegistry)}`, `npm config set fetch-timeout ${Number(stack.npmFetchTimeoutMs)}` ]; if (stack.httpProxy) commands.push(`npm config set proxy ${shellQuote(stack.httpProxy)}`); if (stack.httpsProxy) commands.push(`npm config set https-proxy ${shellQuote(stack.httpsProxy)}`); if (stack.noProxy.length > 0) commands.push(`npm config set noproxy ${shellQuote(stack.noProxy.join(","))}`); return commands.join("; "); } function normalizeStringList(value) { const source = Array.isArray(value) ? value : []; return source.map((item) => String(item ?? "").trim()).filter(Boolean); } function normalizeRepoPath(value) { return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").replace(/^\/+/, "").trim(); } function normalizeAbsolutePath(value) { const text = String(value ?? "").trim(); return text.startsWith("/") && !text.includes("..") ? text : null; } function normalizeHwpodAliases(value) { const source = Array.isArray(value) ? value : []; return source.map((item) => ({ command: String(item?.command ?? "").trim(), script: normalizeRepoPath(item?.script) })).filter((item) => item.command && item.script); } function imageRef(registryPrefix, serviceId, tag) { return `${registryPrefix}/${serviceId}:${tag}`; } function imageRepository(registryPrefix, serviceId) { return `${registryPrefix}/${serviceId}`; } function tailText(value, maxLength = 2500) { const text = value.trim(); if (text.length <= maxLength) return text; return text.slice(text.length - maxLength); } function sourceStateBlocker(service) { return blocker({ type: "contract_blocker", scope: service.serviceId, summary: `${service.serviceId} has invalid sourceState ${service.sourceState ?? "unset"}.`, next: `Set ${service.serviceId} sourceState to source-present or intentionally-disabled.` }); } async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds = new Set() }) { const blockers = []; let registryPrefix = args.registryPrefix; const buildRequired = (args.mode === "build" || args.mode === "publish") && buildServiceIds.size > 0; if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; } else if (buildRequired) { blockers.push(baseImagePreflightBlocker(baseImagePreflight)); } try { registryPrefix = validateRegistryPrefix(args.registryPrefix); } catch (error) { blockers.push( blocker({ type: "safety_blocker", scope: "registry", summary: error.message, next: "Use the node local/internal registry prefix, for example 127.0.0.1:5000/hwlab." }) ); } try { assertArtifactContracts(args, catalog, deployManifest); } catch (error) { blockers.push( blocker({ type: "contract_blocker", scope: `${args.lane}-contract`, summary: error.message, next: `Fix ${args.catalogPath} and ${args.deployPath} so the selected artifact lane covers the frozen service IDs without crossing namespaces.` }) ); } const baseImageTag = args.baseImage ? args.baseImage.split(":").at(-1) : ""; for (const tag of [baseImageTag, args.services.length === 1 ? args.services[0] : ""]) { if (mutableTags.has(tag)) { blockers.push( blocker({ type: "safety_blocker", scope: "tag-policy", summary: `mutable tag ${tag} is not allowed for DEV artifact identity`, next: "Use the immutable git commit tag generated by this script." }) ); } } if (buildRequired) { const buildkitPreflight = await buildkitCommandPreflight(args.buildkitCommand); if (!buildkitPreflight.ok) { blockers.push( blocker({ type: "environment_blocker", scope: "buildkit-command", summary: `BuildKit command ${args.buildkitCommand} is not available in the publish task image.`, next: "Use the node Tekton BuildKit path that prepares buildctl/buildkitd before running --publish." }) ); } } for (const service of services) { if (!sourceStates.has(service.sourceState)) { blockers.push(sourceStateBlocker(service)); } else if (service.sourceState === "intentionally-disabled") { blockers.push( blocker({ type: "runtime_blocker", scope: service.serviceId, summary: `${service.serviceId} is intentionally disabled for MVP artifact publication.`, next: `Keep ${service.serviceId} out of the MVP artifact catalog, or add a source entrypoint before publishing it.` }) ); } else if (service.artifactRequired && service.implementationState === "missing-runtime-entrypoint") { blockers.push( blocker({ type: "runtime_blocker", scope: service.serviceId, summary: `${service.serviceId} has no runtime entrypoint; a health-only placeholder image was not built as a real implementation.`, next: `Add cmd/${service.serviceId}/main.mjs, cmd/${service.serviceId}/main.ts, or a dedicated Dockerfile for ${service.serviceId}.` }) ); } } return blockers; } function hasFatalBlocker(blockers) { return blockers.some((item) => fatalBlockerTypes.has(item.type)); } async function buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }) { if (service.envReuse) return buildEnvReuseService({ args, repo, commitId, service, buildCreatedAt }); const tag = shortCommit; const ref = imageRef(args.registryPrefix, service.serviceId, tag); const buildSource = `${repo}@${commitId}`; if (!buildableImplementationStates.has(service.implementationState)) { return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "blocked_missing_runtime", digest: "not_published" }; } if (service.runtimeKind === "cloud-web") { const check = await run(bunExecutable(), ["run", "--cwd", "web/hwlab-cloud-web", "check"]); if (check.code !== 0) { return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "build_failed", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: service.serviceId, summary: "cloud web source check failed before BuildKit image build", next: "Run bun run --cwd web/hwlab-cloud-web check, fix TypeScript/unit/build failures, then rerun --publish." }), logTail: tailText(`${check.stdout}\n${check.stderr}`), cloudWebCheckDurationMs: check.durationMs }; } service = { ...service, cloudWebCheckDurationMs: check.durationMs }; } const port = servicePorts.get(service.serviceId) ?? 8080; const labels = [ ["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"], ["org.opencontainers.image.revision", commitId], ["org.opencontainers.image.created", buildCreatedAt], ["org.opencontainers.image.title", service.serviceId], ["hwlab.pikastech.local/repo", repo], ["hwlab.pikastech.local/commit", commitId], ["hwlab.pikastech.local/build-created-at", buildCreatedAt], ["hwlab.pikastech.local/build-source", buildSource], ["hwlab.pikastech.local/service-id", service.serviceId], ["hwlab.pikastech.local/environment", ENVIRONMENT_DEV] ]; const envs = [ ["HWLAB_ENVIRONMENT", ENVIRONMENT_DEV], ["HWLAB_SERVICE_ID", service.serviceId], ["HWLAB_ARTIFACT_KIND", service.runtimeKind], ["HWLAB_SERVICE_ENTRYPOINT", service.entrypoint ?? ""], ["HWLAB_COMMIT_ID", commitId], ["HWLAB_REVISION", commitId], ["HWLAB_IMAGE", ref], ["HWLAB_IMAGE_TAG", tag], ["HWLAB_IMAGE_DIGEST", "unknown"], ["HWLAB_BUILD_CREATED_AT", buildCreatedAt], ["HWLAB_BUILD_SOURCE", buildSource], ["HWLAB_BUILD_PROVENANCE", cliEntrypoint], ["PORT", String(port)], ["HWLAB_PORT", String(port)] ]; assert.equal(args.buildBackend, "buildkit", "artifact publish must use BuildKit only"); return buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs }); } async function buildEnvReuseService({ args, repo, commitId, service, buildCreatedAt }) { const ref = envReuseImageRef(args.registryPrefix, service.serviceId, service.environmentInputHash); const buildSource = `${repo}@env:${service.environmentInputHash}`; const labels = [ ["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"], ["org.opencontainers.image.revision", commitId], ["org.opencontainers.image.created", buildCreatedAt], ["org.opencontainers.image.title", `${service.serviceId}-env`], ["hwlab.pikastech.local/runtime-mode", v02EnvReuseRuntimeMode], ["hwlab.pikastech.local/service-id", service.serviceId], ["hwlab.pikastech.local/environment-input-hash", service.environmentInputHash], ["hwlab.pikastech.local/build-created-at", buildCreatedAt] ]; const artifact = await buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreatedAt, buildSource, labels }); return withEnvReuseArtifactFields({ artifact, service, commitId, buildCreatedAt, buildSource }); } async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreatedAt, buildSource, labels }) { const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-env-reuse-${service.serviceId}-`)); const metadataPath = path.join(tmpDir, "metadata.json"); const dockerfilePath = path.join(tmpDir, "Dockerfile"); const cacheRef = envReuseCacheRef(args.registryPrefix, service.serviceId); const registryOption = buildkitRegistryOption(args.registryPrefix); const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : []; const startedAt = Date.now(); try { await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, service.envReuseRecipe, service)); const argsList = [ ...buildkitAddrArgs, "build", "--frontend", "dockerfile.v0", "--local", `context=${repoRoot}`, "--local", `dockerfile=${tmpDir}`, "--opt", "filename=Dockerfile", "--metadata-file", metadataPath, "--import-cache", `type=registry,ref=${cacheRef}${registryOption}`, "--export-cache", `type=registry,ref=${cacheRef},mode=max${registryOption}`, "--output", `type=image,name=${ref},push=true${registryOption}` ]; if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain"); const buildEnv = { ...process.env }; if (!args.buildkitAddr) { buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox"; buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime"); await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true }); } const result = await run(args.buildkitCommand, argsList, { env: buildEnv }); emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) }); if (result.code !== 0) { return { ...service, image: ref, imageTag: imageTagFromReference(ref), buildCreatedAt, buildSource, status: "build_failed", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: service.serviceId, summary: `BuildKit env image build failed for ${service.serviceId}`, next: "Inspect the env image BuildKit output and base image git/bun availability." }), logTail: tailText(`${result.stdout}\n${result.stderr}`), buildBackend: "buildkit-env-reuse", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: result.durationMs }; } let metadata = {}; try { metadata = JSON.parse(await readFile(metadataPath, "utf8")); } catch {} const digest = buildkitDigestFromMetadata(metadata); if (!shaDigestPattern.test(digest ?? "")) { return { ...service, image: ref, imageTag: imageTagFromReference(ref), buildCreatedAt, buildSource, status: "published_unverified_digest", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: service.serviceId, summary: `BuildKit completed env image for ${service.serviceId} but did not expose an immutable digest`, next: "Inspect BuildKit metadata output before updating the v02 env catalog." }), pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), buildBackend: "buildkit-env-reuse", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: Date.now() - startedAt }; } return { ...service, image: ref, imageTag: imageTagFromReference(ref), buildCreatedAt, buildSource, status: args.mode === "publish" ? "published" : "built", digest, repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`, buildBackend: "buildkit-env-reuse", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: Date.now() - startedAt, publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null, pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200) }; } finally { await rm(tmpDir, { recursive: true, force: true }); } } function withEnvReuseArtifactFields({ artifact, service, commitId, buildCreatedAt = null, buildSource = null }) { const environmentImage = artifact.image ?? service.environmentImage ?? null; const environmentDigest = artifact.digest ?? service.environmentDigest ?? "not_published"; return { ...artifact, runtimeMode: v02EnvReuseRuntimeMode, envReuse: true, envChanged: service.envChanged ?? null, codeChanged: service.codeChanged ?? null, commitId, sourceCommitId: commitId, environmentImage, environmentDigest, environmentInputHash: service.environmentInputHash ?? null, codeInputHash: service.codeInputHash ?? null, bootRepo: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git", bootCommit: service.bootCommit ?? commitId, bootSh: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`, image: environmentImage, imageTag: imageTagFromReference(environmentImage), digest: environmentDigest, buildCreatedAt: artifact.buildCreatedAt ?? buildCreatedAt, buildSource: artifact.buildSource ?? buildSource, bootEnvEvidence: bootEnvEvidence({ service, commitId, environmentImage, environmentDigest }) }; } function bootEnvEvidence({ service, commitId, environmentImage, environmentDigest }) { return { runtimeMode: v02EnvReuseRuntimeMode, environmentDigest: environmentDigest ?? null, environmentImage: environmentImage ?? null, bootRepo: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git", bootCommit: service.bootCommit ?? commitId, bootSh: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`, env: { HWLAB_BOOT_REPO: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git", HWLAB_BOOT_COMMIT: service.bootCommit ?? commitId, HWLAB_BOOT_SH: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`, HWLAB_ENVIRONMENT_IMAGE: environmentImage ?? "", HWLAB_ENVIRONMENT_DIGEST: environmentDigest ?? "" } }; } async function buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs }) { const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-buildkit-${service.serviceId}-`)); const metadataPath = path.join(tmpDir, "metadata.json"); const dockerfilePath = path.join(tmpDir, "Dockerfile"); const cacheRef = buildkitCacheRef(args.registryPrefix, service.serviceId); const registryOption = buildkitRegistryOption(args.registryPrefix); const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : []; const startedAt = Date.now(); try { await writeFile(dockerfilePath, dockerfile(args.baseImage, port, labels)); const argsList = [ ...buildkitAddrArgs, "build", "--frontend", "dockerfile.v0", "--local", `context=${repoRoot}`, "--local", `dockerfile=${tmpDir}`, "--opt", "filename=Dockerfile", "--metadata-file", metadataPath, "--import-cache", `type=registry,ref=${cacheRef}${registryOption}`, "--export-cache", `type=registry,ref=${cacheRef},mode=max${registryOption}`, "--output", `type=image,name=${ref},push=true${registryOption}` ]; if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain"); for (const [name, value] of envs) argsList.push("--opt", `build-arg:${name}=${value}`); const buildEnv = { ...process.env }; if (!args.buildkitAddr) { buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox"; buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime"); await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true }); } const result = await run(args.buildkitCommand, argsList, { env: buildEnv }); emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) }); if (result.code !== 0) { return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "build_failed", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: service.serviceId, summary: `BuildKit build failed for ${service.serviceId}`, next: `Inspect the BuildKit output for ${service.serviceId}, then fix its copied source, base image, or registry cache settings.` }), logTail: tailText(`${result.stdout}\n${result.stderr}`), buildBackend: "buildkit", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: result.durationMs }; } let metadata = {}; try { metadata = JSON.parse(await readFile(metadataPath, "utf8")); } catch {} const digest = buildkitDigestFromMetadata(metadata); if (!shaDigestPattern.test(digest ?? "")) { return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "published_unverified_digest", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: service.serviceId, summary: `BuildKit completed for ${service.serviceId} but did not expose an immutable digest`, next: "Inspect BuildKit metadata output before updating the artifact catalog." }), pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), buildBackend: "buildkit", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: Date.now() - startedAt }; } return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: args.mode === "publish" ? "published" : "built", digest, repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`, buildBackend: "buildkit", buildkitCacheRef: cacheRef, buildkitBuildDurationMs: Date.now() - startedAt, publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null, pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200) }; } finally { await rm(tmpDir, { recursive: true, force: true }); } } async function runWithInput(command, args, input) { const startedAt = Date.now(); const child = spawn(command, args, { cwd: repoRoot, env: process.env, stdio: ["pipe", "pipe", "pipe"] }); child.stdin.end(input); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const code = await new Promise((resolve) => { child.on("error", () => resolve(127)); child.on("close", resolve); }); return { command: commandLine(command, args), code, stdout, stderr, durationMs: Date.now() - startedAt }; } function repositoryFromImageRef(image) { const lastColon = image.lastIndexOf(":"); return lastColon === -1 ? image : image.slice(0, lastColon); } const publishReadyStatuses = new Set(["published", "reused"]); const buildReadyStatuses = new Set(["built", "published", "published_unverified_digest", "reused"]); function reportStatus(mode, artifacts, blockers) { const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired); if (mode === "preflight") return blockers.length ? "blocked" : "pass"; if (requiredArtifacts.some((artifact) => artifact.status === "publish_failed" || artifact.status === "build_failed")) return "failed"; if (requiredArtifacts.some((artifact) => artifact.status.startsWith("blocked_"))) return "blocked"; if (mode === "publish" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => publishReadyStatuses.has(artifact.status))) return "published"; if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => buildReadyStatuses.has(artifact.status))) return "built"; return blockers.length ? "blocked" : "pass"; } function devGateBlockers(blockers) { const seen = new Set(); const result = []; for (const item of blockers) { const key = `${item.type}::${item.scope}`; if (seen.has(key)) continue; seen.add(key); result.push({ type: item.type, scope: item.scope, status: item.status, summary: item.summary }); } return result; } function artifactNotPublishedReason(artifact, mode, fatalBlocked) { if (/^sha256:[a-f0-9]{64}$/u.test(artifact.digest ?? "")) return null; if (!artifact.artifactRequired) return artifact.notPublishedReason; if (artifact.status === "preflight_only") return fatalBlocked ? "blocked_by_preflight" : "preflight_only"; if (artifact.status === "built") return "build_only_not_published"; if (artifact.status === "build_failed") return "build_failed"; if (artifact.status === "publish_failed") return "publish_failed"; if (artifact.status === "published_unverified_digest") return "digest_unverified"; return artifact.notPublishedReason ?? "publish_not_run"; } function imageTagFromReference(image) { const value = String(image ?? ""); const slashIndex = value.lastIndexOf("/"); const colonIndex = value.lastIndexOf(":"); return colonIndex > slashIndex ? value.slice(colonIndex + 1) : null; } function repositoryDigestFor(image, digest) { return image && shaDigestPattern.test(digest ?? "") ? `${repositoryFromImageRef(image)}@${digest}` : null; } function serviceEnvKey(serviceId) { return String(serviceId).toUpperCase().replace(/[^A-Z0-9]+/gu, "_"); } function serviceResultEnvName(serviceId, field) { return `HWLAB_SERVICE_RESULT_${serviceEnvKey(serviceId)}_${field}`; } function serviceResultValue(env, serviceId, field) { const value = env[serviceResultEnvName(serviceId, field)]; return typeof value === "string" && value.length > 0 ? value : null; } async function writeTektonResultFiles(dir, report) { const service = report?.artifactPublish?.services?.[0]; if (!service) return; const values = { "service-id": service.serviceId, status: service.status, image: service.image, "image-tag": service.imageTag, digest: service.digest, "repository-digest": service.repositoryDigest ?? repositoryDigestFor(service.image, service.digest) ?? "", "source-commit-id": service.sourceCommitId, "component-input-hash": service.componentInputHash ?? "", "environment-input-hash": service.environmentInputHash ?? "", "code-input-hash": service.codeInputHash ?? "", "runtime-mode": service.runtimeMode ?? "service-image", "boot-repo": service.bootRepo ?? "", "boot-commit": service.bootCommit ?? "", "boot-sh": service.bootSh ?? "", "build-created-at": service.buildCreatedAt ?? "", "build-backend": report.artifactPublish.buildBackend ?? "unknown", "reused-from": service.reusedFrom ?? "" }; await mkdir(dir, { recursive: true }); await Promise.all(Object.entries(values).map(([name, value]) => writeFile(path.join(dir, name), String(value ?? "")))); } function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) { if (service.envReuse) { const environmentImage = service.environmentImage ?? envReuseImageRef(args.registryPrefix, service.serviceId, service.environmentInputHash ?? commitId); const environmentDigest = service.environmentDigest ?? digest; return withEnvReuseArtifactFields({ artifact: { ...service, commitId, sourceCommitId: commitId, status, image: environmentImage, imageTag: imageTagFromReference(environmentImage), buildCreatedAt, buildSource, digest: environmentDigest, repositoryDigest: repositoryDigestFor(environmentImage, environmentDigest) }, service, commitId, buildCreatedAt, buildSource }); } return { ...service, commitId: shortCommit, sourceCommitId: commitId, status, image: imageRef(args.registryPrefix, service.serviceId, shortCommit), imageTag: shortCommit, buildCreatedAt, buildSource, digest, repositoryDigest: null }; } function artifactRecordForCatalogReuse({ service, catalogService, commitId }) { if (service.envReuse) { return artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId }); } const image = catalogService?.image ?? null; const digest = catalogService?.digest ?? "not_published"; const imageTag = catalogService?.imageTag ?? imageTagFromReference(image); const catalogCommitId = catalogService?.commitId ?? imageTag ?? null; const catalogProvenance = serviceImageCatalogProvenance(catalogService); if (!image || !imageTag || !shaDigestPattern.test(digest)) { return { ...service, commitId: catalogCommitId, sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId, status: "blocked_reuse_unavailable", image: image ?? null, imageTag, buildCreatedAt: catalogService?.buildCreatedAt ?? null, buildSource: catalogService?.buildSource ?? null, digest, repositoryDigest: repositoryDigestFor(image, digest), ...catalogProvenance, buildBackend: "reused-catalog", reusedFrom: null, notPublishedReason: "reuse_catalog_digest_unavailable" }; } return { ...service, commitId: catalogCommitId, sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId, status: "reused", image, imageTag, buildCreatedAt: catalogService?.buildCreatedAt ?? null, buildSource: catalogService?.buildSource ?? null, digest, repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest), ...catalogProvenance, buildBackend: "reused-catalog", reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId, notPublishedReason: null }; } function serviceImageCatalogProvenance(catalogService) { return { componentCommitId: catalogService?.componentCommitId ?? null, componentInputHash: catalogService?.componentInputHash ?? null, dockerfileHash: catalogService?.dockerfileHash ?? null, baseImageReference: catalogService?.baseImageReference ?? null, baseImageDigest: catalogService?.baseImageDigest ?? null, buildArgsHash: catalogService?.buildArgsHash ?? null }; } function artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId }) { const environmentImage = catalogService?.environmentImage ?? service.environmentImage ?? null; const environmentDigest = catalogService?.environmentDigest ?? service.environmentDigest ?? "not_published"; const imageTag = imageTagFromReference(environmentImage); const bootCommit = service.codeChanged === true || service.envChanged === true ? service.bootCommit ?? commitId : catalogService?.bootCommit ?? service.bootCommit ?? commitId; const base = { ...service, runtimeMode: v02EnvReuseRuntimeMode, envReuse: true, envChanged: service.envChanged ?? null, codeChanged: service.codeChanged ?? null, commitId: bootCommit, sourceCommitId: bootCommit, image: environmentImage, imageTag, digest: environmentDigest, environmentImage, environmentDigest, environmentInputHash: service.environmentInputHash ?? catalogService?.environmentInputHash ?? null, codeInputHash: service.codeInputHash ?? null, bootRepo: service.bootRepo ?? catalogService?.bootRepo ?? "git@github.com:pikasTech/HWLAB.git", bootCommit, bootSh: service.bootSh ?? catalogService?.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`, buildCreatedAt: catalogService?.buildCreatedAt ?? null, buildSource: catalogService?.buildSource ?? null, buildBackend: "reused-env-catalog", repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(environmentImage, environmentDigest), bootEnvEvidence: bootEnvEvidence({ service: { ...service, bootCommit }, commitId: bootCommit, environmentImage, environmentDigest }) }; if (!environmentImage || !imageTag || !shaDigestPattern.test(environmentDigest)) { return { ...base, status: "blocked_reuse_unavailable", reusedFrom: null, notPublishedReason: "environment_reuse_digest_unavailable" }; } return { ...base, status: "reused", reusedFrom: service.reuse?.reusedFrom ?? catalogService?.environmentInputHash ?? catalogService?.componentInputHash ?? imageTag, notPublishedReason: null }; } function externalServiceReportPath(dir, serviceId) { const base = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir); return path.join(base, `${serviceId}.json`); } function artifactRecordFromExternalReport({ service, record, reportPath }) { const image = record?.image ?? null; const digest = record?.digest ?? "not_published"; const imageTag = record?.imageTag ?? imageTagFromReference(image); return { ...service, ...record, serviceId: service.serviceId, runtimeKind: record?.runtimeKind ?? service.runtimeKind, implementationState: record?.implementationState ?? service.implementationState, sourceState: record?.sourceState ?? service.sourceState, entrypoint: record?.entrypoint ?? service.entrypoint, publishEnabled: record?.publishEnabled ?? service.publishEnabled, artifactRequired: record?.artifactRequired ?? service.artifactRequired, artifactScope: record?.artifactScope ?? service.artifactScope, image, imageTag, digest, repositoryDigest: record?.repositoryDigest ?? repositoryDigestFor(image, digest), externalReportPath: reportPath, notPublishedReason: record?.notPublishedReason ?? null }; } async function readExternalServiceArtifacts({ dir, services, commitId }) { const artifacts = new Map(); const blockers = []; for (const service of services) { const buildRequired = service.buildRequired === true || service.ciBuildRequired === true; const reportPath = externalServiceReportPath(dir, service.serviceId); let report; try { report = JSON.parse(await readFile(reportPath, "utf8")); } catch (error) { if (!buildRequired) { continue; } blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, summary: `missing per-service artifact report for ${service.serviceId}: ${error.message}`, next: `Ensure the Tekton build-${service.serviceId} TaskRun writes ${reportPath} before collect-artifacts runs.` })); continue; } const record = report?.artifactPublish?.services?.find((item) => item?.serviceId === service.serviceId); if (!record) { blockers.push(blocker({ type: "contract_blocker", scope: service.serviceId, summary: `per-service artifact report ${reportPath} does not contain ${service.serviceId}`, next: "Regenerate the service artifact report with --services set to the same service ID." })); continue; } const artifact = artifactRecordFromExternalReport({ service, record, reportPath }); artifacts.set(service.serviceId, artifact); if (!publishReadyStatuses.has(artifact.status) || !shaDigestPattern.test(artifact.digest ?? "")) { blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, summary: `per-service artifact report ${reportPath} is not a verified published/reused image for ${service.serviceId}`, next: "Inspect the corresponding service TaskRun logs and rerun the failed service build." })); } if (artifact.sourceCommitId && artifact.sourceCommitId !== commitId) { blockers.push(blocker({ type: "contract_blocker", scope: service.serviceId, summary: `per-service artifact report ${reportPath} was produced for ${artifact.sourceCommitId}, expected ${commitId}`, next: "Discard stale service reports and rerun the current PipelineRun." })); } } return { artifacts, blockers }; } function artifactRecordFromServiceResultsEnv({ service, env }) { const status = serviceResultValue(env, service.serviceId, "STATUS"); if (!status) return null; const image = serviceResultValue(env, service.serviceId, "IMAGE"); const digest = serviceResultValue(env, service.serviceId, "DIGEST") ?? "not_published"; const imageTag = serviceResultValue(env, service.serviceId, "IMAGE_TAG") ?? imageTagFromReference(image); const reusedServiceImage = status === "reused" && service.envReuse !== true; const artifact = { ...service, serviceId: service.serviceId, status, image, imageTag, digest, repositoryDigest: serviceResultValue(env, service.serviceId, "REPOSITORY_DIGEST") ?? repositoryDigestFor(image, digest), sourceCommitId: serviceResultValue(env, service.serviceId, "SOURCE_COMMIT_ID"), commitId: imageTag, buildCreatedAt: serviceResultValue(env, service.serviceId, "BUILD_CREATED_AT"), componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? (reusedServiceImage ? null : service.componentInputHash ?? null), environmentInputHash: serviceResultValue(env, service.serviceId, "ENVIRONMENT_INPUT_HASH") ?? service.environmentInputHash ?? null, codeInputHash: serviceResultValue(env, service.serviceId, "CODE_INPUT_HASH") ?? service.codeInputHash ?? null, runtimeMode: serviceResultValue(env, service.serviceId, "RUNTIME_MODE") ?? service.runtimeMode ?? "service-image", bootRepo: serviceResultValue(env, service.serviceId, "BOOT_REPO") ?? service.bootRepo ?? null, bootCommit: serviceResultValue(env, service.serviceId, "BOOT_COMMIT") ?? service.bootCommit ?? null, bootSh: serviceResultValue(env, service.serviceId, "BOOT_SH") ?? service.bootSh ?? null, reusedFrom: serviceResultValue(env, service.serviceId, "REUSED_FROM"), buildBackend: serviceResultValue(env, service.serviceId, "BUILD_BACKEND"), ...(reusedServiceImage ? { componentCommitId: null, dockerfileHash: null, baseImageReference: null, baseImageDigest: null, buildArgsHash: null } : {}), notPublishedReason: null }; if (service.envReuse) { return withEnvReuseArtifactFields({ artifact, service, commitId: artifact.sourceCommitId || service.bootCommit || "" }); } return artifact; } async function readExternalServiceArtifactsFromEnv({ services, commitId, env = process.env }) { const artifacts = new Map(); const blockers = []; for (const service of services) { const artifact = artifactRecordFromServiceResultsEnv({ service, env }); if (!artifact) { blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, summary: `missing Tekton result env for ${service.serviceId}`, next: `Ensure build-${service.serviceId} writes Tekton results and collect-artifacts passes them as HWLAB_SERVICE_RESULT_${serviceEnvKey(service.serviceId)}_* env vars.` })); continue; } artifacts.set(service.serviceId, artifact); if (!publishReadyStatuses.has(artifact.status) || !shaDigestPattern.test(artifact.digest ?? "")) { blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, summary: `Tekton result for ${service.serviceId} is not a verified published/reused image`, next: "Inspect the corresponding build task results and registry digest before collecting artifacts." })); } if (artifact.status === "published" && artifact.sourceCommitId && artifact.sourceCommitId !== commitId) { blockers.push(blocker({ type: "contract_blocker", scope: service.serviceId, summary: `Tekton result for ${service.serviceId} was produced for ${artifact.sourceCommitId}, expected ${commitId}`, next: "Discard stale PipelineRun results and rerun the current source commit." })); } } return { artifacts, blockers }; } function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) { return { version: "v2", mode, sourceCommitId: commitId, imageTag: shortCommit, registryTarget: args.registryPrefix, registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), digestPlaceholder: "not_published", services: artifacts.map((artifact) => { const image = artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit); const digest = artifact.digest ?? "not_published"; return { serviceId: artifact.serviceId, enabled: artifact.publishEnabled, required: artifact.artifactRequired, artifactScope: artifact.artifactScope, commitId: artifact.commitId ?? shortCommit, sourceCommitId: artifact.sourceCommitId ?? commitId, registryTarget: imageRepository(args.registryPrefix, artifact.serviceId), image, imageTag: artifact.imageTag ?? shortCommit, buildCreatedAt: artifact.buildCreatedAt ?? null, buildSource: artifact.buildSource ?? null, digest, repositoryDigest: artifact.repositoryDigest ?? null, digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder", status: artifact.status, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, componentCommitId: artifact.componentCommitId ?? null, componentInputHash: artifact.componentInputHash ?? null, dockerfileHash: artifact.dockerfileHash ?? null, baseImageReference: artifact.baseImageReference ?? null, baseImageDigest: artifact.baseImageDigest ?? null, buildArgsHash: artifact.buildArgsHash ?? null, ciAffected: artifact.ciAffected ?? null, ciReason: artifact.ciReason ?? [], runtimeMode: artifact.runtimeMode ?? "service-image", envReuse: artifact.envReuse === true, envChanged: artifact.envChanged ?? null, codeChanged: artifact.codeChanged ?? null, environmentImage: artifact.environmentImage ?? null, environmentDigest: artifact.environmentDigest ?? null, environmentInputHash: artifact.environmentInputHash ?? null, codeInputHash: artifact.codeInputHash ?? null, bootRepo: artifact.bootRepo ?? null, bootCommit: artifact.bootCommit ?? null, bootSh: artifact.bootSh ?? null, bootEnvEvidence: artifact.bootEnvEvidence ?? null, reuse: artifact.reuse ?? null, reusedFrom: artifact.reusedFrom ?? null, notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked) }; }) }; } function plannerFieldsFor(servicePlan) { if (!servicePlan) return {}; return { componentCommitId: servicePlan.componentCommitId ?? null, componentInputHash: servicePlan.componentInputHash ?? null, runtimeMode: servicePlan.runtimeMode ?? "service-image", envReuse: servicePlan.envReuse === true, envReuseRecipe: servicePlan.envReuseRecipe ?? null, envChanged: servicePlan.envChanged ?? null, codeChanged: servicePlan.codeChanged ?? null, environmentInputHash: servicePlan.environmentInputHash ?? null, codeInputHash: servicePlan.codeInputHash ?? null, bootRepo: servicePlan.bootRepo ?? null, bootCommit: servicePlan.bootCommit ?? null, bootSh: servicePlan.bootSh ?? null, buildRequired: servicePlan.buildRequired ?? servicePlan.affected ?? false, environmentImage: servicePlan.environmentImage ?? null, environmentDigest: servicePlan.environmentDigest ?? null, dockerfileHash: servicePlan.dockerfileHash ?? null, baseImageReference: servicePlan.baseImageReference ?? null, baseImageDigest: servicePlan.baseImageDigest ?? null, buildArgsHash: servicePlan.buildArgsHash ?? null, ciAffected: servicePlan.affected ?? false, ciReason: servicePlan.reason ?? [], reuse: servicePlan.reuse ?? null }; } function enrichServicesWithCiPlan(services, ciPlan) { const byServiceId = new Map((ciPlan?.services ?? []).map((service) => [service.serviceId, service])); return services.map((service) => ({ ...service, ...plannerFieldsFor(byServiceId.get(service.serviceId)) })); } function ciPlanReportSummary(ciPlan) { if (!ciPlan) return null; return { planVersion: ciPlan.planVersion, sourceCommitId: ciPlan.sourceCommitId, shortCommitId: ciPlan.shortCommitId, baseRef: ciPlan.baseRef, targetRef: ciPlan.targetRef, compatibility: ciPlan.compatibility, changedPathSummary: ciPlan.changedPathSummary, imageBuildRequired: ciPlan.imageBuildRequired, affectedServices: ciPlan.affectedServices, rolloutServices: ciPlan.rolloutServices ?? ciPlan.affectedServices, buildServices: ciPlan.buildServices ?? ciPlan.affectedServices, reusedServices: ciPlan.reusedServices, buildSkippedCount: ciPlan.buildSkippedCount, ciCdPlan: ciPlan.ciCdPlan }; } function sumDurations(artifacts, field) { return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0); } async function mapWithConcurrency(items, concurrency, worker) { const results = new Array(items.length); let nextIndex = 0; const workerCount = Math.min(Math.max(concurrency, 1), items.length); await Promise.all(Array.from({ length: workerCount }, async () => { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; results[index] = await worker(items[index], index); } })); return results; } function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }) { const status = reportStatus(mode, artifacts, blockers); const fatalBlocked = hasFatalBlocker(blockers); const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired); const serviceInventory = serviceInventoryFromServices(services); const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }); const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length; const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length; const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length; const environment = artifactEnvironment(args); return { $schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json", $id: `https://hwlab.pikastech.local/${args.lane}-cicd/artifacts.json`, reportVersion: "v1", issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35", taskId: `${args.lane}-artifact-publish`, commitId: shortCommit, acceptanceLevel: `${environment}_artifact_publish`, lane: args.lane, devOnly: args.lane !== "v02", prodDisabled: true, reportLifecycle: activeReportLifecycle(`Current ${environment} artifact publish report; it is not runtime acceptance evidence.`), sourceContract: { status: fatalBlocked ? "blocked" : "pass", documents: [ "docs/reference/MVP-e2e-acceptance.md", "docs/reference/spec-v02-documentation-governance.md", "docs/reference/node-gitops-cicd.md", "docs/reference/spec-v02-cicd.md" ], summary: `${environment} artifact publish evidence; runtime acceptance stays separate from artifact publication.` }, validationCommands: [ "node --check scripts/artifact-publish.mjs", "node --check scripts/artifact-publish.mjs", "node --check scripts/src/dev-artifact-services.mjs", "node --check scripts/src/registry-capabilities.mjs", "node --check scripts/preflight-dev-base-image.mjs", "node --check scripts/src/dev-base-image-preflight.mjs", "node --check scripts/dev-runtime-base-image.mjs", "node --test scripts/refresh-artifact-catalog.test.mjs", "node scripts/preflight-dev-base-image.mjs", "node scripts/artifact-publish.mjs --preflight --no-report", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ], localSmoke: { status: "not_run", commands: [], evidence: [ "This report records artifact build/publish state only; local smoke remains a separate gate." ], summary: "Local smoke is not implied by artifact publication." }, dryRun: { status: "not_run", commands: [], evidence: [ "This report records DEV artifact build/publish state only; hwlab-cli is not an artifact service." ], summary: "DEV deploy dry-run is not implied by artifact publication." }, devPreconditions: { status: fatalBlocked ? "blocked" : "pass", requirements: [ "Registry prefix is localhost or private/internal only.", "DEV builder base image preflight returns ready before build or publish.", `Environment remains ${environment} and namespace remains ${args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"}.`, "PROD profile remains disabled.", "No secret or token material is required by this workflow." ], summary: fatalBlocked ? `Fatal preconditions blocked a full ${environment} artifact publish.` : `Fatal ${environment} artifact preconditions passed.` }, blockers: devGateBlockers(blockers), artifactPublish: { issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35", status, mode, lane: args.lane, catalogPath: args.catalogPath, imageTagMode: args.imageTagMode, repo, sourceCommitId: commitId, registryPrefix: args.registryPrefix, buildBackend: args.buildBackend, buildkitCommand: args.buildkitCommand, buildkitAddr: args.buildkitAddr ?? null, producer, registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImage: args.baseImage ?? null, selectedServiceIds: args.services, affectedOnly: args.affectedOnly, externalServiceReportDir: args.externalServiceReportDir, externalServiceResultsEnv: args.externalServiceResultsEnv, quietBuild: args.quietBuild, concurrency: args.concurrency, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), environment, buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null, serviceInventory, ciPlan: ciPlanReportSummary(ciPlan), publishPlan, serviceCount: artifacts.length, requiredServiceCount: serviceInventory.requiredServiceCount, disabledServiceCount: serviceInventory.disabledServiceCount, builtCount, publishedCount, reusedCount, timings: { buildkitBuildDurationMs: sumDurations(artifacts, "buildkitBuildDurationMs"), cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"), publishDurationMs: sumDurations(artifacts, "publishDurationMs") }, generatedAt: new Date().toISOString(), services: artifacts.map((artifact) => ({ serviceId: artifact.serviceId, status: artifact.status, commitId: artifact.commitId ?? shortCommit, sourceCommitId: artifact.sourceCommitId ?? commitId, image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit), imageTag: artifact.imageTag ?? shortCommit, buildCreatedAt: artifact.buildCreatedAt ?? null, buildSource: artifact.buildSource ?? null, digest: artifact.digest ?? "not_published", repositoryDigest: artifact.repositoryDigest ?? null, runtimeKind: artifact.runtimeKind, distFreshness: artifact.distFreshness, implementationState: artifact.implementationState, sourceState: artifact.sourceState, entrypoint: artifact.entrypoint, publishEnabled: artifact.publishEnabled, artifactRequired: artifact.artifactRequired, artifactScope: artifact.artifactScope, notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked), localImageId: artifact.localImageId, buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null, buildBackend: artifact.buildBackend ?? args.buildBackend, buildkitCacheRef: artifact.buildkitCacheRef ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, componentCommitId: artifact.componentCommitId ?? null, componentInputHash: artifact.componentInputHash ?? null, dockerfileHash: artifact.dockerfileHash ?? null, baseImageReference: artifact.baseImageReference ?? null, baseImageDigest: artifact.baseImageDigest ?? null, buildArgsHash: artifact.buildArgsHash ?? null, ciAffected: artifact.ciAffected ?? null, ciReason: artifact.ciReason ?? [], runtimeMode: artifact.runtimeMode ?? "service-image", envReuse: artifact.envReuse === true, envChanged: artifact.envChanged ?? null, codeChanged: artifact.codeChanged ?? null, environmentImage: artifact.environmentImage ?? null, environmentDigest: artifact.environmentDigest ?? null, environmentInputHash: artifact.environmentInputHash ?? null, codeInputHash: artifact.codeInputHash ?? null, bootRepo: artifact.bootRepo ?? null, bootCommit: artifact.bootCommit ?? null, bootSh: artifact.bootSh ?? null, bootEnvEvidence: artifact.bootEnvEvidence ?? null, reuse: artifact.reuse ?? null, reusedFrom: artifact.reusedFrom ?? null, logTail: artifact.logTail ?? null, pushLogTail: artifact.pushLogTail ?? null })), blockers }, notes: `${environment} artifact report. Do not treat runtime placeholder images as real service implementations.` }; } async function writeReport(relativePath, report) { const absolutePath = ensureNotRepoReportsPath(repoRoot, relativePath, "--report"); await mkdir(path.dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, `${JSON.stringify(report, null, 2)}\n`); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return; } if (args.mode === "publish") { const identityGuard = requireArtifactPublishIdentity({ script: cliEntrypoint, mode: "--publish" }); if (identityGuard) { console.log(JSON.stringify(identityGuard, null, 2)); process.exitCode = 2; return; } } args.registryPrefix = validateRegistryPrefix(args.registryPrefix); const [baseImagePreflight, registryCapabilities] = await Promise.all([ buildkitBaseImagePreflight(args.baseImage), probeRegistryCapabilities({ registryPrefix: args.registryPrefix }) ]); if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; } const [deployManifest, commitId, remoteUrl] = await Promise.all([ readConfig(args.deployPath), gitValue(["rev-parse", "HEAD"]), gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git") ]); applyDeployDefaultServices(args, deployManifest); let catalog = await readConfigIfPresent(args.catalogPath, null); const catalogWasMissing = !catalog; if (!catalog && isRuntimeArtifactLane(args.lane)) catalog = artifactCatalogSkeleton({ args, deployManifest, commitId }); catalog = normalizeCatalogForLane(catalog, args.lane, deployManifest); assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`); const shortCommit = imageTagForCommit(args, commitId); let effectiveCatalogPath = args.catalogPath; if (catalogWasMissing && isRuntimeArtifactLane(args.lane)) { effectiveCatalogPath = path.join(os.tmpdir(), `hwlab-${args.lane}-artifact-catalog-${process.pid}.json`); await writeFile(effectiveCatalogPath, `${JSON.stringify(catalog, null, 2)}\n`); } const buildCreatedAt = new Date().toISOString(); const repo = repoLabelFromRemote(remoteUrl); const producer = artifactProducer(process.env); const ciPlan = await createCiPlan({ repoRoot, lane: args.lane, targetRef: "HEAD", deployConfigPath: args.deployPath, artifactCatalogPath: effectiveCatalogPath, registryPrefix: args.registryPrefix, baseImage: args.baseImage ?? undefined, ...(args.servicesExplicit ? { services: args.services } : {}) }); const allowedServiceIds = isRuntimeArtifactLane(args.lane) ? runtimeLaneServiceIdsFromDeploy(deployManifest, args.lane) : SERVICE_IDS; const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog, allowedServiceIds), ciPlan); const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service])); const affectedServiceIds = new Set(ciPlan?.affectedServices ?? []); const plannedBuildServices = Array.isArray(ciPlan?.buildServices) ? ciPlan.buildServices : null; const buildServiceIds = new Set(plannedBuildServices ?? services .filter((service) => service.artifactRequired && (service.envReuse ? service.envChanged === true : affectedServiceIds.has(service.serviceId))) .map((service) => service.serviceId)); const preflightBuildServiceIds = args.externalServiceReportDir || args.externalServiceResultsEnv ? new Set() : buildServiceIds; let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds: preflightBuildServiceIds }); let artifacts = services.map((service) => artifactRecord({ args, service, commitId, shortCommit, status: service.artifactRequired ? "preflight_only" : "not_required" })); let externalArtifacts = new Map(); if (args.mode === "build" || args.mode === "publish") { if (args.externalServiceReportDir && buildServiceIds.size > 0) { const externalResult = await readExternalServiceArtifacts({ dir: args.externalServiceReportDir, services: services.filter((service) => buildServiceIds.has(service.serviceId)), commitId }); externalArtifacts = externalResult.artifacts; blockers.push(...externalResult.blockers); } if (args.externalServiceResultsEnv) { const externalResult = await readExternalServiceArtifactsFromEnv({ services: services.filter((service) => service.artifactRequired), commitId }); externalArtifacts = new Map([...externalArtifacts, ...externalResult.artifacts]); blockers.push(...externalResult.blockers); } artifacts = services.map((service) => { if (externalArtifacts.has(service.serviceId)) { return externalArtifacts.get(service.serviceId); } if (!service.artifactRequired || buildServiceIds.has(service.serviceId)) { return artifactRecord({ args, service, commitId, shortCommit, status: service.artifactRequired ? "preflight_only" : "not_required" }); } const reused = artifactRecordForCatalogReuse({ service, catalogService: catalogByServiceId.get(service.serviceId), commitId }); emit("artifact_reuse", { serviceId: service.serviceId, status: reused.status, image: reused.image, digest: reused.digest, reusedFrom: reused.reusedFrom ?? null }); if (reused.status.startsWith("blocked_")) { blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, summary: `${service.serviceId} cannot be reused because ${args.catalogPath} has no verified digest`, next: "Refresh the service catalog with a verified sha256 digest for this service, or create a service-scoped source change so the planner publishes it through the per-service BuildKit task." })); } return reused; }); } if ((args.mode === "build" || args.mode === "publish") && hasFatalBlocker(blockers)) { emit("publish_blocked", { fatalBlockers: blockers.filter((item) => fatalBlockerTypes.has(item.type)).map((item) => item.scope) }); } else if (args.mode === "build" || args.mode === "publish") { const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact])); await mapWithConcurrency(services.filter((item) => buildServiceIds.has(item.serviceId) && !externalArtifacts.has(item.serviceId)), args.concurrency, async (service) => { emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) }); const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }); artifactByServiceId.set(service.serviceId, artifact); if (artifact.blocker) blockers.push(artifact.blocker); emit("build_done", { serviceId: service.serviceId, status: artifact.status, image: artifact.image, buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null, buildBackend: artifact.buildBackend ?? args.buildBackend, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null }); }); artifacts = services.map((service) => artifactByServiceId.get(service.serviceId)); } const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }); if (args.tektonResultsDir) { await writeTektonResultFiles(args.tektonResultsDir, report); } if (args.emitReport) { await writeReport(args.reportPath, report); } console.log(JSON.stringify({ status: report.artifactPublish.status, mode: args.mode, reportPath: args.emitReport ? args.reportPath : null, sourceCommitId: commitId, registryPrefix: args.registryPrefix, registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), selectedServiceIds: report.artifactPublish.selectedServiceIds, affectedOnly: report.artifactPublish.affectedOnly, ciPlan: report.artifactPublish.ciPlan, concurrency: report.artifactPublish.concurrency, builtCount: report.artifactPublish.builtCount, publishedCount: report.artifactPublish.publishedCount, reusedCount: report.artifactPublish.reusedCount, timings: report.artifactPublish.timings, publishPlan: report.artifactPublish.publishPlan, services: report.artifactPublish.services.map((service) => ({ serviceId: service.serviceId, status: service.status, commitId: service.commitId, sourceCommitId: service.sourceCommitId, image: service.image, imageTag: service.imageTag, buildCreatedAt: service.buildCreatedAt, digest: service.digest, distFreshness: service.distFreshness, buildkitBuildDurationMs: service.buildkitBuildDurationMs, buildBackend: service.buildBackend, buildkitCacheRef: service.buildkitCacheRef, cloudWebBuildDurationMs: service.cloudWebBuildDurationMs, publishDurationMs: service.publishDurationMs, reusedFrom: service.reusedFrom, logTail: service.logTail, pushLogTail: service.pushLogTail, notPublishedReason: service.notPublishedReason })), blockers }, null, 2)); if (hasFatalBlocker(blockers)) { process.exitCode = 2; } else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) { process.exitCode = 1; } } main().catch((error) => { console.error(JSON.stringify({ status: "failed", error: error instanceof Error ? error.message : String(error) }, null, 2)); process.exitCode = 1; });