feat: add dev base image preflight

This commit is contained in:
HWLAB Code Queue
2026-05-21 16:45:17 +00:00
parent c1a227d184
commit 6eb4c199b0
8 changed files with 803 additions and 138 deletions
+20
View File
@@ -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:<tag>`. 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
```
+18 -3
View File
@@ -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/<serviceId>/main.mjs`.
+82
View File
@@ -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/<hwlab-service-id>:<commit>`.
- 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:<sha>` 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.
+2 -1
View File
@@ -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",
+121 -101
View File
@@ -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."
}
]
},
+72 -33
View File
@@ -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;
}
}
+17
View File
@@ -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);
}
+471
View File
@@ -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("<none>")) ?? 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}=<local-image> 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:<tag>.",
"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:<tag>.",
"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}=<approved-local-image> 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;
}