#!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.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 = "reports/dev-gate/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([ "repo-entrypoint", "static-web-wrapper", "repo-bundle" ]); 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 }; 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 === "--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 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: reports/dev-gate/dev-artifacts.json", " --no-report print JSON without updating the report file" ].join("\n")); } async function readJson(relativePath) { return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); } async function pathExists(relativePath) { try { await access(path.join(repoRoot, relativePath), fsConstants.R_OK); return true; } catch { return false; } } function emit(event, payload = {}) { process.stderr.write(`${JSON.stringify({ event, ...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, publishUsable: result.publishUsable, blockers: result.blockers, nextSteps: result.nextSteps, containerEngine: result.containerEngine }; } 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 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 }; } 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 resolveService(serviceId) { const cmdPath = `cmd/${serviceId}/main.mjs`; if (await pathExists(cmdPath)) { return { serviceId, runtimeKind: "node-command", entrypoint: cmdPath, implementationState: "repo-entrypoint" }; } if (serviceId === "hwlab-cloud-web") { return { serviceId, runtimeKind: "cloud-web", entrypoint: "web/hwlab-cloud-web/index.html", implementationState: "static-web-wrapper" }; } if (serviceId === "hwlab-cli") { const hasBin = await pathExists("tools/hwlab-cli/bin/hwlab-cli.mjs"); return { serviceId, runtimeKind: "cli", entrypoint: hasBin ? "tools/hwlab-cli/bin/hwlab-cli.mjs" : "tools/hwlab-cli/lib/cli.mjs", implementationState: hasBin ? "repo-entrypoint" : "library-only" }; } if (serviceId === "hwlab-agent-skills") { return { serviceId, runtimeKind: "skills-bundle", entrypoint: "skills/hwlab-agent-runtime/SKILL.md", implementationState: "repo-bundle" }; } return { serviceId, runtimeKind: "health-placeholder", entrypoint: null, implementationState: "missing-runtime-entrypoint" }; } async function resolveServices(serviceIds) { const unknown = serviceIds.filter((serviceId) => !SERVICE_IDS.includes(serviceId)); assert.deepEqual(unknown, [], `unknown service IDs: ${unknown.join(", ")}`); const services = []; for (const serviceId of serviceIds) { services.push(await resolveService(serviceId)); } return services; } function runtimeScriptBase64() { const source = String.raw` import { createServer } from "node:http"; import { spawn } from "node:child_process"; 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); 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 healthPayload() { return { serviceId, environment, status: "ok", artifactKind: runtimeKind, revision: process.env.HWLAB_COMMIT_ID || "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); }); } 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; } const relativePath = url.pathname === "/" ? "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", "ARG HWLAB_ENVIRONMENT", "ARG HWLAB_SERVICE_ID", "ARG HWLAB_ARTIFACT_KIND", "ARG HWLAB_SERVICE_ENTRYPOINT", "ARG HWLAB_COMMIT_ID", "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 PORT=$PORT", "ENV HWLAB_PORT=$HWLAB_PORT", "COPY package.json ./package.json", "COPY internal ./internal", "COPY cmd ./cmd", "COPY web ./web", "COPY tools ./tools", "COPY skills ./skills", `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);"`, `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); } async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) { 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 (baseImagePreflight.containerEngine?.available) { if (registryPrefix.startsWith("127.0.0.1:5000/") || registryPrefix.startsWith("localhost:5000/")) { const ps = await run("docker", ["ps", "--format", "{{json .}}"]); if (ps.code !== 0 || !dockerPsShowsRegistry5000(ps.stdout)) { blockers.push( blocker({ type: "network_blocker", scope: "registry", summary: "No Docker-visible registry container is publishing port 5000.", next: "Start or expose the D601 local registry before running --publish." }) ); } } } for (const service of services) { if (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)); } function dockerPsShowsRegistry5000(stdout) { return stdout .split("\n") .filter(Boolean) .some((line) => { try { const row = JSON.parse(line); const image = String(row.Image ?? ""); const ports = String(row.Ports ?? ""); return image.startsWith("registry:") && ports.includes("5000->5000/tcp"); } catch { return line.includes("registry:") && /5000(?:->|\\u003e)5000\/tcp/u.test(line); } }); } async function buildService({ args, repo, commitId, shortCommit, service }) { const tag = shortCommit; const ref = imageRef(args.registryPrefix, service.serviceId, tag); if (!buildableImplementationStates.has(service.implementationState)) { return { ...service, image: ref, imageTag: tag, status: "blocked_missing_runtime", digest: "not_published" }; } 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.title", service.serviceId], ["hwlab.pikastech.local/repo", repo], ["hwlab.pikastech.local/commit", commitId], ["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], ["PORT", String(port)], ["HWLAB_PORT", String(port)] ]; const argsList = [ "build", "--pull=false", "--network=none", "--quiet", "--build-arg", `BASE_IMAGE=${args.baseImage}` ]; 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, 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}`) }; } return { ...service, image: ref, imageTag: tag, status: "built", localImageId: result.stdout.trim(), digest: "not_published" }; } async function runWithInput(command, args, input) { 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 }; } 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}`) }; } const digest = inspectDigest(`${result.stdout}\n${result.stderr}`); return { ...artifact, status: digest ? "published" : "published_unverified_digest", digest: digest ?? "digest_not_found", repositoryDigest: digest ? `${repositoryFromImageRef(artifact.image)}@${digest}` : null, pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200) }; } function repositoryFromImageRef(image) { const lastColon = image.lastIndexOf(":"); return lastColon === -1 ? image : image.slice(0, lastColon); } function reportStatus(mode, artifacts, blockers) { if (mode === "preflight") return blockers.length ? "blocked" : "pass"; if (artifacts.some((artifact) => artifact.status === "publish_failed" || artifact.status === "build_failed")) return "failed"; if (artifacts.some((artifact) => artifact.status.startsWith("blocked_"))) return "blocked"; if (mode === "publish" && artifacts.every((artifact) => artifact.status === "published")) return "published"; if (mode === "build" && artifacts.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 createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers, baseImagePreflight }) { const status = reportStatus(mode, artifacts, blockers); const fatalBlocked = hasFatalBlocker(blockers); const publishedCount = artifacts.filter((artifact) => artifact.status === "published").length; const builtCount = artifacts.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/reports/dev-gate/dev-artifacts.json", reportVersion: "v1", issue: "pikasTech/HWLAB#31", taskId: "dev-artifact-publish", commitId: shortCommit, acceptanceLevel: "dev_artifact_publish", devOnly: true, prodDisabled: true, 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/preflight-dev-base-image.mjs", "node --check scripts/src/dev-base-image-preflight.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, baseImage: args.baseImage ?? null, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), environment: ENVIRONMENT_DEV, serviceCount: artifacts.length, builtCount, publishedCount, 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, digest: artifact.digest ?? "not_published", repositoryDigest: artifact.repositoryDigest ?? null, runtimeKind: artifact.runtimeKind, implementationState: artifact.implementationState, entrypoint: artifact.entrypoint, localImageId: artifact.localImageId })), blockers }, notes: "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations." }; } async function writeReport(relativePath, report) { const absolutePath = path.join(repoRoot, relativePath); 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; } args.registryPrefix = validateRegistryPrefix(args.registryPrefix); const baseImagePreflight = await runDevBaseImagePreflight({ env: preflightEnvForBaseImage(args.baseImage) }); 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 repo = repoLabelFromRemote(remoteUrl); const services = await resolveServices(args.services); let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight }); let artifacts = services.map((service) => ({ ...service, status: "preflight_only", image: imageRef(args.registryPrefix, service.serviceId, shortCommit), imageTag: shortCommit, digest: "not_published" })); 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") { artifacts = []; for (const service of services) { emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) }); const artifact = await buildService({ args, repo, commitId, shortCommit, service }); artifacts.push(artifact); if (artifact.blocker) blockers.push(artifact.blocker); emit("build_done", { serviceId: service.serviceId, status: artifact.status, image: artifact.image }); if (args.mode === "publish" && artifact.status === "built") { emit("publish_start", { serviceId: service.serviceId, image: artifact.image }); const published = await publishService(artifact); artifacts[artifacts.length - 1] = published; if (published.blocker) blockers.push(published.blocker); emit("publish_done", { serviceId: service.serviceId, status: published.status, image: published.image, digest: published.digest }); } } } const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers, baseImagePreflight }); 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, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), services: report.artifactPublish.services.map((service) => ({ serviceId: service.serviceId, status: service.status, image: service.image, digest: service.digest })), 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; });