feat: add dev artifact publish workflow
This commit is contained in:
@@ -0,0 +1,946 @@
|
||||
#!/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";
|
||||
|
||||
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 || "node:22-bookworm-slim";
|
||||
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 default: node:22-bookworm-slim",
|
||||
" --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 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 }) {
|
||||
const blockers = [];
|
||||
let registryPrefix = args.registryPrefix;
|
||||
|
||||
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."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of [args.baseImage.split(":").at(-1), 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."
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dockerVersion = await run("docker", ["version", "--format", "{{json .}}"]);
|
||||
if (dockerVersion.code !== 0) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "environment_blocker",
|
||||
scope: "docker",
|
||||
summary: "Docker CLI or daemon is unavailable.",
|
||||
next: "Make the D601 runner Docker daemon reachable, then rerun the publish command."
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const baseInspect = await run("docker", ["image", "inspect", args.baseImage, "--format", "{{.Id}}"]);
|
||||
if (baseInspect.code !== 0) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "environment_blocker",
|
||||
scope: "base-image",
|
||||
summary: `base image ${args.baseImage} is not present locally`,
|
||||
next: "Preload an internal/local Node base image or set HWLAB_DEV_BASE_IMAGE to an already-local image; the publish path uses --pull=false."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
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"
|
||||
],
|
||||
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 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.",
|
||||
"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,
|
||||
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 [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 });
|
||||
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 });
|
||||
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,
|
||||
services: report.artifactPublish.services.map((service) => ({
|
||||
serviceId: service.serviceId,
|
||||
status: service.status,
|
||||
image: service.image,
|
||||
digest: service.digest
|
||||
})),
|
||||
blockers
|
||||
}, null, 2));
|
||||
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user