diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index 2768c691..0ad8f12b 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -29,6 +29,8 @@ skeleton files agree with each other and do not drift into PROD. | `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. | | `CI.json` | Lightweight command and forbidden-action skeleton. | | `scripts/validate-artifact-catalog.mjs` | Local static validator for the catalog, deploy manifest, and CI forbidden list. | +| `scripts/preflight-dev-base-image.mjs` | Local DEV builder base-image preflight for future artifact publish. | +| `docs/dev-base-image-preflight.md` | Operator contract for interpreting preflight readiness and blockers. | ## Pin Rules @@ -64,12 +66,30 @@ publish result that includes registry digests and provenance. That publish result may be compared with this contract, but it must not be inferred from this contract. +## DEV Builder Preflight + +Before a future DEV artifact publish attempts to build or publish service +images, run: + +```sh +npm run --silent dev-base-image:preflight +``` + +The preflight is local-only and checks for an approved builder base image through +`HWLAB_DEV_BASE_IMAGE`, a local `node:20-*` Docker image, or an explicit local +HWLAB dev base tag such as `hwlab-dev-base:`. It prints the image source, +local tag, image ID, publish usability, blockers, and next steps. A blocked +result means artifact publish must stop and must not be reported as a successful +publish. + ## Validation Run the local static validator: ```sh node --check scripts/validate-artifact-catalog.mjs +node --check scripts/preflight-dev-base-image.mjs +node --check scripts/src/dev-base-image-preflight.mjs node scripts/validate-artifact-catalog.mjs ``` diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index a33c8b02..239ed66a 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -9,8 +9,9 @@ secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries. - 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`. +- Base image: resolved by `scripts/preflight-dev-base-image.mjs` from + `HWLAB_DEV_BASE_IMAGE` or a local `node:20-*` Docker image. There is no + network pull or third-party fallback; 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` @@ -23,9 +24,17 @@ secrets, enable PROD, or push to GHCR/Docker Hub/other third-party registries. Static check: ```sh +node --check scripts/preflight-dev-base-image.mjs +node --check scripts/src/dev-base-image-preflight.mjs node --check scripts/dev-artifact-publish.mjs ``` +Base image preflight: + +```sh +node scripts/preflight-dev-base-image.mjs +``` + Preflight and report generation: ```sh @@ -46,7 +55,8 @@ 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. +that names PROD. Use `--base-image` only to override `HWLAB_DEV_BASE_IMAGE` with +an approved local image for that run. ## Report @@ -69,6 +79,11 @@ Each service record contains: 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. +The report also includes `artifactPublish.baseImagePreflight` with the image +source, local tag, local image ID, `publishUsable` gate, blockers, and next +steps. When that preflight is blocked, artifact publish is blocked and no build +or push is attempted. + ## Known Implementation States - `repo-entrypoint`: the repository has `cmd//main.mjs`. diff --git a/docs/dev-base-image-preflight.md b/docs/dev-base-image-preflight.md new file mode 100644 index 00000000..1eb6c778 --- /dev/null +++ b/docs/dev-base-image-preflight.md @@ -0,0 +1,82 @@ +# HWLAB DEV Base Image Preflight + +This preflight is the lightweight builder gate for DEV artifact publish work. It +checks whether the D601 builder has an approved local base image before any +artifact publish attempt claims readiness. + +The command is local-only. It does not pull, build, push, deploy, contact DEV or +PROD, read secrets, or use UniDesk runtime services as substitutes. + +## Current Repository Constraints + +- No repository Dockerfile is present in the DEV skeleton. +- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` pin service image + names to `ghcr.io/pikastech/:`. +- The artifact catalog is `contract-skeleton` only. Service entries must keep + `publishState: "skeleton-only"` and `digest: "not_published"` until real + publish evidence exists. +- The catalog and runtime boundary forbid PROD deployment, secret reads, force + push, real publish claims, and UniDesk runtime substitution. +- A builder base image is separate from HWLAB service artifact images. This + preflight only decides whether #35 may proceed to build/publish; it does not + prove that any service image was published. + +## Command + +```sh +npm run --silent dev-base-image:preflight +``` + +Equivalent direct command: + +```sh +node scripts/preflight-dev-base-image.mjs +``` + +The direct command always prints JSON. Use `npm run --silent` when npm is the +entry point and the output needs to be machine parsed. Exit code `0` means +`status: "ready"` and the base image can be used by #35 DEV artifact publish. +Exit code `2` means `status: "blocked"` and artifact publish must stop. + +## Image Selection + +Selection order is intentionally narrow: + +1. If `HWLAB_DEV_BASE_IMAGE` is set, it must name an approved image that already + exists in the local Docker image cache. +2. If `HWLAB_DEV_BASE_IMAGE` is not set, the preflight looks for local + `node:20-*` images and selects the first deterministic local tag. +3. If neither source is available, the result is a blocker. + +Explicit base image values must match local `node:20-*`, `hwlab-dev-base:*`, or +`hwlab-node20-base:*` references. They must not reference UniDesk, Code Queue, +`backend-core`, `provider-gateway`, `microservice-proxy`, or an HWLAB runtime +service image. A service image such as +`ghcr.io/pikastech/hwlab-cloud-api:` is an artifact output, not a builder +base image. + +## Output Contract + +Important fields: + +| Field | Meaning | +| --- | --- | +| `status` | `ready` or `blocked`. | +| `imageSource` | `env:HWLAB_DEV_BASE_IMAGE`, `local-docker-node:20-*`, or `none`. | +| `requestedImage` | The explicit environment value, when present. | +| `localTag` | Local Docker tag selected for the builder base image. | +| `imageId` | Local Docker image ID from `docker image inspect`. | +| `publishUsable` | Boolean gate for #35 artifact publish. | +| `blockers` | Deterministic reasons artifact publish must stop. | +| `nextSteps` | Operational fixes that do not require secret reads or runtime substitution. | + +## Blocker Handling + +When blocked, preload or build an approved DEV builder base image into the D601 +Docker cache, set `HWLAB_DEV_BASE_IMAGE` to the exact local tag if it is not a +`node:20-*` tag, and rerun the preflight. Do not claim artifact publish success +until the preflight returns `status: "ready"`. + +The preflight may use UniDesk and Code Queue as scheduling infrastructure only. +It must not use their runtime images as HWLAB service images or builder base +image substitutes. diff --git a/package.json b/package.json index 11c15964..adf262d4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.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 internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", "dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check", diff --git a/reports/dev-gate/dev-artifacts.json b/reports/dev-gate/dev-artifacts.json index bb36bf4c..fb4c53c6 100644 --- a/reports/dev-gate/dev-artifacts.json +++ b/reports/dev-gate/dev-artifacts.json @@ -4,22 +4,26 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#31", "taskId": "dev-artifact-publish", - "commitId": "1e8d009", + "commitId": "ad903a7", "acceptanceLevel": "dev_artifact_publish", "devOnly": true, "prodDisabled": true, "sourceContract": { - "status": "pass", + "status": "blocked", "documents": [ "docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md", "docs/artifact-catalog.md", - "docs/dev-artifact-publish.md" + "docs/dev-artifact-publish.md", + "docs/dev-base-image-preflight.md" ], "summary": "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope." }, "validationCommands": [ "node --check scripts/dev-artifact-publish.mjs", + "node --check scripts/preflight-dev-base-image.mjs", + "node --check scripts/src/dev-base-image-preflight.mjs", + "node scripts/preflight-dev-base-image.mjs", "node scripts/dev-artifact-publish.mjs --preflight --no-report", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" @@ -45,16 +49,23 @@ "summary": "DEV deploy dry-run is not implied by artifact publication." }, "devPreconditions": { - "status": "pass", + "status": "blocked", "requirements": [ "Registry prefix is localhost or private/internal only.", + "DEV builder base image preflight returns ready before build or publish.", "Environment remains dev and namespace remains hwlab-dev.", "PROD profile remains disabled.", "No secret or token material is required by this workflow." ], - "summary": "Fatal DEV-only artifact preconditions passed." + "summary": "Fatal preconditions blocked a full DEV artifact publish." }, "blockers": [ + { + "type": "environment_blocker", + "scope": "base-image", + "status": "open", + "summary": "HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache." + }, { "type": "runtime_blocker", "scope": "hwlab-router", @@ -72,129 +83,139 @@ "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", + "mode": "preflight", "repo": "pikasTech/HWLAB", - "sourceCommitId": "1e8d009e9531d71cef0762998663b4e3731032c6", + "sourceCommitId": "ad903a76e93bc8fab5c95138df15bfcb852bc339", "registryPrefix": "127.0.0.1:5000/hwlab", - "baseImage": "node:22-bookworm-slim", + "baseImage": null, + "baseImagePreflight": { + "status": "blocked", + "imageSource": "none", + "requestedImage": null, + "localTag": null, + "imageId": null, + "repoDigests": [], + "publishUsable": false, + "blockers": [ + "HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache." + ], + "nextSteps": [ + "Set HWLAB_DEV_BASE_IMAGE to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim.", + "Rerun this preflight and require status=ready before #35 artifact publish.", + "Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes." + ], + "containerEngine": { + "command": "docker", + "available": true, + "version": "Docker version 26.1.5+dfsg1, build a72d7cd", + "errorCode": null, + "stderr": "" + } + }, "environment": "dev", "serviceCount": 13, - "builtCount": 9, - "publishedCount": 9, - "generatedAt": "2026-05-21T16:39:23.804Z", + "builtCount": 0, + "publishedCount": 0, + "generatedAt": "2026-05-21T16:53:05.962Z", "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-cloud-api/main.mjs", - "localImageId": "sha256:cfa8de38b9113365764a2a7dffb8732e7049d77ba83329eb5f7f29167dfa13e9" + "entrypoint": "cmd/hwlab-cloud-api/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "cloud-web", "implementationState": "static-web-wrapper", - "entrypoint": "web/hwlab-cloud-web/index.html", - "localImageId": "sha256:d4bd2ef29fda976ad162092cd95197e130a7ade811aa11784b8396c0ef979884" + "entrypoint": "web/hwlab-cloud-web/index.html" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-agent-mgr/main.mjs", - "localImageId": "sha256:58ac216ee9faee27e1bcb569d5ce36166d8344aab24c7e4ab69781ef4356e70d" + "entrypoint": "cmd/hwlab-agent-mgr/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-agent-worker/main.mjs", - "localImageId": "sha256:44fad5f75ce63c82e052827c4536be1c70b08a48d8962da78094a29a6822070e" + "entrypoint": "cmd/hwlab-agent-worker/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-gateway/main.mjs", - "localImageId": "sha256:ea0301dd293a71de84642b1d21682e8575219e08c221ccfb637bef7a1ce39c52" + "entrypoint": "cmd/hwlab-gateway/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-gateway-simu/main.mjs", - "localImageId": "sha256:38736ba8ca348d8c7527a94378b509be879ce338f36596be91f756c88a78ff96" + "entrypoint": "cmd/hwlab-gateway-simu/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-box-simu/main.mjs", - "localImageId": "sha256:7e9db37e7ad184c8fea659675385dc5dc1078c29e4108cf9e605495699570c8c" + "entrypoint": "cmd/hwlab-box-simu/main.mjs" }, { "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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "node-command", "implementationState": "repo-entrypoint", - "entrypoint": "cmd/hwlab-patch-panel/main.mjs", - "localImageId": "sha256:b7d11cf5683a3fc1a54029ecfd13bbf05353a33b2038ffa8a968f596c54bc864" + "entrypoint": "cmd/hwlab-patch-panel/main.mjs" }, { "serviceId": "hwlab-router", - "status": "blocked_missing_runtime", - "image": "127.0.0.1:5000/hwlab/hwlab-router:1e8d009", - "imageTag": "1e8d009", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-router:ad903a7", + "imageTag": "ad903a7", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "health-placeholder", @@ -203,9 +224,9 @@ }, { "serviceId": "hwlab-tunnel-client", - "status": "blocked_missing_runtime", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:1e8d009", - "imageTag": "1e8d009", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:ad903a7", + "imageTag": "ad903a7", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "health-placeholder", @@ -214,9 +235,9 @@ }, { "serviceId": "hwlab-edge-proxy", - "status": "blocked_missing_runtime", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:1e8d009", - "imageTag": "1e8d009", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:ad903a7", + "imageTag": "ad903a7", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "health-placeholder", @@ -225,29 +246,35 @@ }, { "serviceId": "hwlab-cli", - "status": "blocked_missing_runtime", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:1e8d009", - "imageTag": "1e8d009", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:ad903a7", + "imageTag": "ad903a7", "digest": "not_published", "repositoryDigest": null, "runtimeKind": "cli", - "implementationState": "library-only", - "entrypoint": "tools/hwlab-cli/lib/cli.mjs" + "implementationState": "repo-entrypoint", + "entrypoint": "tools/hwlab-cli/bin/hwlab-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", + "status": "preflight_only", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:ad903a7", + "imageTag": "ad903a7", + "digest": "not_published", + "repositoryDigest": null, "runtimeKind": "skills-bundle", "implementationState": "repo-bundle", - "entrypoint": "skills/hwlab-agent-runtime/SKILL.md", - "localImageId": "sha256:33d64850cc7dd4b86ca136ba7b8d6ff79cdffab1d30f2c90cd16693976739050" + "entrypoint": "skills/hwlab-agent-runtime/SKILL.md" } ], "blockers": [ + { + "type": "environment_blocker", + "scope": "base-image", + "status": "open", + "summary": "HWLAB_DEV_BASE_IMAGE is not set and no local node:20-* image was found in the Docker image cache.", + "next": "Set HWLAB_DEV_BASE_IMAGE to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim. Rerun this preflight and require status=ready before #35 artifact publish. Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes." + }, { "type": "runtime_blocker", "scope": "hwlab-router", @@ -268,13 +295,6 @@ "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." } ] }, diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 16a0d4d3..ed886e69 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -12,11 +12,12 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; +import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; -const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || "node:22-bookworm-slim"; +const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null; const defaultReportPath = "reports/dev-gate/dev-artifacts.json"; const catalogPath = "deploy/artifact-catalog.dev.json"; const deployPath = "deploy/deploy.json"; @@ -93,7 +94,7 @@ function printHelp() { "", "options:", " --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab", - " --base-image IMAGE default: node:22-bookworm-slim", + " --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local", " --services LIST comma-separated service IDs; default: all frozen DEV services", " --report PATH default: reports/dev-gate/dev-artifacts.json", " --no-report print JSON without updating the report file" @@ -127,6 +128,44 @@ function blocker({ type, scope, summary, next }) { }; } +function preflightEnvForBaseImage(baseImage) { + if (!baseImage) { + return process.env; + } + return { + ...process.env, + HWLAB_DEV_BASE_IMAGE: baseImage + }; +} + +function summarizeBaseImagePreflight(result) { + return { + status: result.status, + imageSource: result.imageSource, + requestedImage: result.requestedImage, + localTag: result.localTag, + imageId: result.imageId, + repoDigests: result.repoDigests, + publishUsable: result.publishUsable, + blockers: result.blockers, + nextSteps: result.nextSteps, + containerEngine: result.containerEngine + }; +} + +function baseImagePreflightBlocker(result) { + return blocker({ + type: "environment_blocker", + scope: "base-image", + summary: result.blockers.length + ? result.blockers.join("; ") + : "DEV builder base image preflight did not return ready.", + next: result.nextSteps.length + ? result.nextSteps.join(" ") + : "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish." + }); +} + function commandLine(command, args) { return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); } @@ -460,10 +499,16 @@ function tailText(value, maxLength = 2500) { return text.slice(text.length - maxLength); } -async function preflight({ args, services, catalog, deployManifest }) { +async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) { const blockers = []; let registryPrefix = args.registryPrefix; + if (baseImagePreflight.publishUsable) { + args.baseImage = baseImagePreflight.localTag; + } else { + blockers.push(baseImagePreflightBlocker(baseImagePreflight)); + } + try { registryPrefix = validateRegistryPrefix(args.registryPrefix); } catch (error) { @@ -490,7 +535,8 @@ async function preflight({ args, services, catalog, deployManifest }) { ); } - for (const tag of [args.baseImage.split(":").at(-1), args.services.length === 1 ? args.services[0] : ""]) { + 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({ @@ -503,29 +549,7 @@ async function preflight({ args, services, catalog, deployManifest }) { } } - 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 (baseImagePreflight.containerEngine?.available) { if (registryPrefix.startsWith("127.0.0.1:5000/") || registryPrefix.startsWith("localhost:5000/")) { const ps = await run("docker", ["ps", "--format", "{{json .}}"]); if (ps.code !== 0 || !dockerPsShowsRegistry5000(ps.stdout)) { @@ -755,7 +779,7 @@ function devGateBlockers(blockers) { return result; } -function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers }) { +function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers, baseImagePreflight }) { const status = reportStatus(mode, artifacts, blockers); const fatalBlocked = hasFatalBlocker(blockers); const publishedCount = artifacts.filter((artifact) => artifact.status === "published").length; @@ -777,12 +801,16 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc "docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md", "docs/artifact-catalog.md", - "docs/dev-artifact-publish.md" + "docs/dev-artifact-publish.md", + "docs/dev-base-image-preflight.md" ], summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope." }, validationCommands: [ "node --check scripts/dev-artifact-publish.mjs", + "node --check scripts/preflight-dev-base-image.mjs", + "node --check scripts/src/dev-base-image-preflight.mjs", + "node scripts/preflight-dev-base-image.mjs", "node scripts/dev-artifact-publish.mjs --preflight --no-report", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" @@ -811,6 +839,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc status: fatalBlocked ? "blocked" : "pass", requirements: [ "Registry prefix is localhost or private/internal only.", + "DEV builder base image preflight returns ready before build or publish.", "Environment remains dev and namespace remains hwlab-dev.", "PROD profile remains disabled.", "No secret or token material is required by this workflow." @@ -827,7 +856,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc repo, sourceCommitId: commitId, registryPrefix: args.registryPrefix, - baseImage: args.baseImage, + baseImage: args.baseImage ?? null, + baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), environment: ENVIRONMENT_DEV, serviceCount: artifacts.length, builtCount, @@ -865,6 +895,12 @@ async function main() { } args.registryPrefix = validateRegistryPrefix(args.registryPrefix); + const baseImagePreflight = await runDevBaseImagePreflight({ + env: preflightEnvForBaseImage(args.baseImage) + }); + if (baseImagePreflight.publishUsable) { + args.baseImage = baseImagePreflight.localTag; + } const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([ readJson(catalogPath), readJson(deployPath), @@ -875,7 +911,7 @@ async function main() { const repo = repoLabelFromRemote(remoteUrl); const services = await resolveServices(args.services); - let blockers = await preflight({ args, services, catalog, deployManifest }); + let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight }); let artifacts = services.map((service) => ({ ...service, status: "preflight_only", @@ -912,7 +948,7 @@ async function main() { } } - const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers }); + const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers, baseImagePreflight }); if (args.emitReport) { await writeReport(args.reportPath, report); } @@ -923,6 +959,7 @@ async function main() { reportPath: args.emitReport ? args.reportPath : null, sourceCommitId: commitId, registryPrefix: args.registryPrefix, + baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), services: report.artifactPublish.services.map((service) => ({ serviceId: service.serviceId, status: service.status, @@ -932,7 +969,9 @@ async function main() { blockers }, null, 2)); - if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) { + if (hasFatalBlocker(blockers)) { + process.exitCode = 2; + } else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) { process.exitCode = 1; } } diff --git a/scripts/preflight-dev-base-image.mjs b/scripts/preflight-dev-base-image.mjs new file mode 100644 index 00000000..6190a758 --- /dev/null +++ b/scripts/preflight-dev-base-image.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import { + exitCodeForPreflight, + formatPreflightResult, + runDevBaseImagePreflight, + unexpectedPreflightErrorResult +} from "./src/dev-base-image-preflight.mjs"; + +try { + const result = await runDevBaseImagePreflight({ env: process.env }); + console.log(formatPreflightResult(result)); + process.exitCode = exitCodeForPreflight(result); +} catch (error) { + const result = unexpectedPreflightErrorResult(error); + console.log(formatPreflightResult(result)); + process.exitCode = exitCodeForPreflight(result); +} diff --git a/scripts/src/dev-base-image-preflight.mjs b/scripts/src/dev-base-image-preflight.mjs new file mode 100644 index 00000000..09595953 --- /dev/null +++ b/scripts/src/dev-base-image-preflight.mjs @@ -0,0 +1,471 @@ +import { spawnSync } from "node:child_process"; + +import { ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; + +const envVarName = "HWLAB_DEV_BASE_IMAGE"; +const dockerCommand = "docker"; +const dockerTimeoutMs = 15_000; +const node20TagPattern = /^20-/u; +const forbiddenImageFragments = Object.freeze([ + "unidesk", + "backend-core", + "provider-gateway", + "microservice-proxy", + "code-queue" +]); +const allowedExplicitBaseImages = Object.freeze([ + "node:20-*", + "hwlab-dev-base:*", + "*/hwlab-dev-base:*", + "hwlab-node20-base:*", + "*/hwlab-node20-base:*" +]); + +function runDocker(args) { + const result = spawnSync(dockerCommand, args, { + encoding: "utf8", + timeout: dockerTimeoutMs + }); + + return { + ok: result.status === 0, + status: result.status, + signal: result.signal, + errorCode: result.error?.code ?? null, + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + timedOut: result.error?.code === "ETIMEDOUT" + }; +} + +function splitJsonLines(stdout) { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function candidateTag(image) { + return `${image.Repository}:${image.Tag}`; +} + +function imageRefFromInspect(requestedImage, inspectDoc) { + const repoTags = Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []; + if (repoTags.includes(requestedImage)) { + return requestedImage; + } + return repoTags.find((tag) => !tag.includes("")) ?? requestedImage; +} + +function isNode20BaseRef(imageRef) { + const lowerRef = imageRef.toLowerCase(); + return lowerRef.startsWith("node:20-"); +} + +function isHwlabDevBaseRef(imageRef) { + const lowerRef = imageRef.toLowerCase(); + return /(^|\/)(hwlab-dev-base|hwlab-node20-base)(?=[:@]|$)/u.test(lowerRef); +} + +function approvalForBaseRef(imageRef) { + if (isNode20BaseRef(imageRef)) { + return { + approved: true, + reason: "local Node 20 builder base image" + }; + } + if (isHwlabDevBaseRef(imageRef)) { + return { + approved: true, + reason: "explicit HWLAB DEV builder base image" + }; + } + return { + approved: false, + reason: `image reference is not one of the approved builder base patterns: ${allowedExplicitBaseImages.join(", ")}` + }; +} + +function forbiddenReasons(imageRef) { + const lowerRef = imageRef.toLowerCase(); + const reasons = []; + + for (const fragment of forbiddenImageFragments) { + if (lowerRef.includes(fragment)) { + reasons.push(`image reference contains forbidden runtime/build substitute fragment: ${fragment}`); + } + } + + for (const serviceId of SERVICE_IDS) { + if ( + lowerRef.includes(`/pikastech/${serviceId}:`) || + lowerRef.includes(`/pikastech/${serviceId}@`) || + lowerRef.startsWith(`${serviceId}:`) || + lowerRef.startsWith(`${serviceId}@`) + ) { + reasons.push(`image reference points at HWLAB service artifact ${serviceId}, not a builder base image`); + } + } + + return reasons; +} + +function inspectEvidenceStrings(inspectDoc) { + const labels = inspectDoc.Config?.Labels ?? {}; + const env = Array.isArray(inspectDoc.Config?.Env) ? inspectDoc.Config.Env : []; + return [ + ...(Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []), + ...(Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : []), + ...Object.entries(labels).map(([key, value]) => `${key}=${value}`), + ...env + ].filter((value) => typeof value === "string" && value.length > 0); +} + +function inspectEvidenceBlockers(inspectDoc) { + const blockers = []; + for (const evidence of inspectEvidenceStrings(inspectDoc)) { + for (const reason of forbiddenReasons(evidence)) { + blockers.push(`${reason} in image inspect evidence ${JSON.stringify(evidence)}`); + } + } + return [...new Set(blockers)]; +} + +function baseResult(overrides) { + return { + preflight: "hwlab-dev-base-image", + environment: ENVIRONMENT_DEV, + status: "blocked", + publishUsable: false, + artifactPublish: { + issue: "#35", + usable: false, + reason: "No approved local DEV builder base image was selected." + }, + imageSource: "none", + requestedImage: null, + localTag: null, + imageId: null, + repoDigests: [], + candidates: [], + blockers: [], + nextSteps: [], + constraints: { + localOnly: true, + doesNotPull: true, + doesNotBuild: true, + doesNotPush: true, + doesNotDeploy: true, + doesNotReadSecrets: true, + allowedExplicitBaseImages, + forbiddenImageFragments, + forbiddenHwlabServiceImages: SERVICE_IDS + }, + ...overrides + }; +} + +function blocked(overrides) { + return baseResult({ + status: "blocked", + publishUsable: false, + artifactPublish: { + issue: "#35", + usable: false, + reason: "DEV artifact publish must stop until the blocker is resolved." + }, + ...overrides + }); +} + +function ready({ imageSource, approval, requestedImage, localTag, inspectDoc, candidates }) { + return baseResult({ + status: "ready", + publishUsable: true, + artifactPublish: { + issue: "#35", + usable: true, + reason: "A local approved DEV builder base image is available." + }, + imageSource, + approval, + requestedImage, + localTag, + imageId: inspectDoc.Id ?? null, + repoDigests: Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : [], + candidates, + blockers: [], + nextSteps: [ + "Use the reported localTag as the DEV builder base image for artifact publish.", + "Keep publish DEV-only and record the produced HWLAB service image tags and digests separately." + ] + }); +} + +function inspectLocalImage(imageRef) { + const inspect = runDocker(["image", "inspect", imageRef]); + if (!inspect.ok) { + return { + ok: false, + inspect, + docs: [] + }; + } + + return { + ok: true, + inspect, + docs: JSON.parse(inspect.stdout) + }; +} + +function dockerUnavailableResult(versionResult) { + const reason = versionResult.timedOut + ? "Docker CLI timed out during version detection." + : "Docker CLI is not available in this runner."; + return blocked({ + blockers: [reason], + nextSteps: [ + "Run this preflight in the D601 builder environment with Docker available.", + `Preload an approved local Node 20 builder image and set ${envVarName}= when using a non-node:20-* tag.`, + "Do not use a UniDesk, Code Queue, backend-core, provider-gateway, or microservice-proxy runtime image as a substitute." + ], + containerEngine: { + command: dockerCommand, + available: false, + version: null, + errorCode: versionResult.errorCode, + stderr: versionResult.stderr + } + }); +} + +function selectNode20Candidate(images) { + const candidates = images + .filter((image) => image.Repository === "node" && node20TagPattern.test(image.Tag)) + .map((image) => ({ + source: "local-docker-node:20-*", + approval: approvalForBaseRef(candidateTag(image)), + localTag: candidateTag(image), + imageId: image.ID ?? null, + size: image.Size ?? null, + createdSince: image.CreatedSince ?? null + })) + .sort((left, right) => left.localTag.localeCompare(right.localTag)); + + return { + candidates, + selected: candidates[0] ?? null + }; +} + +function explicitImageBlockers(requestedImage, localTag) { + const approval = approvalForBaseRef(localTag); + const requestedApproval = approvalForBaseRef(requestedImage); + if (approval.approved || requestedApproval.approved) { + return { + approval: approval.approved ? approval : requestedApproval, + blockers: [] + }; + } + + return { + approval, + blockers: [approval.reason] + }; +} + +function readyFromExplicitImage({ requestedImage, inspectDoc, containerEngine }) { + const localTag = imageRefFromInspect(requestedImage, inspectDoc); + const localTagBlockers = forbiddenReasons(localTag); + const evidenceBlockers = inspectEvidenceBlockers(inspectDoc); + const localTagApproval = explicitImageBlockers(requestedImage, localTag); + const blockers = [...localTagBlockers, ...evidenceBlockers, ...localTagApproval.blockers]; + if (blockers.length > 0) { + return blocked({ + imageSource: `env:${envVarName}`, + requestedImage, + localTag, + imageId: inspectDoc.Id ?? null, + blockers, + nextSteps: [ + `Set ${envVarName} to an approved local builder base image, not a runtime service image.`, + "Allowed references are local node:20-* tags or HWLAB dev base tags such as hwlab-dev-base:.", + "Do not continue #35 artifact publish with this image." + ], + containerEngine + }); + } + + return { + ...ready({ + imageSource: `env:${envVarName}`, + approval: localTagApproval.approval, + requestedImage, + localTag, + inspectDoc, + candidates: [ + { + source: `env:${envVarName}`, + approval: localTagApproval.approval, + localTag, + imageId: inspectDoc.Id ?? null + } + ] + }), + containerEngine + }; +} + +export async function runDevBaseImagePreflight({ env = process.env } = {}) { + const requestedImage = env[envVarName]?.trim() || null; + const version = runDocker(["--version"]); + if (!version.ok) { + return dockerUnavailableResult(version); + } + + const containerEngine = { + command: dockerCommand, + available: true, + version: version.stdout, + errorCode: null, + stderr: "" + }; + + if (requestedImage) { + const refBlockers = forbiddenReasons(requestedImage); + const requestedApproval = approvalForBaseRef(requestedImage); + if (refBlockers.length > 0 || !requestedApproval.approved) { + return blocked({ + imageSource: `env:${envVarName}`, + requestedImage, + blockers: [ + ...refBlockers, + ...(requestedApproval.approved ? [] : [requestedApproval.reason]) + ], + nextSteps: [ + `Set ${envVarName} to an approved local builder base image, not a runtime service image.`, + "Allowed references are local node:20-* tags or HWLAB dev base tags such as hwlab-dev-base:.", + "Do not continue #35 artifact publish with this image." + ], + containerEngine + }); + } + + const inspected = inspectLocalImage(requestedImage); + if (!inspected.ok || inspected.docs.length === 0) { + return blocked({ + imageSource: `env:${envVarName}`, + requestedImage, + blockers: [`${envVarName} is set to ${requestedImage}, but that image is not present in the local Docker image cache.`], + nextSteps: [ + "Preload or build the approved DEV builder base image locally before artifact publish.", + `Keep ${envVarName} pointed at the exact local tag once the image is available.`, + "Do not pull from third-party hosting or substitute a UniDesk runtime image during this preflight." + ], + containerEngine + }); + } + + return readyFromExplicitImage({ + requestedImage, + inspectDoc: inspected.docs[0], + containerEngine + }); + } + + const imageList = runDocker(["image", "ls", "--format", "{{json .}}"]); + if (!imageList.ok) { + return blocked({ + blockers: ["Docker image list failed; local base-image cache could not be inspected."], + nextSteps: [ + "Start or repair the local Docker daemon for the D601 builder environment.", + `Then set ${envVarName}= or provide a local node:20-* image.`, + "Do not claim #35 artifact publish readiness until this preflight returns ready." + ], + containerEngine: { + ...containerEngine, + stderr: imageList.stderr, + errorCode: imageList.errorCode + } + }); + } + + const { candidates, selected } = selectNode20Candidate(splitJsonLines(imageList.stdout)); + if (!selected) { + return blocked({ + blockers: [`${envVarName} is not set and no local node:20-* image was found in the Docker image cache.`], + candidates, + nextSteps: [ + `Set ${envVarName} to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim.`, + "Rerun this preflight and require status=ready before #35 artifact publish.", + "Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes." + ], + containerEngine + }); + } + + const inspected = inspectLocalImage(selected.localTag); + if (!inspected.ok || inspected.docs.length === 0) { + return blocked({ + imageSource: selected.source, + requestedImage: null, + localTag: selected.localTag, + candidates, + blockers: [`Selected local candidate ${selected.localTag} could not be inspected.`], + nextSteps: [ + `Set ${envVarName} to a known-good approved local base image and rerun this preflight.`, + "Do not continue #35 artifact publish until image inspect succeeds." + ], + containerEngine + }); + } + const evidenceBlockers = inspectEvidenceBlockers(inspected.docs[0]); + if (evidenceBlockers.length > 0) { + return blocked({ + imageSource: selected.source, + requestedImage: null, + localTag: selected.localTag, + candidates, + imageId: inspected.docs[0].Id ?? null, + blockers: evidenceBlockers, + nextSteps: [ + `Replace ${selected.localTag} with a clean approved Node 20 builder base image.`, + "Do not continue #35 artifact publish with an image whose inspect metadata points at a forbidden runtime substitute." + ], + containerEngine + }); + } + + return { + ...ready({ + imageSource: selected.source, + approval: selected.approval, + requestedImage: null, + localTag: selected.localTag, + inspectDoc: inspected.docs[0], + candidates + }), + containerEngine + }; +} + +export function unexpectedPreflightErrorResult(error) { + const message = error instanceof Error ? error.message : String(error); + return blocked({ + blockers: [`Unexpected preflight error: ${message}`], + nextSteps: [ + "Treat this as a blocker and inspect the preflight command output.", + "Do not continue #35 artifact publish until the preflight returns status=ready." + ] + }); +} + +export function formatPreflightResult(result) { + return JSON.stringify(result, null, 2); +} + +export function exitCodeForPreflight(result) { + return result.publishUsable ? 0 : 2; +}