#!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; 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 { BUILDABLE_IMPLEMENTATION_STATES, resolveDevArtifactServices, serviceInventoryFromServices } from "./src/dev-artifact-services.mjs"; import { devArtifactProducer, requireDevArtifactSideEffectEnv } from "./src/dev-cd-transaction-guard.mjs"; import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs"; import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs"; import { inspectCloudWebDistFreshness } from "../web/hwlab-cloud-web/scripts/dist-contract.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); 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 catalogPath = "deploy/artifact-catalog.dev.json"; const deployPath = "deploy/deploy.json"; const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); 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 servicePorts = new Map([ ["hwlab-cloud-api", 6667], ["hwlab-cloud-web", 8080], ["hwlab-agent-mgr", 7410], ["hwlab-agent-worker", 7411], ["hwlab-gateway", 7001], ["hwlab-gateway-simu", 7101], ["hwlab-box-simu", 7201], ["hwlab-patch-panel", 7301], ["hwlab-router", 7401], ["hwlab-tunnel-client", 7402], ["hwlab-edge-proxy", 6667], ["hwlab-cli", 7420], ["hwlab-agent-skills", 7430] ]); function parseArgs(argv) { const args = { mode: "preflight", registryPrefix: defaultRegistryPrefix, baseImage: defaultBaseImage, reportPath: defaultReportPath, services: [...SERVICE_IDS], 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 === "--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); 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}`); } 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 printHelp() { console.log([ "usage: node scripts/dev-artifact-publish.mjs [--preflight|--build|--publish]", "", "DEV-only artifact workflow for the D601 local/internal registry.", "", "options:", " --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; default: all frozen DEV services", ` --report PATH default: ${defaultReportPath}`, " --no-report print JSON without updating the temporary artifact JSON", " --quiet-build keep docker build quiet; default keeps build output visible", " --concurrency N parallel service build/push workers; default: 4, max: 8" ].join("\n")); } async function readJson(relativePath) { return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); } function emit(event, payload = {}) { process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`); } function blocker({ type, scope, summary, next }) { return { type, scope, status: "open", summary, next }; } function preflightEnvForBaseImage(baseImage) { if (!baseImage) { return process.env; } return { ...process.env, HWLAB_DEV_BASE_IMAGE: baseImage }; } 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(" "); } 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 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 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) { return resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS, catalog); } function runtimeScriptBase64() { const source = String.raw` import { createServer } from "node:http"; import http from "node:http"; import { spawn } from "node:child_process"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown"; const environment = process.env.HWLAB_ENVIRONMENT || "dev"; const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder"; const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || ""; const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10); const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || ""; const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 180000, { min: 1000, max: 300000 }); const authUsername = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_USERNAME", "admin"); const authPassword = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_PASSWORD", "hwlab2026"); const authSessionTtlMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_AUTH_SESSION_TTL_MS, 12 * 60 * 60 * 1000, { min: 60 * 1000, max: 7 * 24 * 60 * 60 * 1000 }); const authCookieName = "hwlab_cloud_web_session"; const authSessions = new Map(); const readOnlyRpcMethods = new Set([ "system.health", "cloud.adapter.describe", "audit.event.query", "evidence.record.query" ]); function ensureCodeAgentRuntimeBase() { if (serviceId !== "hwlab-cloud-api") return; const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab"; const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home"; process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace; process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace; process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "workspace-write"; process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1"; process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned"; process.env.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio"; process.env.CODEX_HOME = codexHome; process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex"; process.env.HWLAB_CODE_AGENT_SKILLS_DIRS = process.env.HWLAB_CODE_AGENT_SKILLS_DIRS || "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills"; mkdirSync(path.dirname(workspace), { recursive: true }); if (!existsSync(workspace)) { if (workspace === "/workspace/hwlab" && existsSync("/app")) { symlinkSync("/app", workspace, "dir"); } else { mkdirSync(workspace, { recursive: true }); } } mkdirSync(codexHome, { recursive: true }); for (const target of [workspace, codexHome]) { try { const info = lstatSync(target); chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777); } catch { // Runtime readiness reports the concrete blocker without exposing secret values. } } } ensureCodeAgentRuntimeBase(); function sendJson(response, statusCode, body) { const payload = JSON.stringify(body, null, 2); response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload) }); response.end(payload); } function nonEmptyEnv(name, fallback) { const value = process.env[name]; return typeof value === "string" && value.trim() ? value.trim() : fallback; } function healthPayload() { const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; const imageReference = process.env.HWLAB_IMAGE || "unknown"; const imageTag = process.env.HWLAB_IMAGE_TAG || imageTagFromReference(imageReference) || shortRevision(commitId); const buildCreatedAt = normalizeIsoTimestamp( process.env.HWLAB_BUILD_CREATED_AT || process.env.HWLAB_IMAGE_CREATED_AT || process.env.SOURCE_DATE_EPOCH || process.env.BUILD_DATE ); const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env"; return { serviceId, environment, status: "ok", artifactKind: runtimeKind, revision: process.env.HWLAB_REVISION || commitId, commit: { id: commitId, source: buildSource }, image: { reference: imageReference, tag: imageTag, digest: process.env.HWLAB_IMAGE_DIGEST || "unknown" }, build: { createdAt: buildCreatedAt, source: buildSource, provenance: process.env.HWLAB_BUILD_PROVENANCE || null, metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable", unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment" } }; } function normalizeIsoTimestamp(value) { const source = String(value || "").trim(); if (!source) return null; if (/^\d+$/u.test(source)) { const seconds = Number.parseInt(source, 10); if (Number.isSafeInteger(seconds)) { const date = new Date(seconds * 1000); if (Number.isFinite(date.getTime())) return date.toISOString(); } } const date = new Date(source); return Number.isFinite(date.getTime()) ? date.toISOString() : null; } function imageTagFromReference(imageReference) { const source = String(imageReference || "").trim(); const slashIndex = source.lastIndexOf("/"); const colonIndex = source.lastIndexOf(":"); if (colonIndex <= slashIndex) return null; return source.slice(colonIndex + 1) || null; } function shortRevision(value) { const source = String(value || "").trim(); return source.length >= 7 ? source.slice(0, 7) : source || "unknown"; } function runNodeEntrypoint(file) { const child = spawn(process.execPath, [file], { cwd: "/app", env: process.env, stdio: "inherit" }); child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 0); }); } function cookieValue(request, name) { const raw = request.headers.cookie || ""; for (const part of raw.split(";")) { const [key, ...rest] = part.trim().split("="); if (key === name) return decodeURIComponent(rest.join("=") || ""); } return ""; } function authCookie(token, expiresAt) { const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); return authCookieName + "=" + encodeURIComponent(token) + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + maxAgeSeconds; } function clearAuthCookie() { return authCookieName + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"; } function activeAuthSession(request) { const token = cookieValue(request, authCookieName); if (!token) return null; const session = authSessions.get(token); if (!session) return null; if (session.expiresAt <= Date.now()) { authSessions.delete(token); return null; } return { token, ...session }; } function cleanupExpiredAuthSessions() { const now = Date.now(); for (const [token, session] of authSessions) { if (session.expiresAt <= now) authSessions.delete(token); } } function constantTimeEquals(a, b) { const left = Buffer.from(String(a)); const right = Buffer.from(String(b)); if (left.length !== right.length) return false; return timingSafeEqual(left, right); } async function handleCloudWebAuth(request, response, url) { if (url.pathname === "/auth/session" && request.method === "GET") { const session = activeAuthSession(request); sendJson(response, 200, session ? { authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() } : { authenticated: false }); return true; } if (url.pathname === "/auth/login" && request.method === "POST") { let body = {}; try { body = JSON.parse(await readRequestBody(request) || "{}"); } catch { sendJson(response, 400, { authenticated: false, error: "invalid_request" }); return true; } const username = typeof body.username === "string" ? body.username.trim() : ""; const password = typeof body.password === "string" ? body.password : ""; if (!constantTimeEquals(username, authUsername) || !constantTimeEquals(password, authPassword)) { sendJson(response, 401, { authenticated: false, error: "invalid_credentials" }); return true; } cleanupExpiredAuthSessions(); const token = randomBytes(32).toString("hex"); const expiresAt = Date.now() + authSessionTtlMs; authSessions.set(token, { username: authUsername, expiresAt }); const payload = JSON.stringify({ authenticated: true, user: { username: authUsername }, expiresAt: new Date(expiresAt).toISOString() }, null, 2); response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload), "set-cookie": authCookie(token, expiresAt) }); response.end(payload); return true; } if (url.pathname === "/auth/logout" && request.method === "POST") { const session = activeAuthSession(request); if (session) authSessions.delete(session.token); const payload = JSON.stringify({ authenticated: false }, null, 2); response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload), "set-cookie": clearAuthCookie() }); response.end(payload); return true; } if (url.pathname.startsWith("/auth/")) { sendJson(response, 404, { authenticated: false, error: "not_found" }); return true; } return false; } async function readRequestBody(request) { let body = ""; for await (const chunk of request) { body += chunk; } return body; } function copyHeaders(headers) { const result = {}; for (const [key, value] of Object.entries(headers)) { if (value === undefined) continue; if (key.toLowerCase() === "transfer-encoding") continue; if (key.toLowerCase() === "content-length") continue; result[key] = value; } return result; } function parseTimeout(value, fallback, { min, max }) { const parsed = Number.parseInt(value || "", 10); if (!Number.isInteger(parsed)) return fallback; return Math.min(Math.max(parsed, min), max); } function requestUpstream(target, request, body) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { upstream.destroy(new Error("cloud api proxy timed out after " + cloudApiProxyTimeoutMs + "ms")); }, cloudApiProxyTimeoutMs); const upstream = http.request( target, { method: request.method, headers: { accept: request.headers.accept || "application/json", "content-type": request.headers["content-type"] || "application/json", "content-length": Buffer.byteLength(body) } }, (upstreamResponse) => { const chunks = []; upstreamResponse.on("data", (chunk) => chunks.push(chunk)); upstreamResponse.on("end", () => { clearTimeout(timeout); resolve({ statusCode: upstreamResponse.statusCode || 502, headers: upstreamResponse.headers, body: Buffer.concat(chunks) }); }); } ); upstream.on("error", (error) => { clearTimeout(timeout); reject(error); }); if (body) upstream.write(body); upstream.end(); }); } async function proxyCloudApi(request, response, url) { if (!cloudApiBaseUrl) { sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname }); return; } let body = ""; if (request.method !== "GET") { body = await readRequestBody(request); } if (url.pathname === "/json-rpc") { try { const envelope = body ? JSON.parse(body) : {}; if (!readOnlyRpcMethods.has(envelope.method)) { sendJson(response, 403, { error: "readonly_rpc_required", serviceId, method: envelope.method || null }); return; } } catch (error) { sendJson(response, 400, { error: "invalid_json", serviceId, reason: error.message }); return; } } const target = new URL(url.pathname + url.search, cloudApiBaseUrl); try { const upstream = await requestUpstream(target, request, request.method === "GET" ? "" : body); response.writeHead(upstream.statusCode, { ...copyHeaders(upstream.headers), "content-length": upstream.body.length }); response.end(upstream.body); } catch (error) { const timedOut = /timed out after \d+ms/iu.test(error.message); const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed"; const category = timedOut ? "timeout" : "proxy"; const traceId = request.headers["x-trace-id"] || null; const userMessage = timedOut ? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。" : "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。"; sendJson(response, 502, { status: "failed", error: { code, layer: "proxy", category, retryable: true, userMessage, message: userMessage, timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null, traceId, route: url.pathname, blocker: { code, layer: "proxy", category, retryable: true, summary: userMessage, userMessage, traceId, route: url.pathname } }, serviceId, target: target.href.replace(/\/\/[^/@]*@/u, "//***@"), traceId, reason: userMessage }); } } async function serveCloudWeb() { const roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"]; const server = createServer(async (request, response) => { const url = new URL(request.url || "/", "http://hwlab-cloud-web.local"); if (url.pathname === "/health/live" || url.pathname === "/health") { sendJson(response, 200, healthPayload()); return; } if (await handleCloudWebAuth(request, response, url)) { return; } if ( (request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || url.pathname.startsWith("/v1/"))) || (request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io")) ) { if (!activeAuthSession(request)) { sendJson(response, 401, { status: "failed", error: "auth_required", serviceId }); return; } await proxyCloudApi(request, response, url); return; } const routePath = url.pathname.replace(/\/+$/, "") || "/"; const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" ? "index.html" : url.pathname.slice(1); for (const root of roots) { const candidate = path.resolve(root, relativePath); if (!candidate.startsWith(root)) continue; try { const info = await stat(candidate); if (!info.isFile()) continue; const body = await readFile(candidate); response.writeHead(200, { "content-type": contentType(candidate), "content-length": body.length }); response.end(body); return; } catch { // Try the next root. } } sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname }); }); server.listen(port, "0.0.0.0", () => { process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n"); }); } function contentType(filePath) { if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; return "application/octet-stream"; } function serveHealthOnly() { const server = createServer((request, response) => { const url = new URL(request.url || "/", "http://hwlab-artifact.local"); if (url.pathname === "/health/live" || url.pathname === "/health") { sendJson(response, 200, healthPayload()); return; } sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname }); }); server.listen(port, "0.0.0.0", () => { process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n"); }); } if (runtimeKind === "node-command" && entrypoint) { runNodeEntrypoint(entrypoint); } else if (runtimeKind === "cloud-web") { await serveCloudWeb(); } else { serveHealthOnly(); } `; return Buffer.from(source, "utf8").toString("base64"); } function dockerfile(baseImage, port) { return [ `FROM ${baseImage}`, "WORKDIR /app", "ENV CODEX_HOME=/codex-home", "ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home", "ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab", "ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab", "ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write", "ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1", "ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned", "ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex", "ENV HWLAB_CODE_AGENT_PROVIDER=codex-stdio", "ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills", "COPY package.json ./package.json", "COPY package-lock.json* ./", "RUN if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; elif [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi", "COPY internal ./internal", "COPY cmd ./cmd", "COPY scripts ./scripts", "COPY web ./web", "COPY tools ./tools", "COPY skills ./skills", "COPY deploy ./deploy", "RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt && (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)", `RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`, "ARG HWLAB_ENVIRONMENT", "ARG HWLAB_SERVICE_ID", "ARG HWLAB_ARTIFACT_KIND", "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 imageRef(registryPrefix, serviceId, tag) { return `${registryPrefix}/${serviceId}:${tag}`; } function imageRepository(registryPrefix, serviceId) { return `${registryPrefix}/${serviceId}`; } function inspectDigest(pushOutput) { const matches = [...pushOutput.matchAll(/digest:\s+(sha256:[a-f0-9]{64})/giu)]; if (!matches.length) return null; return matches[matches.length - 1][1]; } 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 }) { const blockers = []; let registryPrefix = args.registryPrefix; if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; } else { 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 D601 local/internal registry prefix, for example 127.0.0.1:5000/hwlab." }) ); } try { assertDevOnlyContracts(catalog, deployManifest); } catch (error) { blockers.push( blocker({ type: "contract_blocker", scope: "dev-contract", summary: error.message, next: "Fix deploy/artifact-catalog.dev.json and deploy/deploy.json so both remain DEV-only and cover the frozen service IDs." }) ); } 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 (registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") { blockers.push( blocker({ type: "network_blocker", scope: "docker-daemon-push-access", summary: registryCapabilities.dimensions.dockerDaemonPushAccess.summary, next: "Restore Docker daemon access to the D601 local/internal registry 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 cmd/${service.serviceId}/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.`, next: `Add cmd/${service.serviceId}/main.mjs or a dedicated Dockerfile for ${service.serviceId}.` }) ); } if (service.serviceId === "hwlab-cli" && service.implementationState === "library-only") { blockers.push( blocker({ type: "runtime_blocker", scope: service.serviceId, summary: "tools/hwlab-cli declares bin/hwlab-cli.mjs but the bin file is absent; a health-only placeholder image was not built as a real CLI artifact.", next: "Add tools/hwlab-cli/bin/hwlab-cli.mjs that calls tools/hwlab-cli/lib/cli.mjs." }) ); } } return blockers; } function hasFatalBlocker(blockers) { return blockers.some((item) => fatalBlockerTypes.has(item.type)); } async function buildService({ args, repo, commitId, shortCommit, 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 build = await run(process.execPath, ["web/hwlab-cloud-web/scripts/build.mjs"]); if (build.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 dist build failed before Docker build", next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish." }), logTail: tailText(`${build.stdout}\n${build.stderr}`), cloudWebBuildDurationMs: build.durationMs }; } const distFreshness = await inspectCloudWebDistFreshness(path.join(repoRoot, "web/hwlab-cloud-web")); if (distFreshness.status !== "pass") { return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "build_failed", digest: "not_published", distFreshness, blocker: blocker({ type: "contract_blocker", scope: service.serviceId, summary: `cloud web dist is stale or incomplete before Docker build: ${distFreshness.mismatches.join(", ")}`, next: "Run node web/hwlab-cloud-web/scripts/build.mjs and node web/hwlab-cloud-web/scripts/check.mjs before publishing hwlab-cloud-web." }) }; } service = { ...service, distFreshness, cloudWebBuildDurationMs: build.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", "scripts/dev-artifact-publish.mjs"], ["PORT", String(port)], ["HWLAB_PORT", String(port)] ]; const argsList = [ "build", "--pull=false" ]; if (args.quietBuild) argsList.push("--quiet"); for (const [name, value] of labels) { argsList.push("--label", `${name}=${value}`); } for (const [name, value] of envs) { argsList.push("--build-arg", `${name}=${value}`); } argsList.push("-t", ref, "-f", "-", "."); const result = await runWithInput("docker", argsList, dockerfile(args.baseImage, port)); 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: `docker build failed for ${service.serviceId}`, next: `Inspect the Docker build output for ${service.serviceId}, then fix its copied source or base image.` }), logTail: tailText(`${result.stdout}\n${result.stderr}`), dockerBuildDurationMs: result.durationMs }; } const imageInspect = await run("docker", ["image", "inspect", "--format", "{{.Id}}", ref]); return { ...service, image: ref, imageTag: tag, buildCreatedAt, buildSource, status: "built", localImageId: imageInspect.code === 0 ? imageInspect.stdout.trim() : null, dockerBuildDurationMs: result.durationMs, dockerImageInspectDurationMs: imageInspect.durationMs, dockerImageInspectStatus: imageInspect.code === 0 ? "pass" : "failed", dockerBuildLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), digest: "not_published" }; } 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 }; } async function publishService(artifact) { if (artifact.status !== "built") { return artifact; } const result = await run("docker", ["push", artifact.image]); if (result.code !== 0) { return { ...artifact, status: "publish_failed", blocker: blocker({ type: "network_blocker", scope: artifact.serviceId, summary: `docker push failed for ${artifact.image}`, next: "Verify the D601 local/internal registry is reachable from the Docker daemon and rerun --publish." }), logTail: tailText(`${result.stdout}\n${result.stderr}`), publishDurationMs: result.durationMs }; } const digest = inspectDigest(`${result.stdout}\n${result.stderr}`); if (!digest) { return { ...artifact, status: "published_unverified_digest", digest: "not_published", blocker: blocker({ type: "environment_blocker", scope: artifact.serviceId, summary: `docker push completed for ${artifact.image} but did not return an immutable digest`, next: "Verify registry digest reporting, then rerun --publish; do not update the catalog with a digest until a sha256 registry digest is observed." }), pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), publishDurationMs: result.durationMs }; } return { ...artifact, status: "published", digest, repositoryDigest: `${repositoryFromImageRef(artifact.image)}@${digest}`, pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), publishDurationMs: result.durationMs }; } function repositoryFromImageRef(image) { const lastColon = image.lastIndexOf(":"); return lastColon === -1 ? image : image.slice(0, lastColon); } 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) => artifact.status === "published")) return "published"; if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => artifact.status === "built")) 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 artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) { return { ...service, sourceCommitId: commitId, status, image: imageRef(args.registryPrefix, service.serviceId, shortCommit), imageTag: shortCommit, buildCreatedAt, buildSource, digest, repositoryDigest: null }; } 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, 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, dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked) }; }) }; } 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 }) { 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 builtCount = requiredArtifacts.filter((artifact) => ["built", "published", "published_unverified_digest"].includes(artifact.status)).length; return { $schema: "https://hwlab.pikastech.local/schemas/dev-artifact-publish.schema.json", $id: "https://hwlab.pikastech.local/dev-cd/dev-artifacts.json", reportVersion: "v1", issue: "pikasTech/HWLAB#35", taskId: "dev-artifact-publish", commitId: shortCommit, acceptanceLevel: "dev_artifact_publish", devOnly: true, prodDisabled: true, reportLifecycle: activeReportLifecycle("Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute."), sourceContract: { status: fatalBlocked ? "blocked" : "pass", documents: [ "docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md", "docs/artifact-catalog.md", "docs/dev-artifact-publish.md", "docs/dev-base-image-preflight.md" ], summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope." }, validationCommands: [ "node --check scripts/dev-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/dev-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: [ "node scripts/m1-contract-smoke.mjs" ], 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: [ "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run" ], evidence: [ "This report records DEV artifact build/publish state only; deployment dry-run remains separate." ], 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 dev and namespace remains hwlab-dev.", "PROD profile remains disabled.", "No secret or token material is required by this workflow." ], summary: fatalBlocked ? "Fatal preconditions blocked a full DEV artifact publish." : "Fatal DEV-only artifact preconditions passed." }, blockers: devGateBlockers(blockers), artifactPublish: { issue: "pikasTech/HWLAB#35", status, mode, repo, sourceCommitId: commitId, registryPrefix: args.registryPrefix, producer, registryCapabilities: summarizeRegistryCapabilities(registryCapabilities), baseImage: args.baseImage ?? null, selectedServiceIds: args.services, quietBuild: args.quietBuild, concurrency: args.concurrency, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), environment: ENVIRONMENT_DEV, buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null, serviceInventory, publishPlan, serviceCount: artifacts.length, requiredServiceCount: serviceInventory.requiredServiceCount, disabledServiceCount: serviceInventory.disabledServiceCount, builtCount, publishedCount, timings: { dockerBuildDurationMs: sumDurations(artifacts, "dockerBuildDurationMs"), cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"), publishDurationMs: sumDurations(artifacts, "publishDurationMs") }, generatedAt: new Date().toISOString(), services: artifacts.map((artifact) => ({ serviceId: artifact.serviceId, status: artifact.status, 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, dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null, publishDurationMs: artifact.publishDurationMs ?? null, dockerBuildLogTail: artifact.dockerBuildLogTail ?? null, logTail: artifact.logTail ?? null, pushLogTail: artifact.pushLogTail ?? null })), blockers }, notes: "DEV-only 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 transactionGuard = requireDevArtifactSideEffectEnv({ script: "scripts/dev-artifact-publish.mjs", mode: "--publish" }); if (transactionGuard) { console.log(JSON.stringify(transactionGuard, null, 2)); process.exitCode = 2; return; } } args.registryPrefix = validateRegistryPrefix(args.registryPrefix); const [baseImagePreflight, registryCapabilities] = await Promise.all([ runDevBaseImagePreflight({ env: preflightEnvForBaseImage(args.baseImage) }), probeRegistryCapabilities({ registryPrefix: args.registryPrefix }) ]); if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; } const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([ readJson(catalogPath), readJson(deployPath), gitValue(["rev-parse", "HEAD"]), gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git") ]); const shortCommit = commitId.slice(0, 7); const buildCreatedAt = new Date().toISOString(); const repo = repoLabelFromRemote(remoteUrl); const producer = devArtifactProducer(process.env); const services = await resolveServices(args.services, catalog); let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }); let artifacts = services.map((service) => artifactRecord({ args, service, commitId, shortCommit, status: service.artifactRequired ? "preflight_only" : "not_required" })); 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) => item.artifactRequired), 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, dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null, cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null }); if (args.mode === "publish" && artifact.status === "built") { emit("publish_start", { serviceId: service.serviceId, image: artifact.image }); const published = await publishService(artifact); artifactByServiceId.set(service.serviceId, published); if (published.blocker) blockers.push(published.blocker); emit("publish_done", { serviceId: service.serviceId, status: published.status, image: published.image, digest: published.digest, publishDurationMs: published.publishDurationMs ?? 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 }); 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, concurrency: report.artifactPublish.concurrency, timings: report.artifactPublish.timings, publishPlan: report.artifactPublish.publishPlan, services: report.artifactPublish.services.map((service) => ({ serviceId: service.serviceId, status: service.status, image: service.image, imageTag: service.imageTag, buildCreatedAt: service.buildCreatedAt, digest: service.digest, distFreshness: service.distFreshness, dockerBuildDurationMs: service.dockerBuildDurationMs, cloudWebBuildDurationMs: service.cloudWebBuildDurationMs, publishDurationMs: service.publishDurationMs, 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; });