Files
pikasTech-HWLAB/scripts/artifact-publish.mjs
T
2026-05-28 16:56:19 +08:00

2334 lines
90 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import {
mkdir,
mkdtemp,
readFile,
rm,
writeFile
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import { createG14CiPlan } from "./src/g14-ci-plan-lib.mjs";
import {
BUILDABLE_IMPLEMENTATION_STATES,
resolveDevArtifactServices,
serviceInventoryFromServices
} from "./src/dev-artifact-services.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 cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs";
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14";
const defaultRegistryPrefix =
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null;
const defaultReportPath = tempReportPath("dev-artifacts.json");
const buildBackend = "buildkit";
const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh";
const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null;
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u;
const fatalBlockerTypes = new Set([
"contract_blocker",
"environment_blocker",
"network_blocker",
"safety_blocker"
]);
const buildableImplementationStates = new Set(BUILDABLE_IMPLEMENTATION_STATES);
const sourceStates = new Set([
"source-present",
"intentionally-disabled"
]);
const ciArtifactIdentityEnvNames = [
"HWLAB_CI_ARTIFACT_RUN_ID",
"GITHUB_RUN_ID",
"CI_PIPELINE_ID",
"BUILDKITE_BUILD_ID"
];
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",
lane: defaultLane,
catalogPath: defaultCatalogPath,
deployPath: defaultDeployPath,
imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short",
registryPrefix: defaultRegistryPrefix,
baseImage: defaultBaseImage,
reportPath: defaultReportPath,
services: [...SERVICE_IDS],
servicesExplicit: false,
affectedOnly: true,
buildBackend,
buildkitCommand: defaultBuildkitCommand,
buildkitAddr: defaultBuildkitAddr,
externalServiceReportDir: null,
externalServiceResultsEnv: false,
tektonResultsDir: null,
emitReport: true,
quietBuild: false,
concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4)
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--preflight") args.mode = "preflight";
else if (arg === "--build") args.mode = "build";
else if (arg === "--publish") args.mode = "publish";
else if (arg === "--lane") args.lane = readOption(argv, ++index, arg);
else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") args.deployPath = readOption(argv, ++index, arg);
else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg);
else if (arg === "--services") {
args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
args.servicesExplicit = true;
}
else if (arg === "--affected-only") args.affectedOnly = true;
else if (arg === "--buildkit-command") args.buildkitCommand = readOption(argv, ++index, arg);
else if (arg === "--buildkit-addr") args.buildkitAddr = readOption(argv, ++index, arg);
else if (arg === "--external-service-report-dir") args.externalServiceReportDir = readOption(argv, ++index, arg);
else if (arg === "--external-service-results-env") args.externalServiceResultsEnv = true;
else if (arg === "--tekton-results-dir") args.tektonResultsDir = readOption(argv, ++index, arg);
else if (arg === "--no-report") args.emitReport = false;
else if (arg === "--quiet-build") args.quietBuild = true;
else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4);
else if (arg === "--help" || arg === "-h") args.help = true;
else throw new Error(`unknown argument ${arg}`);
}
assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`);
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
return args;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) {
throw new Error(`${name} requires a value`);
}
return value;
}
function parseConcurrency(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, 1), 8);
}
function artifactIdentityValue(env, name) {
const value = env?.[name];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function g14ArtifactIdentityName(env = process.env) {
return ciArtifactIdentityEnvNames.find((name) => artifactIdentityValue(env, name));
}
function g14ArtifactProducer(env = process.env) {
const idName = g14ArtifactIdentityName(env);
if (!idName) {
return {
kind: "missing",
runId: null,
owner: null
};
}
return {
kind: "g14-ci-artifact",
runId: artifactIdentityValue(env, idName),
owner: artifactIdentityValue(env, "HWLAB_CI_ARTIFACT_RUN_OWNER") ?? idName
};
}
function g14ArtifactPublishIdentityGuardFailure({ script, mode }) {
return {
ok: false,
status: "blocked",
error: "g14-artifact-publish-identity-required",
code: "g14-artifact-publish-identity-required",
script,
mode,
requiredEnv: "HWLAB_CI_ARTIFACT_RUN_ID or standard CI run identity",
acceptedEnv: ciArtifactIdentityEnvNames,
entrypoint: `HWLAB_CI_ARTIFACT_RUN_ID=<run-id> HWLAB_CI_ARTIFACT_RUN_OWNER=<owner> node scripts/g14-artifact-publish.mjs --publish --report ${tempReportPath("dev-artifacts.json")}`,
summary: `${script} ${mode} is a G14 artifact publish side-effect step and must run under CI artifact identity. Legacy D601 CD transaction identity is not accepted.`,
devOnly: true,
prodTouched: false,
cdLockRequired: false,
rolloutAttempted: false,
mutationAttempted: false
};
}
function requireG14ArtifactPublishIdentity({ env = process.env, script, mode }) {
return g14ArtifactIdentityName(env) ? null : g14ArtifactPublishIdentityGuardFailure({ script, mode });
}
function printHelp() {
console.log([
`usage: node ${cliEntrypoint} [--preflight|--build|--publish]`,
"",
"Commit-pinned artifact build/publish helper for controlled CI execution.",
"",
"options:",
" --lane LANE g14 or v02; default: g14",
` --catalog-path PATH default: ${defaultCatalogPath}`,
` --deploy-json PATH default: ${defaultDeployPath}`,
" --image-tag-mode MODE short or full; v02 uses full source commit IDs",
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
" --services LIST comma-separated service IDs; default: all frozen DEV services",
" --affected-only default and only supported scope: build/publish affected services and reuse unchanged catalog digests",
" --buildkit-command PATH buildctl-daemonless.sh/buildctl command for buildkit backend",
" --buildkit-addr ADDR optional buildctl --addr endpoint for a sidecar BuildKit daemon",
" --external-service-report-dir DIR collect affected service artifacts from per-service publish reports",
" --external-service-results-env collect service artifacts from HWLAB_SERVICE_RESULT_* env vars",
" --tekton-results-dir DIR write single-service build result files for Tekton results",
` --report PATH default: ${defaultReportPath}`,
" --no-report print JSON without updating the temporary artifact JSON",
" --quiet-build keep BuildKit progress quiet; default keeps build output visible",
" --concurrency N parallel service build/push workers; default: 4, max: 8"
].join("\n"));
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
}
async function readJsonIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
function emit(event, payload = {}) {
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`);
}
function ciTimingEnabled(env = process.env) {
return env.HWLAB_G14_CICD_TIMING === "1" || env.HWLAB_CI_TIMING === "1";
}
function emitCiTiming(payload) {
emit("g14-cicd-timing", {
schemaVersion: "v1",
source: "scripts/artifact-publish.mjs",
pipelineRun: process.env.HWLAB_TEKTON_PIPELINERUN ?? null,
taskRun: process.env.HWLAB_TEKTON_TASKRUN ?? null,
task: process.env.HWLAB_TEKTON_TASK ?? null,
revision: process.env.HWLAB_SOURCE_REVISION ?? null,
...payload
});
}
function parseDurationMs(value) {
const text = String(value ?? "").trim();
if (!text) return null;
let total = 0;
let matched = false;
for (const match of text.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) {
matched = true;
const amount = Number(match[1]);
const unit = match[2];
if (unit === "ms") total += amount;
else if (unit === "s") total += amount * 1000;
else if (unit === "m") total += amount * 60 * 1000;
else if (unit === "h") total += amount * 60 * 60 * 1000;
}
return matched ? Math.round(total) : null;
}
function parseBuildkitPlainTimings(text) {
const idToStage = new Map();
const timings = new Map();
const stageMatchers = [
{ stage: "buildkit-context-transfer", pattern: /load build context|transferring context/iu },
{ stage: "cache-import", pattern: /importing cache manifest/iu },
{ stage: "cache-export", pattern: /exporting cache/iu }
];
for (const rawLine of String(text ?? "").split("\n")) {
const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, "").trim();
const match = line.match(/^#(\d+)\s+(.*)$/u);
if (!match) continue;
const [, id, body] = match;
const identified = stageMatchers.find((item) => item.pattern.test(body));
if (identified) idToStage.set(id, identified.stage);
const stage = idToStage.get(id);
if (!stage || timings.has(stage)) continue;
const durationMatch = body.match(/(?:DONE|done)\s+([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)/iu) ??
body.match(/([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)\s+done/iu);
const durationMs = parseDurationMs(durationMatch?.[1]);
if (durationMs !== null) timings.set(stage, { stage, status: "succeeded", durationMs, source: "buildkit-plain-progress" });
}
return timings;
}
function parseDockerfileStepTimings(text) {
const timings = [];
for (const rawLine of String(text ?? "").split("\n")) {
const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, "");
const start = line.indexOf("{");
const end = line.lastIndexOf("}");
if (start < 0 || end <= start) continue;
let parsed;
try {
parsed = JSON.parse(line.slice(start, end + 1));
} catch {
continue;
}
if (parsed?.event !== "g14-cicd-build-step-timing") continue;
timings.push({
stage: parsed.stage,
status: parsed.status ?? "unknown",
durationMs: Number.isFinite(parsed.durationMs) ? parsed.durationMs : null,
source: parsed.source ?? "dockerfile-run"
});
}
return timings;
}
function emitBuildkitTimingEvents({ service, result, timingEnabled }) {
if (!timingEnabled) return;
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
const buildkitTimings = parseBuildkitPlainTimings(output);
for (const stage of ["buildkit-context-transfer", "cache-import", "cache-export"]) {
const timing = buildkitTimings.get(stage) ?? { stage, status: "not-observed", durationMs: null, source: "buildkit-plain-progress" };
emitCiTiming({
...timing,
serviceId: service.serviceId,
buildkitDurationMs: result.durationMs
});
}
for (const timing of parseDockerfileStepTimings(output)) {
emitCiTiming({
...timing,
serviceId: service.serviceId,
buildkitDurationMs: result.durationMs
});
}
}
function blocker({ type, scope, summary, next }) {
return {
type,
scope,
status: "open",
summary,
next
};
}
function buildkitBaseImagePreflight(baseImage) {
const requestedImage = baseImage || defaultBaseImage || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
return {
preflight: "hwlab-dev-base-image",
environment: ENVIRONMENT_DEV,
status: "pass",
publishUsable: true,
artifactPublish: {
issue: "#35",
usable: true,
reason: "BuildKit pulls the approved builder base image from the registry during k8s-native publish."
},
imageSource: "buildkit-registry-pull",
requestedImage,
localTag: requestedImage,
imageId: null,
repoDigests: [],
candidates: [],
rejectedCandidates: [],
blockers: [],
nextSteps: [],
recommendation: {
source: "G14 k8s registry image used by rootless BuildKit.",
envVar: "HWLAB_DEV_BASE_IMAGE",
recommendedEnvValue: requestedImage
},
provision: {
required: false,
exactBlocker: null,
recommendedEnv: {
name: "HWLAB_DEV_BASE_IMAGE",
value: requestedImage,
assignment: `HWLAB_DEV_BASE_IMAGE=${requestedImage}`
},
commands: {}
},
blockedReport: null,
containerEngine: "buildkit-rootless"
};
}
function summarizeBaseImagePreflight(result) {
return {
status: result.status,
imageSource: result.imageSource,
requestedImage: result.requestedImage,
localTag: result.localTag,
imageId: result.imageId,
repoDigests: result.repoDigests,
candidates: result.candidates,
rejectedCandidates: result.rejectedCandidates,
publishUsable: result.publishUsable,
blockers: result.blockers,
nextSteps: result.nextSteps,
recommendation: result.recommendation,
provision: result.provision,
blockedReport: result.blockedReport,
containerEngine: result.containerEngine
};
}
function summarizeRegistryCapabilities(result) {
return {
version: result.version,
registryPrefix: result.registryPrefix,
generatedAt: result.generatedAt,
classification: result.classification,
dimensions: result.dimensions,
interpretation: result.interpretation
};
}
function baseImagePreflightBlocker(result) {
return blocker({
type: "environment_blocker",
scope: "base-image",
summary: result.blockers.length
? result.blockers.join("; ")
: "DEV builder base image preflight did not return ready.",
next: result.nextSteps.length
? result.nextSteps.join(" ")
: "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish."
});
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function dockerfileQuote(value) {
return JSON.stringify(String(value));
}
function buildkitCacheRef(registryPrefix, serviceId) {
return `${registryPrefix}/cache/${serviceId}`;
}
function buildkitRegistryOption(registryPrefix) {
const parsed = parseRegistryPrefix(registryPrefix);
return parsed.host === "127.0.0.1" || parsed.host === "localhost"
? ",registry.insecure=true"
: "";
}
function buildkitDigestFromMetadata(value) {
const keys = [
"containerimage.digest",
"containerimage.descriptor.digest",
"image.digest",
"digest"
];
for (const key of keys) {
const direct = value?.[key];
if (shaDigestPattern.test(direct ?? "")) return direct;
}
const nested = value?.containerimage?.digest ?? value?.containerimage?.descriptor?.digest;
return shaDigestPattern.test(nested ?? "") ? nested : null;
}
async function buildkitCommandPreflight(command) {
const result = await run("sh", ["-c", `command -v ${shellQuote(command)}`]);
return {
ok: result.code === 0,
command,
result
};
}
async function run(command, args, options = {}) {
const startedAt = Date.now();
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: options.env ?? process.env,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise((resolve) => {
child.on("error", () => resolve(127));
child.on("close", resolve);
});
return {
command: commandLine(command, args),
code,
stdout,
stderr,
durationMs: Date.now() - startedAt
};
}
function assertDevOnlyContracts(catalog, deployManifest) {
assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog.environment must be dev");
assert.equal(catalog.profile, ENVIRONMENT_DEV, "catalog.profile must be dev");
assert.equal(catalog.namespace, "hwlab-dev", "catalog.namespace must be hwlab-dev");
assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "catalog.allowedProfiles must only allow dev");
assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "catalog.forbiddenProfiles must forbid prod");
assert.equal(deployManifest.environment, ENVIRONMENT_DEV, "deploy.environment must be dev");
assert.equal(deployManifest.namespace, "hwlab-dev", "deploy.namespace must be hwlab-dev");
assert.equal(deployManifest.profiles?.dev?.enabled, true, "deploy dev profile must be enabled");
assert.equal(deployManifest.profiles?.prod?.enabled, false, "deploy prod profile must stay disabled");
const catalogIds = catalog.services.map((service) => service.serviceId);
const deployIds = deployManifest.services.map((service) => service.serviceId);
assert.deepEqual(catalogIds, SERVICE_IDS, "catalog services must match frozen DEV service IDs");
assert.deepEqual(deployIds, SERVICE_IDS, "deploy services must match frozen DEV service IDs");
for (const service of [...catalog.services, ...deployManifest.services]) {
assert.equal(service.profile, ENVIRONMENT_DEV, `${service.serviceId}.profile must be dev`);
assert.equal(service.namespace, "hwlab-dev", `${service.serviceId}.namespace must be hwlab-dev`);
assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`);
}
}
function laneDeployConfig(deployManifest, lane) {
if (lane === "v02") return deployManifest?.lanes?.v02 ?? null;
return null;
}
function assertV02Contracts(catalog, deployManifest) {
const lane = laneDeployConfig(deployManifest, "v02");
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 artifact publish");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.equal(lane.endpoint, "http://74.48.78.17:19667", "deploy.lanes.v02.endpoint must be the v02 API endpoint");
assert.equal(catalog.environment, "v02", "catalog.environment must be v02");
assert.equal(catalog.profile, "v02", "catalog.profile must be v02");
assert.equal(catalog.namespace, "hwlab-v02", "catalog.namespace must be hwlab-v02");
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog.allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod");
const catalogIds = (catalog.services ?? []).map((service) => service.serviceId);
assert.deepEqual(catalogIds, SERVICE_IDS, "v02 catalog services must match frozen service IDs");
for (const service of catalog.services ?? []) {
assert.equal(service.profile, "v02", `${service.serviceId}.profile must be v02`);
assert.equal(service.namespace, "hwlab-v02", `${service.serviceId}.namespace must be hwlab-v02`);
assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`);
}
}
function assertArtifactContracts(args, catalog, deployManifest) {
if (args.lane === "v02") return assertV02Contracts(catalog, deployManifest);
return assertDevOnlyContracts(catalog, deployManifest);
}
function imageTagForCommit(args, commitId) {
return args.imageTagMode === "full" ? commitId : commitId.slice(0, 7);
}
function artifactEnvironment(args) {
return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV;
}
function artifactCatalogSkeleton({ args, commitId }) {
const tag = imageTagForCommit(args, commitId);
return {
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
environment: artifactEnvironment(args),
profile: artifactEnvironment(args),
namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev",
endpoint: args.lane === "v02" ? "http://74.48.78.17:19667" : "http://74.48.78.17:16667",
commitId: tag,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
registryVerified: false,
provenance: "not_available_until_publish",
note: `${artifactEnvironment(args)} artifact catalog skeleton initialized without DEV/G14 fallback.`
},
allowedProfiles: [artifactEnvironment(args)],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: SERVICE_IDS.map((serviceId) => ({
serviceId,
commitId: tag,
sourceCommitId: commitId,
image: imageRef(args.registryPrefix, serviceId, tag),
imageTag: tag,
digest: "not_published",
publishState: "skeleton-only",
profile: artifactEnvironment(args),
namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev",
healthPath: "/health/live",
sourceState: "source-present",
publishEnabled: true,
artifactRequired: true,
artifactScope: "required",
notPublishedReason: "publish_not_run"
}))
};
}
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 { 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";
import { isCloudWebSseRoute, proxyCloudApiRequest } from "/app/internal/dev-entrypoint/cloud-web-proxy.mjs";
import { cloudWebProxyRoutePolicy } from "/app/internal/dev-entrypoint/cloud-web-routes.mjs";
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, 1260000, {
min: 1000,
max: 2400000
});
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 || "danger-full-access";
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";
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 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, response, body, url) {
return proxyCloudApiRequest({
target,
request,
response,
body,
timeoutMs: cloudApiProxyTimeoutMs,
forceStream: isCloudWebSseRoute(url.pathname)
});
}
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 {
await requestUpstream(target, request, response, request.method === "GET" ? "" : body, url);
} catch (error) {
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy(error);
return;
}
const timedOut = error.timedOut === true || /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;
}
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) {
if (proxyPolicy.authRequired && !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, labels = []) {
const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'g14-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`;
return [
`FROM ${baseImage}`,
"WORKDIR /app",
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
"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=danger-full-access",
"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",
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
`RUN ${dependencyTimingScript}`,
"COPY internal ./internal",
"COPY cmd ./cmd",
"COPY scripts ./scripts",
"COPY web ./web",
"COPY tools ./tools",
"COPY skills ./skills",
"COPY 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 tailText(value, maxLength = 2500) {
const text = value.trim();
if (text.length <= maxLength) return text;
return text.slice(text.length - maxLength);
}
function sourceStateBlocker(service) {
return blocker({
type: "contract_blocker",
scope: service.serviceId,
summary: `${service.serviceId} has invalid sourceState ${service.sourceState ?? "unset"}.`,
next: `Set ${service.serviceId} sourceState to source-present or intentionally-disabled.`
});
}
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds = new Set() }) {
const blockers = [];
let registryPrefix = args.registryPrefix;
const buildRequired = (args.mode === "build" || args.mode === "publish") && buildServiceIds.size > 0;
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
} else if (buildRequired) {
blockers.push(baseImagePreflightBlocker(baseImagePreflight));
}
try {
registryPrefix = validateRegistryPrefix(args.registryPrefix);
} catch (error) {
blockers.push(
blocker({
type: "safety_blocker",
scope: "registry",
summary: error.message,
next: "Use the G14 local/internal registry prefix, for example 127.0.0.1:5000/hwlab."
})
);
}
try {
assertArtifactContracts(args, catalog, deployManifest);
} catch (error) {
blockers.push(
blocker({
type: "contract_blocker",
scope: `${args.lane}-contract`,
summary: error.message,
next: `Fix ${args.catalogPath} and ${args.deployPath} so the selected artifact lane covers the frozen service IDs without crossing namespaces.`
})
);
}
const baseImageTag = args.baseImage ? args.baseImage.split(":").at(-1) : "";
for (const tag of [baseImageTag, args.services.length === 1 ? args.services[0] : ""]) {
if (mutableTags.has(tag)) {
blockers.push(
blocker({
type: "safety_blocker",
scope: "tag-policy",
summary: `mutable tag ${tag} is not allowed for DEV artifact identity`,
next: "Use the immutable git commit tag generated by this script."
})
);
}
}
if (buildRequired) {
const buildkitPreflight = await buildkitCommandPreflight(args.buildkitCommand);
if (!buildkitPreflight.ok) {
blockers.push(
blocker({
type: "environment_blocker",
scope: "buildkit-command",
summary: `BuildKit command ${args.buildkitCommand} is not available in the publish task image.`,
next: "Use the G14 Tekton BuildKit path that prepares buildctl/buildkitd before running --publish."
})
);
}
}
for (const service of services) {
if (!sourceStates.has(service.sourceState)) {
blockers.push(sourceStateBlocker(service));
} else if (service.sourceState === "intentionally-disabled") {
blockers.push(
blocker({
type: "runtime_blocker",
scope: service.serviceId,
summary: `${service.serviceId} is intentionally disabled for MVP artifact publication.`,
next: `Keep ${service.serviceId} out of the MVP artifact catalog, or add a source entrypoint before publishing it.`
})
);
} else if (service.artifactRequired && service.implementationState === "missing-runtime-entrypoint") {
blockers.push(
blocker({
type: "runtime_blocker",
scope: service.serviceId,
summary: `${service.serviceId} has no 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 BuildKit image 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 BuildKit image 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", cliEntrypoint],
["PORT", String(port)],
["HWLAB_PORT", String(port)]
];
assert.equal(args.buildBackend, "buildkit", "artifact publish must use BuildKit only");
return buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs });
}
async function buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs }) {
const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-buildkit-${service.serviceId}-`));
const metadataPath = path.join(tmpDir, "metadata.json");
const dockerfilePath = path.join(tmpDir, "Dockerfile");
const cacheRef = buildkitCacheRef(args.registryPrefix, service.serviceId);
const registryOption = buildkitRegistryOption(args.registryPrefix);
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
const startedAt = Date.now();
try {
await writeFile(dockerfilePath, dockerfile(args.baseImage, port, labels));
const argsList = [
...buildkitAddrArgs,
"build",
"--frontend", "dockerfile.v0",
"--local", `context=${repoRoot}`,
"--local", `dockerfile=${tmpDir}`,
"--opt", "filename=Dockerfile",
"--metadata-file", metadataPath,
"--import-cache", `type=registry,ref=${cacheRef}${registryOption}`,
"--export-cache", `type=registry,ref=${cacheRef},mode=max${registryOption}`,
"--output", `type=image,name=${ref},push=true${registryOption}`
];
if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain");
for (const [name, value] of envs) argsList.push("--opt", `build-arg:${name}=${value}`);
const buildEnv = { ...process.env };
if (!args.buildkitAddr) {
buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox";
buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime");
await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true });
}
const result = await run(args.buildkitCommand, argsList, { env: buildEnv });
emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) });
if (result.code !== 0) {
return {
...service,
image: ref,
imageTag: tag,
buildCreatedAt,
buildSource,
status: "build_failed",
digest: "not_published",
blocker: blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `BuildKit build failed for ${service.serviceId}`,
next: `Inspect the BuildKit output for ${service.serviceId}, then fix its copied source, base image, or registry cache settings.`
}),
logTail: tailText(`${result.stdout}\n${result.stderr}`),
buildBackend: "buildkit",
buildkitCacheRef: cacheRef,
buildkitBuildDurationMs: result.durationMs
};
}
let metadata = {};
try {
metadata = JSON.parse(await readFile(metadataPath, "utf8"));
} catch {}
const digest = buildkitDigestFromMetadata(metadata);
if (!shaDigestPattern.test(digest ?? "")) {
return {
...service,
image: ref,
imageTag: tag,
buildCreatedAt,
buildSource,
status: "published_unverified_digest",
digest: "not_published",
blocker: blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `BuildKit completed for ${service.serviceId} but did not expose an immutable digest`,
next: "Inspect BuildKit metadata output before updating the artifact catalog."
}),
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
buildBackend: "buildkit",
buildkitCacheRef: cacheRef,
buildkitBuildDurationMs: Date.now() - startedAt
};
}
return {
...service,
image: ref,
imageTag: tag,
buildCreatedAt,
buildSource,
status: args.mode === "publish" ? "published" : "built",
digest,
repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`,
buildBackend: "buildkit",
buildkitCacheRef: cacheRef,
buildkitBuildDurationMs: Date.now() - startedAt,
publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null,
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
};
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function runWithInput(command, args, input) {
const startedAt = Date.now();
const child = spawn(command, args, {
cwd: repoRoot,
env: process.env,
stdio: ["pipe", "pipe", "pipe"]
});
child.stdin.end(input);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise((resolve) => {
child.on("error", () => resolve(127));
child.on("close", resolve);
});
return {
command: commandLine(command, args),
code,
stdout,
stderr,
durationMs: Date.now() - startedAt
};
}
function repositoryFromImageRef(image) {
const lastColon = image.lastIndexOf(":");
return lastColon === -1 ? image : image.slice(0, lastColon);
}
const publishReadyStatuses = new Set(["published", "reused"]);
const buildReadyStatuses = new Set(["built", "published", "published_unverified_digest", "reused"]);
function reportStatus(mode, artifacts, blockers) {
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
if (mode === "preflight") return blockers.length ? "blocked" : "pass";
if (requiredArtifacts.some((artifact) => artifact.status === "publish_failed" || artifact.status === "build_failed")) return "failed";
if (requiredArtifacts.some((artifact) => artifact.status.startsWith("blocked_"))) return "blocked";
if (mode === "publish" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => publishReadyStatuses.has(artifact.status))) return "published";
if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => buildReadyStatuses.has(artifact.status))) return "built";
return blockers.length ? "blocked" : "pass";
}
function devGateBlockers(blockers) {
const seen = new Set();
const result = [];
for (const item of blockers) {
const key = `${item.type}::${item.scope}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
type: item.type,
scope: item.scope,
status: item.status,
summary: item.summary
});
}
return result;
}
function artifactNotPublishedReason(artifact, mode, fatalBlocked) {
if (/^sha256:[a-f0-9]{64}$/u.test(artifact.digest ?? "")) return null;
if (!artifact.artifactRequired) return artifact.notPublishedReason;
if (artifact.status === "preflight_only") return fatalBlocked ? "blocked_by_preflight" : "preflight_only";
if (artifact.status === "built") return "build_only_not_published";
if (artifact.status === "build_failed") return "build_failed";
if (artifact.status === "publish_failed") return "publish_failed";
if (artifact.status === "published_unverified_digest") return "digest_unverified";
return artifact.notPublishedReason ?? "publish_not_run";
}
function imageTagFromReference(image) {
const value = String(image ?? "");
const slashIndex = value.lastIndexOf("/");
const colonIndex = value.lastIndexOf(":");
return colonIndex > slashIndex ? value.slice(colonIndex + 1) : null;
}
function repositoryDigestFor(image, digest) {
return image && shaDigestPattern.test(digest ?? "") ? `${repositoryFromImageRef(image)}@${digest}` : null;
}
function serviceEnvKey(serviceId) {
return String(serviceId).toUpperCase().replace(/[^A-Z0-9]+/gu, "_");
}
function serviceResultEnvName(serviceId, field) {
return `HWLAB_SERVICE_RESULT_${serviceEnvKey(serviceId)}_${field}`;
}
function serviceResultValue(env, serviceId, field) {
const value = env[serviceResultEnvName(serviceId, field)];
return typeof value === "string" && value.length > 0 ? value : null;
}
async function writeTektonResultFiles(dir, report) {
const service = report?.artifactPublish?.services?.[0];
if (!service) return;
const values = {
"service-id": service.serviceId,
status: service.status,
image: service.image,
"image-tag": service.imageTag,
digest: service.digest,
"repository-digest": service.repositoryDigest ?? repositoryDigestFor(service.image, service.digest) ?? "",
"source-commit-id": service.sourceCommitId,
"component-input-hash": service.componentInputHash ?? "",
"build-created-at": service.buildCreatedAt ?? "",
"build-backend": report.artifactPublish.buildBackend ?? "unknown",
"reused-from": service.reusedFrom ?? ""
};
await mkdir(dir, { recursive: true });
await Promise.all(Object.entries(values).map(([name, value]) => writeFile(path.join(dir, name), String(value ?? ""))));
}
function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) {
return {
...service,
commitId: shortCommit,
sourceCommitId: commitId,
status,
image: imageRef(args.registryPrefix, service.serviceId, shortCommit),
imageTag: shortCommit,
buildCreatedAt,
buildSource,
digest,
repositoryDigest: null
};
}
function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
const image = catalogService?.image ?? null;
const digest = catalogService?.digest ?? "not_published";
const imageTag = catalogService?.imageTag ?? imageTagFromReference(image);
const catalogCommitId = catalogService?.commitId ?? imageTag ?? null;
if (!image || !imageTag || !shaDigestPattern.test(digest)) {
return {
...service,
commitId: catalogCommitId,
sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId,
status: "blocked_reuse_unavailable",
image: image ?? null,
imageTag,
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
buildSource: catalogService?.buildSource ?? null,
digest,
repositoryDigest: repositoryDigestFor(image, digest),
reusedFrom: null,
notPublishedReason: "reuse_catalog_digest_unavailable"
};
}
return {
...service,
commitId: catalogCommitId,
sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId,
status: "reused",
image,
imageTag,
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
buildSource: catalogService?.buildSource ?? null,
digest,
repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest),
reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId,
notPublishedReason: null
};
}
function externalServiceReportPath(dir, serviceId) {
const base = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir);
return path.join(base, `${serviceId}.json`);
}
function artifactRecordFromExternalReport({ service, record, reportPath }) {
const image = record?.image ?? null;
const digest = record?.digest ?? "not_published";
const imageTag = record?.imageTag ?? imageTagFromReference(image);
return {
...service,
...record,
serviceId: service.serviceId,
runtimeKind: record?.runtimeKind ?? service.runtimeKind,
implementationState: record?.implementationState ?? service.implementationState,
sourceState: record?.sourceState ?? service.sourceState,
entrypoint: record?.entrypoint ?? service.entrypoint,
publishEnabled: record?.publishEnabled ?? service.publishEnabled,
artifactRequired: record?.artifactRequired ?? service.artifactRequired,
artifactScope: record?.artifactScope ?? service.artifactScope,
image,
imageTag,
digest,
repositoryDigest: record?.repositoryDigest ?? repositoryDigestFor(image, digest),
externalReportPath: reportPath,
notPublishedReason: record?.notPublishedReason ?? null
};
}
async function readExternalServiceArtifacts({ dir, services, commitId }) {
const artifacts = new Map();
const blockers = [];
for (const service of services) {
const reportPath = externalServiceReportPath(dir, service.serviceId);
let report;
try {
report = JSON.parse(await readFile(reportPath, "utf8"));
} catch (error) {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `missing per-service artifact report for ${service.serviceId}: ${error.message}`,
next: `Ensure the Tekton build-${service.serviceId} TaskRun writes ${reportPath} before collect-artifacts runs.`
}));
continue;
}
const record = report?.artifactPublish?.services?.find((item) => item?.serviceId === service.serviceId);
if (!record) {
blockers.push(blocker({
type: "contract_blocker",
scope: service.serviceId,
summary: `per-service artifact report ${reportPath} does not contain ${service.serviceId}`,
next: "Regenerate the service artifact report with --services set to the same service ID."
}));
continue;
}
const artifact = artifactRecordFromExternalReport({ service, record, reportPath });
artifacts.set(service.serviceId, artifact);
if (artifact.status !== "published" || !shaDigestPattern.test(artifact.digest ?? "")) {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `per-service artifact report ${reportPath} is not a verified published image for ${service.serviceId}`,
next: "Inspect the corresponding service TaskRun logs and rerun the failed service build."
}));
}
if (artifact.sourceCommitId && artifact.sourceCommitId !== commitId) {
blockers.push(blocker({
type: "contract_blocker",
scope: service.serviceId,
summary: `per-service artifact report ${reportPath} was produced for ${artifact.sourceCommitId}, expected ${commitId}`,
next: "Discard stale service reports and rerun the current PipelineRun."
}));
}
}
return { artifacts, blockers };
}
function artifactRecordFromServiceResultsEnv({ service, env }) {
const status = serviceResultValue(env, service.serviceId, "STATUS");
if (!status) return null;
const image = serviceResultValue(env, service.serviceId, "IMAGE");
const digest = serviceResultValue(env, service.serviceId, "DIGEST") ?? "not_published";
const imageTag = serviceResultValue(env, service.serviceId, "IMAGE_TAG") ?? imageTagFromReference(image);
return {
...service,
serviceId: service.serviceId,
status,
image,
imageTag,
digest,
repositoryDigest: serviceResultValue(env, service.serviceId, "REPOSITORY_DIGEST") ?? repositoryDigestFor(image, digest),
sourceCommitId: serviceResultValue(env, service.serviceId, "SOURCE_COMMIT_ID"),
commitId: imageTag,
buildCreatedAt: serviceResultValue(env, service.serviceId, "BUILD_CREATED_AT"),
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? service.componentInputHash ?? null,
reusedFrom: serviceResultValue(env, service.serviceId, "REUSED_FROM"),
buildBackend: serviceResultValue(env, service.serviceId, "BUILD_BACKEND"),
notPublishedReason: null
};
}
async function readExternalServiceArtifactsFromEnv({ services, commitId, env = process.env }) {
const artifacts = new Map();
const blockers = [];
for (const service of services) {
const artifact = artifactRecordFromServiceResultsEnv({ service, env });
if (!artifact) {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `missing Tekton result env for ${service.serviceId}`,
next: `Ensure build-${service.serviceId} writes Tekton results and collect-artifacts passes them as HWLAB_SERVICE_RESULT_${serviceEnvKey(service.serviceId)}_* env vars.`
}));
continue;
}
artifacts.set(service.serviceId, artifact);
if (!publishReadyStatuses.has(artifact.status) || !shaDigestPattern.test(artifact.digest ?? "")) {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `Tekton result for ${service.serviceId} is not a verified published/reused image`,
next: "Inspect the corresponding build task results and registry digest before collecting artifacts."
}));
}
if (artifact.status === "published" && artifact.sourceCommitId && artifact.sourceCommitId !== commitId) {
blockers.push(blocker({
type: "contract_blocker",
scope: service.serviceId,
summary: `Tekton result for ${service.serviceId} was produced for ${artifact.sourceCommitId}, expected ${commitId}`,
next: "Discard stale PipelineRun results and rerun the current source commit."
}));
}
}
return { artifacts, blockers };
}
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) {
return {
version: "v2",
mode,
sourceCommitId: commitId,
imageTag: shortCommit,
registryTarget: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
digestPlaceholder: "not_published",
services: artifacts.map((artifact) => {
const image = artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit);
const digest = artifact.digest ?? "not_published";
return {
serviceId: artifact.serviceId,
enabled: artifact.publishEnabled,
required: artifact.artifactRequired,
artifactScope: artifact.artifactScope,
commitId: artifact.commitId ?? shortCommit,
sourceCommitId: artifact.sourceCommitId ?? commitId,
registryTarget: imageRepository(args.registryPrefix, artifact.serviceId),
image,
imageTag: artifact.imageTag ?? shortCommit,
buildCreatedAt: artifact.buildCreatedAt ?? null,
buildSource: artifact.buildSource ?? null,
digest,
repositoryDigest: artifact.repositoryDigest ?? null,
digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder",
status: artifact.status,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
componentCommitId: artifact.componentCommitId ?? null,
componentInputHash: artifact.componentInputHash ?? null,
dockerfileHash: artifact.dockerfileHash ?? null,
baseImageReference: artifact.baseImageReference ?? null,
baseImageDigest: artifact.baseImageDigest ?? null,
buildArgsHash: artifact.buildArgsHash ?? null,
ciAffected: artifact.ciAffected ?? null,
ciReason: artifact.ciReason ?? [],
reuse: artifact.reuse ?? null,
reusedFrom: artifact.reusedFrom ?? null,
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked)
};
})
};
}
function plannerFieldsFor(servicePlan) {
if (!servicePlan) return {};
return {
componentCommitId: servicePlan.componentCommitId ?? null,
componentInputHash: servicePlan.componentInputHash ?? null,
dockerfileHash: servicePlan.dockerfileHash ?? null,
baseImageReference: servicePlan.baseImageReference ?? null,
baseImageDigest: servicePlan.baseImageDigest ?? null,
buildArgsHash: servicePlan.buildArgsHash ?? null,
ciAffected: servicePlan.affected ?? false,
ciReason: servicePlan.reason ?? [],
reuse: servicePlan.reuse ?? null
};
}
function enrichServicesWithCiPlan(services, ciPlan) {
const byServiceId = new Map((ciPlan?.services ?? []).map((service) => [service.serviceId, service]));
return services.map((service) => ({
...service,
...plannerFieldsFor(byServiceId.get(service.serviceId))
}));
}
function ciPlanReportSummary(ciPlan) {
if (!ciPlan) return null;
return {
planVersion: ciPlan.planVersion,
sourceCommitId: ciPlan.sourceCommitId,
shortCommitId: ciPlan.shortCommitId,
baseRef: ciPlan.baseRef,
targetRef: ciPlan.targetRef,
compatibility: ciPlan.compatibility,
changedPathSummary: ciPlan.changedPathSummary,
imageBuildRequired: ciPlan.imageBuildRequired,
affectedServices: ciPlan.affectedServices,
reusedServices: ciPlan.reusedServices,
buildSkippedCount: ciPlan.buildSkippedCount,
ciCdPlan: ciPlan.ciCdPlan
};
}
function sumDurations(artifacts, field) {
return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0);
}
async function mapWithConcurrency(items, concurrency, worker) {
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.min(Math.max(concurrency, 1), items.length);
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await worker(items[index], index);
}
}));
return results;
}
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }) {
const status = reportStatus(mode, artifacts, blockers);
const fatalBlocked = hasFatalBlocker(blockers);
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
const serviceInventory = serviceInventoryFromServices(services);
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities });
const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length;
const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length;
const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length;
const environment = artifactEnvironment(args);
return {
$schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json",
$id: `https://hwlab.pikastech.local/${args.lane}-cicd/artifacts.json`,
reportVersion: "v1",
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
taskId: `${args.lane}-artifact-publish`,
commitId: shortCommit,
acceptanceLevel: `${environment}_artifact_publish`,
lane: args.lane,
devOnly: args.lane !== "v02",
prodDisabled: true,
reportLifecycle: activeReportLifecycle(`Current ${environment} artifact publish report; it is not runtime acceptance evidence.`),
sourceContract: {
status: fatalBlocked ? "blocked" : "pass",
documents: [
"docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md",
"docs/artifact-catalog.md",
"docs/reference/g14-gitops-cicd.md",
"docs/dev-base-image-preflight.md"
],
summary: `${environment} artifact publish evidence; runtime acceptance stays separate from artifact publication.`
},
validationCommands: [
"node --check scripts/artifact-publish.mjs",
"node --check scripts/g14-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/g14-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 ${environment} and namespace remains ${args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"}.`,
"PROD profile remains disabled.",
"No secret or token material is required by this workflow."
],
summary: fatalBlocked
? `Fatal preconditions blocked a full ${environment} artifact publish.`
: `Fatal ${environment} artifact preconditions passed.`
},
blockers: devGateBlockers(blockers),
artifactPublish: {
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
status,
mode,
lane: args.lane,
catalogPath: args.catalogPath,
imageTagMode: args.imageTagMode,
repo,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
buildBackend: args.buildBackend,
buildkitCommand: args.buildkitCommand,
buildkitAddr: args.buildkitAddr ?? null,
producer,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImage: args.baseImage ?? null,
selectedServiceIds: args.services,
affectedOnly: args.affectedOnly,
externalServiceReportDir: args.externalServiceReportDir,
externalServiceResultsEnv: args.externalServiceResultsEnv,
quietBuild: args.quietBuild,
concurrency: args.concurrency,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
environment,
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
serviceInventory,
ciPlan: ciPlanReportSummary(ciPlan),
publishPlan,
serviceCount: artifacts.length,
requiredServiceCount: serviceInventory.requiredServiceCount,
disabledServiceCount: serviceInventory.disabledServiceCount,
builtCount,
publishedCount,
reusedCount,
timings: {
buildkitBuildDurationMs: sumDurations(artifacts, "buildkitBuildDurationMs"),
cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"),
publishDurationMs: sumDurations(artifacts, "publishDurationMs")
},
generatedAt: new Date().toISOString(),
services: artifacts.map((artifact) => ({
serviceId: artifact.serviceId,
status: artifact.status,
commitId: artifact.commitId ?? shortCommit,
sourceCommitId: artifact.sourceCommitId ?? commitId,
image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit),
imageTag: artifact.imageTag ?? shortCommit,
buildCreatedAt: artifact.buildCreatedAt ?? null,
buildSource: artifact.buildSource ?? null,
digest: artifact.digest ?? "not_published",
repositoryDigest: artifact.repositoryDigest ?? null,
runtimeKind: artifact.runtimeKind,
distFreshness: artifact.distFreshness,
implementationState: artifact.implementationState,
sourceState: artifact.sourceState,
entrypoint: artifact.entrypoint,
publishEnabled: artifact.publishEnabled,
artifactRequired: artifact.artifactRequired,
artifactScope: artifact.artifactScope,
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked),
localImageId: artifact.localImageId,
buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null,
buildBackend: artifact.buildBackend ?? args.buildBackend,
buildkitCacheRef: artifact.buildkitCacheRef ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
componentCommitId: artifact.componentCommitId ?? null,
componentInputHash: artifact.componentInputHash ?? null,
dockerfileHash: artifact.dockerfileHash ?? null,
baseImageReference: artifact.baseImageReference ?? null,
baseImageDigest: artifact.baseImageDigest ?? null,
buildArgsHash: artifact.buildArgsHash ?? null,
ciAffected: artifact.ciAffected ?? null,
ciReason: artifact.ciReason ?? [],
reuse: artifact.reuse ?? null,
reusedFrom: artifact.reusedFrom ?? null,
logTail: artifact.logTail ?? null,
pushLogTail: artifact.pushLogTail ?? null
})),
blockers
},
notes: `${environment} artifact report. Do not treat runtime placeholder images as real service implementations.`
};
}
async function writeReport(relativePath, report) {
const absolutePath = ensureNotRepoReportsPath(repoRoot, relativePath, "--report");
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, `${JSON.stringify(report, null, 2)}\n`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
if (args.mode === "publish") {
const identityGuard = requireG14ArtifactPublishIdentity({
script: cliEntrypoint,
mode: "--publish"
});
if (identityGuard) {
console.log(JSON.stringify(identityGuard, null, 2));
process.exitCode = 2;
return;
}
}
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
const [baseImagePreflight, registryCapabilities] = await Promise.all([
buildkitBaseImagePreflight(args.baseImage),
probeRegistryCapabilities({
registryPrefix: args.registryPrefix
})
]);
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
}
const [deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(args.deployPath),
gitValue(["rev-parse", "HEAD"]),
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
]);
let catalog = await readJsonIfPresent(args.catalogPath, null);
const catalogWasMissing = !catalog;
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, commitId });
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`);
const shortCommit = imageTagForCommit(args, commitId);
let effectiveCatalogPath = args.catalogPath;
if (catalogWasMissing && args.lane === "v02") {
effectiveCatalogPath = path.join(os.tmpdir(), `hwlab-v02-artifact-catalog-${process.pid}.json`);
await writeFile(effectiveCatalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
}
const buildCreatedAt = new Date().toISOString();
const repo = repoLabelFromRemote(remoteUrl);
const producer = g14ArtifactProducer(process.env);
const ciPlan = await createG14CiPlan({
repoRoot,
targetRef: "HEAD",
deployJsonPath: args.deployPath,
artifactCatalogPath: effectiveCatalogPath,
registryPrefix: args.registryPrefix,
baseImage: args.baseImage ?? undefined,
...(args.servicesExplicit ? { services: args.services } : {})
});
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan);
const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service]));
const affectedServiceIds = new Set(ciPlan?.affectedServices ?? []);
const buildServiceIds = new Set(
services.filter((service) => service.artifactRequired && affectedServiceIds.has(service.serviceId)).map((service) => service.serviceId)
);
const preflightBuildServiceIds = args.externalServiceReportDir || args.externalServiceResultsEnv ? new Set() : buildServiceIds;
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds: preflightBuildServiceIds });
let artifacts = services.map((service) => artifactRecord({
args,
service,
commitId,
shortCommit,
status: service.artifactRequired ? "preflight_only" : "not_required"
}));
let externalArtifacts = new Map();
if (args.mode === "build" || args.mode === "publish") {
if (args.externalServiceReportDir && buildServiceIds.size > 0) {
const externalResult = await readExternalServiceArtifacts({
dir: args.externalServiceReportDir,
services: services.filter((service) => buildServiceIds.has(service.serviceId)),
commitId
});
externalArtifacts = externalResult.artifacts;
blockers.push(...externalResult.blockers);
}
if (args.externalServiceResultsEnv) {
const externalResult = await readExternalServiceArtifactsFromEnv({
services: services.filter((service) => service.artifactRequired),
commitId
});
externalArtifacts = new Map([...externalArtifacts, ...externalResult.artifacts]);
blockers.push(...externalResult.blockers);
}
artifacts = services.map((service) => {
if (externalArtifacts.has(service.serviceId)) {
return externalArtifacts.get(service.serviceId);
}
if (!service.artifactRequired || buildServiceIds.has(service.serviceId)) {
return artifactRecord({
args,
service,
commitId,
shortCommit,
status: service.artifactRequired ? "preflight_only" : "not_required"
});
}
const reused = artifactRecordForCatalogReuse({
service,
catalogService: catalogByServiceId.get(service.serviceId),
commitId
});
emit("artifact_reuse", {
serviceId: service.serviceId,
status: reused.status,
image: reused.image,
digest: reused.digest,
reusedFrom: reused.reusedFrom ?? null
});
if (reused.status.startsWith("blocked_")) {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `${service.serviceId} cannot be reused because ${args.catalogPath} has no verified digest`,
next: "Refresh the service catalog with a verified sha256 digest for this service, or create a service-scoped source change so the planner publishes it through the per-service BuildKit task."
}));
}
return reused;
});
}
if ((args.mode === "build" || args.mode === "publish") && hasFatalBlocker(blockers)) {
emit("publish_blocked", {
fatalBlockers: blockers.filter((item) => fatalBlockerTypes.has(item.type)).map((item) => item.scope)
});
} else if (args.mode === "build" || args.mode === "publish") {
const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact]));
await mapWithConcurrency(services.filter((item) => buildServiceIds.has(item.serviceId) && !externalArtifacts.has(item.serviceId)), args.concurrency, async (service) => {
emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) });
const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt });
artifactByServiceId.set(service.serviceId, artifact);
if (artifact.blocker) blockers.push(artifact.blocker);
emit("build_done", {
serviceId: service.serviceId,
status: artifact.status,
image: artifact.image,
buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null,
buildBackend: artifact.buildBackend ?? args.buildBackend,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null
});
});
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
}
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan });
if (args.tektonResultsDir) {
await writeTektonResultFiles(args.tektonResultsDir, report);
}
if (args.emitReport) {
await writeReport(args.reportPath, report);
}
console.log(JSON.stringify({
status: report.artifactPublish.status,
mode: args.mode,
reportPath: args.emitReport ? args.reportPath : null,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
selectedServiceIds: report.artifactPublish.selectedServiceIds,
affectedOnly: report.artifactPublish.affectedOnly,
ciPlan: report.artifactPublish.ciPlan,
concurrency: report.artifactPublish.concurrency,
builtCount: report.artifactPublish.builtCount,
publishedCount: report.artifactPublish.publishedCount,
reusedCount: report.artifactPublish.reusedCount,
timings: report.artifactPublish.timings,
publishPlan: report.artifactPublish.publishPlan,
services: report.artifactPublish.services.map((service) => ({
serviceId: service.serviceId,
status: service.status,
commitId: service.commitId,
sourceCommitId: service.sourceCommitId,
image: service.image,
imageTag: service.imageTag,
buildCreatedAt: service.buildCreatedAt,
digest: service.digest,
distFreshness: service.distFreshness,
buildkitBuildDurationMs: service.buildkitBuildDurationMs,
buildBackend: service.buildBackend,
buildkitCacheRef: service.buildkitCacheRef,
cloudWebBuildDurationMs: service.cloudWebBuildDurationMs,
publishDurationMs: service.publishDurationMs,
reusedFrom: service.reusedFrom,
logTail: service.logTail,
pushLogTail: service.pushLogTail,
notPublishedReason: service.notPublishedReason
})),
blockers
}, null, 2));
if (hasFatalBlocker(blockers)) {
process.exitCode = 2;
} else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(JSON.stringify({
status: "failed",
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exitCode = 1;
});