diff --git a/CI.json b/CI.json index 901f9eca..46a7b47f 100644 --- a/CI.json +++ b/CI.json @@ -7,6 +7,10 @@ "name": "contract-check", "run": "npm run check" }, + { + "name": "dev-artifact-publish-static-check", + "run": "node --check scripts/dev-artifact-publish.mjs" + }, { "name": "parse-deploy-json", "run": "node -e \"JSON.parse(require('node:fs').readFileSync('deploy/deploy.json','utf8'))\"" diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md new file mode 100644 index 00000000..a33c8b02 --- /dev/null +++ b/docs/dev-artifact-publish.md @@ -0,0 +1,83 @@ +# HWLAB DEV Artifact Publish + +`scripts/dev-artifact-publish.mjs` is the DEV-only build and publish path for +D601 local/internal registry artifacts. It does not deploy workloads, read +secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries. + +## Scope + +- Environment: `dev`. +- Namespace: `hwlab-dev`. +- Default registry prefix: `127.0.0.1:5000/hwlab`. +- Default base image: `node:22-bookworm-slim`, which must already exist in the + Docker daemon because the build runs with `--pull=false`. +- Image tag: first seven characters of the Git commit used as build source. +- Required image labels: + - `hwlab.pikastech.local/repo` + - `hwlab.pikastech.local/commit` + - `hwlab.pikastech.local/service-id` + - `hwlab.pikastech.local/environment=dev` + +## Commands + +Static check: + +```sh +node --check scripts/dev-artifact-publish.mjs +``` + +Preflight and report generation: + +```sh +node scripts/dev-artifact-publish.mjs --preflight +``` + +Build only: + +```sh +node scripts/dev-artifact-publish.mjs --build +``` + +Build and publish to the D601 local/internal registry: + +```sh +node scripts/dev-artifact-publish.mjs --publish +``` + +Use `--registry-prefix` only for another localhost/private/internal D601 +registry prefix. The script rejects third-party registry hosts and any prefix +that names PROD. + +## Report + +The script writes `reports/dev-gate/dev-artifacts.json`. The file is kept in the +existing DEV gate report envelope for compatibility with +`scripts/validate-dev-gate-report.mjs`, while the `artifactPublish` object is +the HWLAB#35-specific publish record. + +Each service record contains: + +- `serviceId` +- `image` +- `imageTag` +- `digest` +- `runtimeKind` +- `implementationState` +- `entrypoint` + +`digest` is only set to a registry digest after `docker push` succeeds and the +push output contains a digest. The script records blockers instead of claiming a +publish when build, push, base image, registry, contract, or safety checks fail. + +## Known Implementation States + +- `repo-entrypoint`: the repository has `cmd//main.mjs`. +- `static-web-wrapper`: the cloud web static files are packaged behind a small + health/static-file wrapper. +- `repo-bundle`: the image packages repository-owned skill artifacts. +- `library-only`: the repository has library code but no executable package bin. +- `missing-runtime-entrypoint`: the image is only a health placeholder and is + not a real service implementation. + +Runtime implementation blockers do not fake a service implementation. They are +recorded as the next minimum work needed after artifact publication. diff --git a/package.json b/package.json index 5aa3b0b3..7e59da53 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check scripts/validate-contract.mjs && node scripts/validate-contract.mjs && node --test internal/agent/index.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node scripts/validate-contract.mjs && node --test internal/agent/index.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health", "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run", "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", "web:check": "node web/hwlab-cloud-web/scripts/check.mjs", - "web:build": "node web/hwlab-cloud-web/scripts/build.mjs" + "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", + "dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight", + "dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish" } } diff --git a/reports/dev-gate/dev-artifacts.json b/reports/dev-gate/dev-artifacts.json new file mode 100644 index 00000000..bb36bf4c --- /dev/null +++ b/reports/dev-gate/dev-artifacts.json @@ -0,0 +1,282 @@ +{ + "$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": "1e8d009", + "acceptanceLevel": "dev_artifact_publish", + "devOnly": true, + "prodDisabled": true, + "sourceContract": { + "status": "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": "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": "Fatal DEV-only artifact preconditions passed." + }, + "blockers": [ + { + "type": "runtime_blocker", + "scope": "hwlab-router", + "status": "open", + "summary": "hwlab-router has no cmd/hwlab-router/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-tunnel-client", + "status": "open", + "summary": "hwlab-tunnel-client has no cmd/hwlab-tunnel-client/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-edge-proxy", + "status": "open", + "summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-cli", + "status": "open", + "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." + } + ], + "artifactPublish": { + "issue": "pikasTech/HWLAB#35", + "status": "blocked", + "mode": "publish", + "repo": "pikasTech/HWLAB", + "sourceCommitId": "1e8d009e9531d71cef0762998663b4e3731032c6", + "registryPrefix": "127.0.0.1:5000/hwlab", + "baseImage": "node:22-bookworm-slim", + "environment": "dev", + "serviceCount": 13, + "builtCount": 9, + "publishedCount": 9, + "generatedAt": "2026-05-21T16:39:23.804Z", + "services": [ + { + "serviceId": "hwlab-cloud-api", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:daa3e09996209242ae409f21802757cfab20fe80adfdcc729dc7144a0758a067", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:daa3e09996209242ae409f21802757cfab20fe80adfdcc729dc7144a0758a067", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-cloud-api/main.mjs", + "localImageId": "sha256:cfa8de38b9113365764a2a7dffb8732e7049d77ba83329eb5f7f29167dfa13e9" + }, + { + "serviceId": "hwlab-cloud-web", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:267de8af9ef4ff2bc60e197ca37f266ab49e75d49cce91909dda64715d47c0c8", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:267de8af9ef4ff2bc60e197ca37f266ab49e75d49cce91909dda64715d47c0c8", + "runtimeKind": "cloud-web", + "implementationState": "static-web-wrapper", + "entrypoint": "web/hwlab-cloud-web/index.html", + "localImageId": "sha256:d4bd2ef29fda976ad162092cd95197e130a7ade811aa11784b8396c0ef979884" + }, + { + "serviceId": "hwlab-agent-mgr", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:12510042c5ab96419b4eafa52314ad58e17e1d1c5a0fbebe4cb6cd50e8935a5a", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-mgr@sha256:12510042c5ab96419b4eafa52314ad58e17e1d1c5a0fbebe4cb6cd50e8935a5a", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-agent-mgr/main.mjs", + "localImageId": "sha256:58ac216ee9faee27e1bcb569d5ce36166d8344aab24c7e4ab69781ef4356e70d" + }, + { + "serviceId": "hwlab-agent-worker", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:1cbfd4840ca567539f5eab5df2e301b4c1e263555b1a1f160900996bc6895c07", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-worker@sha256:1cbfd4840ca567539f5eab5df2e301b4c1e263555b1a1f160900996bc6895c07", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-agent-worker/main.mjs", + "localImageId": "sha256:44fad5f75ce63c82e052827c4536be1c70b08a48d8962da78094a29a6822070e" + }, + { + "serviceId": "hwlab-gateway", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:b6e6213e03773539ab21691bdee973338e270cf639b1deccb8c6d904278a43d8", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-gateway@sha256:b6e6213e03773539ab21691bdee973338e270cf639b1deccb8c6d904278a43d8", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-gateway/main.mjs", + "localImageId": "sha256:ea0301dd293a71de84642b1d21682e8575219e08c221ccfb637bef7a1ce39c52" + }, + { + "serviceId": "hwlab-gateway-simu", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:8d54ea760cdad2a418660e7d6293ae493d86b2173db0ec0be00ad49d63bb7aaf", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-gateway-simu@sha256:8d54ea760cdad2a418660e7d6293ae493d86b2173db0ec0be00ad49d63bb7aaf", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-gateway-simu/main.mjs", + "localImageId": "sha256:38736ba8ca348d8c7527a94378b509be879ce338f36596be91f756c88a78ff96" + }, + { + "serviceId": "hwlab-box-simu", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:e18223e56d7b443dd0d5b3bccbd1ebc7964ef188c39d85750c1d82659634d861", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-box-simu@sha256:e18223e56d7b443dd0d5b3bccbd1ebc7964ef188c39d85750c1d82659634d861", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-box-simu/main.mjs", + "localImageId": "sha256:7e9db37e7ad184c8fea659675385dc5dc1078c29e4108cf9e605495699570c8c" + }, + { + "serviceId": "hwlab-patch-panel", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:2cbf98f534cd31f86df9d656f40fb883bf40582dae4a4b3615c8c0db187d85fc", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-patch-panel@sha256:2cbf98f534cd31f86df9d656f40fb883bf40582dae4a4b3615c8c0db187d85fc", + "runtimeKind": "node-command", + "implementationState": "repo-entrypoint", + "entrypoint": "cmd/hwlab-patch-panel/main.mjs", + "localImageId": "sha256:b7d11cf5683a3fc1a54029ecfd13bbf05353a33b2038ffa8a968f596c54bc864" + }, + { + "serviceId": "hwlab-router", + "status": "blocked_missing_runtime", + "image": "127.0.0.1:5000/hwlab/hwlab-router:1e8d009", + "imageTag": "1e8d009", + "digest": "not_published", + "repositoryDigest": null, + "runtimeKind": "health-placeholder", + "implementationState": "missing-runtime-entrypoint", + "entrypoint": null + }, + { + "serviceId": "hwlab-tunnel-client", + "status": "blocked_missing_runtime", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:1e8d009", + "imageTag": "1e8d009", + "digest": "not_published", + "repositoryDigest": null, + "runtimeKind": "health-placeholder", + "implementationState": "missing-runtime-entrypoint", + "entrypoint": null + }, + { + "serviceId": "hwlab-edge-proxy", + "status": "blocked_missing_runtime", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:1e8d009", + "imageTag": "1e8d009", + "digest": "not_published", + "repositoryDigest": null, + "runtimeKind": "health-placeholder", + "implementationState": "missing-runtime-entrypoint", + "entrypoint": null + }, + { + "serviceId": "hwlab-cli", + "status": "blocked_missing_runtime", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:1e8d009", + "imageTag": "1e8d009", + "digest": "not_published", + "repositoryDigest": null, + "runtimeKind": "cli", + "implementationState": "library-only", + "entrypoint": "tools/hwlab-cli/lib/cli.mjs" + }, + { + "serviceId": "hwlab-agent-skills", + "status": "published", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:1e8d009", + "imageTag": "1e8d009", + "digest": "sha256:46c3c8e24c3f696931599e40c21c66de98f1caf431a992f0a02e19dde67529b2", + "repositoryDigest": "127.0.0.1:5000/hwlab/hwlab-agent-skills@sha256:46c3c8e24c3f696931599e40c21c66de98f1caf431a992f0a02e19dde67529b2", + "runtimeKind": "skills-bundle", + "implementationState": "repo-bundle", + "entrypoint": "skills/hwlab-agent-runtime/SKILL.md", + "localImageId": "sha256:33d64850cc7dd4b86ca136ba7b8d6ff79cdffab1d30f2c90cd16693976739050" + } + ], + "blockers": [ + { + "type": "runtime_blocker", + "scope": "hwlab-router", + "status": "open", + "summary": "hwlab-router has no cmd/hwlab-router/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", + "next": "Add cmd/hwlab-router/main.mjs or a dedicated Dockerfile for hwlab-router." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-tunnel-client", + "status": "open", + "summary": "hwlab-tunnel-client has no cmd/hwlab-tunnel-client/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", + "next": "Add cmd/hwlab-tunnel-client/main.mjs or a dedicated Dockerfile for hwlab-tunnel-client." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-edge-proxy", + "status": "open", + "summary": "hwlab-edge-proxy has no cmd/hwlab-edge-proxy/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.", + "next": "Add cmd/hwlab-edge-proxy/main.mjs or a dedicated Dockerfile for hwlab-edge-proxy." + }, + { + "type": "runtime_blocker", + "scope": "hwlab-cli", + "status": "open", + "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." + } + ] + }, + "notes": "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations." +} diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs new file mode 100644 index 00000000..16a0d4d3 --- /dev/null +++ b/scripts/dev-artifact-publish.mjs @@ -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; +});