fix: align dev artifact catalog identity

This commit is contained in:
HWLAB Code Queue
2026-05-21 17:54:01 +00:00
parent 1a2efd4915
commit ef35856bb0
13 changed files with 1138 additions and 185 deletions
+53 -45
View File
@@ -5,22 +5,30 @@
"profile": "dev",
"namespace": "hwlab-dev",
"endpoint": "http://74.48.78.17:6667",
"commitId": "24eb3bf",
"commitId": "1a2efd4",
"artifactState": "contract-skeleton",
"publish": {
"ciPublished": false,
"registryVerified": false,
"provenance": "not_available_in_mvp_skeleton",
"note": "This catalog is a static DEV contract only. It is not evidence that images were built, pushed, or deployed."
"provenance": "not_available_until_publish",
"note": "Artifact identity was refreshed to this source commit, but no publish report proved registry digests."
},
"healthContract": {
"method": "GET",
"path": "/health/live",
"responseFormat": "json",
"requiredFields": ["serviceId", "environment", "status"]
"requiredFields": [
"serviceId",
"environment",
"status"
]
},
"allowedProfiles": ["dev"],
"forbiddenProfiles": ["prod"],
"allowedProfiles": [
"dev"
],
"forbiddenProfiles": [
"prod"
],
"forbiddenItems": [
{
"id": "prod-deploy",
@@ -58,9 +66,9 @@
"services": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -69,9 +77,9 @@
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -80,9 +88,9 @@
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -91,9 +99,9 @@
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -102,9 +110,9 @@
},
{
"serviceId": "hwlab-gateway",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -113,9 +121,9 @@
},
{
"serviceId": "hwlab-gateway-simu",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -124,9 +132,9 @@
},
{
"serviceId": "hwlab-box-simu",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -135,9 +143,9 @@
},
{
"serviceId": "hwlab-patch-panel",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -146,9 +154,9 @@
},
{
"serviceId": "hwlab-router",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -157,9 +165,9 @@
},
{
"serviceId": "hwlab-tunnel-client",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -168,9 +176,9 @@
},
{
"serviceId": "hwlab-edge-proxy",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -179,9 +187,9 @@
},
{
"serviceId": "hwlab-cli",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
@@ -190,9 +198,9 @@
},
{
"serviceId": "hwlab-agent-skills",
"commitId": "24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
"imageTag": "24eb3bf",
"commitId": "1a2efd4",
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
+18 -18
View File
@@ -1,7 +1,7 @@
{
"manifestVersion": "v1",
"environment": "dev",
"commitId": "24eb3bf",
"commitId": "1a2efd4",
"namespace": "hwlab-dev",
"endpoint": "http://74.48.78.17:6667",
"profiles": {
@@ -22,7 +22,7 @@
"services": [
{
"serviceId": "hwlab-cloud-api",
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -30,9 +30,9 @@
"env": {
"HWLAB_ENVIRONMENT": "dev",
"HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:6667",
"HWLAB_COMMIT_ID": "24eb3bf",
"HWLAB_IMAGE": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
"HWLAB_IMAGE_TAG": "24eb3bf",
"HWLAB_COMMIT_ID": "1a2efd4",
"HWLAB_IMAGE": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"HWLAB_IMAGE_TAG": "1a2efd4",
"HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy",
"HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url",
"HWLAB_CLOUD_DB_SSL_MODE": "require",
@@ -41,7 +41,7 @@
},
{
"serviceId": "hwlab-cloud-web",
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -53,7 +53,7 @@
},
{
"serviceId": "hwlab-agent-mgr",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -65,7 +65,7 @@
},
{
"serviceId": "hwlab-agent-worker",
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -77,7 +77,7 @@
},
{
"serviceId": "hwlab-gateway",
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -89,7 +89,7 @@
},
{
"serviceId": "hwlab-gateway-simu",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -101,7 +101,7 @@
},
{
"serviceId": "hwlab-box-simu",
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -113,7 +113,7 @@
},
{
"serviceId": "hwlab-patch-panel",
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -125,7 +125,7 @@
},
{
"serviceId": "hwlab-router",
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -137,7 +137,7 @@
},
{
"serviceId": "hwlab-tunnel-client",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -150,7 +150,7 @@
},
{
"serviceId": "hwlab-edge-proxy",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -162,7 +162,7 @@
},
{
"serviceId": "hwlab-cli",
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -173,13 +173,13 @@
},
{
"serviceId": "hwlab-agent-skills",
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
"replicas": 1,
"env": {
"HWLAB_SKILLS_COMMIT_ID": "24eb3bf"
"HWLAB_SKILLS_COMMIT_ID": "1a2efd4"
}
}
],
+17 -17
View File
@@ -31,7 +31,7 @@
"containers": [
{
"name": "hwlab-cloud-api",
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"ports": [
{
"name": "http",
@@ -49,15 +49,15 @@
},
{
"name": "HWLAB_COMMIT_ID",
"value": "24eb3bf"
"value": "1a2efd4"
},
{
"name": "HWLAB_IMAGE",
"value": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf"
"value": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4"
},
{
"name": "HWLAB_IMAGE_TAG",
"value": "24eb3bf"
"value": "1a2efd4"
},
{
"name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN",
@@ -129,7 +129,7 @@
"containers": [
{
"name": "hwlab-cloud-web",
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"ports": [
{
"name": "http",
@@ -189,7 +189,7 @@
"containers": [
{
"name": "hwlab-agent-mgr",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"ports": [
{
"name": "http",
@@ -245,7 +245,7 @@
"containers": [
{
"name": "hwlab-agent-worker",
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"env": [
{
"name": "HWLAB_AGENT_SESSION_MODE",
@@ -288,7 +288,7 @@
"containers": [
{
"name": "hwlab-gateway",
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"ports": [
{
"name": "http",
@@ -348,7 +348,7 @@
"containers": [
{
"name": "hwlab-gateway-simu",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"ports": [
{
"name": "http",
@@ -408,7 +408,7 @@
"containers": [
{
"name": "hwlab-box-simu",
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"ports": [
{
"name": "http",
@@ -468,7 +468,7 @@
"containers": [
{
"name": "hwlab-patch-panel",
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"ports": [
{
"name": "http",
@@ -522,7 +522,7 @@
"containers": [
{
"name": "hwlab-router",
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"ports": [
{
"name": "http",
@@ -582,7 +582,7 @@
"containers": [
{
"name": "hwlab-tunnel-client",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"ports": [
{
"name": "http",
@@ -650,7 +650,7 @@
"containers": [
{
"name": "hwlab-edge-proxy",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"ports": [
{
"name": "http",
@@ -711,7 +711,7 @@
"containers": [
{
"name": "hwlab-cli",
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"env": [
{
"name": "HWLAB_CLI_ENDPOINT",
@@ -753,7 +753,7 @@
"containers": [
{
"name": "hwlab-agent-skills",
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"ports": [
{
"name": "http",
@@ -763,7 +763,7 @@
"env": [
{
"name": "HWLAB_SKILLS_COMMIT_ID",
"value": "24eb3bf"
"value": "1a2efd4"
}
],
"readinessProbe": {
+49 -12
View File
@@ -4,11 +4,19 @@ The artifact catalog is the DEV-only MVP contract for interpreting deployment
artifacts. It pins which HWLAB service image, commit, profile, namespace, and
health path a static deploy manifest is claiming for acceptance review.
This file is not a CI publish record. It does not prove that an image exists in a
registry, that a digest was produced, or that a deployment happened. Real CI
publish evidence must come from the future publishing workflow and registry
metadata. Until that exists, the catalog can only validate that the local
skeleton files agree with each other and do not drift into PROD.
This file is not automatically a CI publish record. It proves only the state it
declares:
- `artifactState: "contract-skeleton"` means the source/artifact commit and image
tags are aligned, but `ciPublished=false`, `registryVerified=false`, and every
digest must stay `not_published`.
- `artifactState: "published"` is allowed only after a DEV publish report proves
every frozen service was published for the same source commit with a real
`sha256:<64 hex>` registry digest.
Do not infer registry existence, digest values, or deployment from commit tags.
Real publish evidence must come from the publishing workflow and registry
metadata, then be copied into the catalog by the refresh command below.
## Scope
@@ -28,6 +36,7 @@ skeleton files agree with each other and do not drift into PROD.
| `deploy/artifact-catalog.dev.json` | Machine-readable DEV artifact catalog sample and static source of truth for the skeleton. |
| `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. |
| `CI.json` | Lightweight command and forbidden-action skeleton. |
| `scripts/refresh-artifact-catalog.mjs` | Refreshes source/artifact commit tags and records either blocked `not_published` state or proven publish digests. |
| `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. |
@@ -46,12 +55,38 @@ rules:
| `profile` | Must be `dev`. |
| `namespace` | Must be `hwlab-dev`. |
| `healthPath` | Must be `/health/live` and match the DEV health contract. |
| `publishState` | Must be `skeleton-only` until real CI publish evidence exists. |
| `digest` | Must be `not_published` in the skeleton catalog; CI must replace this with a registry digest only after publishing. |
| `publishState` | Must be `skeleton-only` in a blocked skeleton catalog, or `published` only when the catalog was refreshed from a successful publish report. |
| `digest` | Must be `not_published` in the skeleton catalog; it may be a registry `sha256:<64 hex>` digest only after publish evidence exists. |
The current skeleton commit and tag values intentionally mirror
`deploy/deploy.json`. They are traceability placeholders for static acceptance
work, not a statement that GHCR already contains those images.
The catalog `commitId` is the artifact source commit, not proof that a registry
artifact exists. It must match `deploy/deploy.json`, every service `commitId`,
and the image tag. If it does not match the selected source ref, the DEV gate
preflight reports `artifact-source-commit` as a blocker.
## Refresh Workflow
When the source commit changes and no publish report proves digests, refresh the
catalog and deploy manifests in blocked mode:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
```
This updates `deploy/deploy.json`, `deploy/artifact-catalog.dev.json`, and
`deploy/k8s/base/workloads.yaml` to the selected commit tag. It keeps
`artifactState: "contract-skeleton"`, `ciPublished=false`,
`registryVerified=false`, and every service digest as `not_published`.
After a successful DEV artifact publish, record digests only from the publish
report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
```
The publish-report mode refuses to update the catalog unless the report says
`artifactPublish.status="published"`, covers every frozen service, matches the
target source commit, and contains a valid `sha256` digest for each service.
## Relationship To `deploy/deploy.json`
@@ -91,10 +126,12 @@ Run the local static validator:
```sh
node --check scripts/validate-artifact-catalog.mjs
node --check scripts/refresh-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
```
The validator reads only local repository files. It does not contact a registry,
read secrets, build images, push images, deploy to DEV, or deploy to PROD.
The validator and refresh command read only local repository files and optional
publish reports. They do not contact a registry, read secrets, build images,
push images, deploy to DEV, or deploy to PROD.
+19 -2
View File
@@ -30,6 +30,7 @@ Static check:
node --check scripts/preflight-dev-base-image.mjs
node --check scripts/src/dev-base-image-preflight.mjs
node --check scripts/dev-artifact-publish.mjs
node --check scripts/refresh-artifact-catalog.mjs
```
Base image preflight:
@@ -79,8 +80,24 @@ Each service record contains:
- `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.
push output contains a `sha256:<64 hex>` digest. If the push succeeds but no
digest is observable, the service remains `published_unverified_digest`,
`digest` stays `not_published`, and the report carries a blocker. The script
records blockers instead of claiming a publish when build, push, digest
observation, base image, registry, contract, or safety checks fail.
After a fully successful publish, update the catalog from the report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
```
If publish remains blocked, refresh only commit/tag identity and keep digests
blocked:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
```
The report also includes `artifactPublish.baseImagePreflight` with the image
source, local tag, local image ID, `publishUsable` gate, blockers, and next
+22 -2
View File
@@ -15,6 +15,7 @@ Run from the repository root:
```sh
node --check scripts/dev-gate-preflight.mjs
node --check scripts/src/dev-gate-preflight.mjs
node --check scripts/refresh-artifact-catalog.mjs
node scripts/dev-gate-preflight.mjs
```
@@ -39,8 +40,12 @@ The preflight checks:
- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` are internally
consistent and DEV-only.
- The deploy manifest and catalog target the selected `origin/main` commit.
- The artifact catalog has real publish and registry digest evidence.
- The deploy manifest, artifact catalog, catalog service commits, and image tags
target the selected `origin/main` source commit.
- The artifact catalog clearly separates source commit, artifact commit,
`ciPublished`, `registryVerified`, and per-service digest state.
- The artifact catalog has real publish and registry digest evidence, or stays
blocked with `not_published` digests.
- `reports/dev-gate/dev-artifacts.json`, when present, proves all frozen DEV
service artifacts were published for the selected `origin/main` commit.
- `deploy/k8s/base` and `deploy/k8s/dev` parse and remain scoped to
@@ -75,3 +80,18 @@ The DB gate has two common blocked scopes:
- `cloud-api-db-health-gate`: runtime env presence is missing. The next task is
to configure the DEV Secret/env names, not to print or commit any secret
value.
For catalog commit mismatch, the preflight emits an
`artifactIdentity.refreshCommands.blocked` command. Use it when the source
commit changed but publish is still blocked:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
```
Only use the published refresh path after `reports/dev-gate/dev-artifacts.json`
proves every frozen service has a registry digest for that same source commit:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
```
+2 -1
View File
@@ -5,7 +5,7 @@
"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/db-contract.mjs && node --check internal/cloud/json-rpc.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/validate-dev-m3-cardinality.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 --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.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/db-contract.mjs && node --check internal/cloud/json-rpc.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/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.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 --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.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",
@@ -16,6 +16,7 @@
"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",
"artifact-catalog:refresh-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
"dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight",
"dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish",
"d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs",
+367 -48
View File
@@ -15,14 +15,17 @@
"pikasTech/HWLAB#31",
"pikasTech/HWLAB#33",
"pikasTech/HWLAB#39",
"pikasTech/HWLAB#49"
"pikasTech/HWLAB#49",
"pikasTech/HWLAB#33",
"pikasTech/HWLAB#35",
"pikasTech/HWLAB#48"
],
"target": {
"ref": "origin/main",
"commitId": "36bf2c42edd3011deb0889a25aba077a2a69f872",
"shortCommitId": "36bf2c4"
"commitId": "1a2efd4915b2d7a6f640a22cc22087e7cae4c654",
"shortCommitId": "1a2efd4"
},
"generatedAt": "2026-05-21T17:57:44.613Z",
"generatedAt": "2026-05-21T18:00:08.924Z",
"mode": "read-only",
"devOnly": true,
"prodDisabled": true,
@@ -38,10 +41,165 @@
"validationCommands": [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
"node --check scripts/refresh-artifact-catalog.mjs",
"node scripts/dev-gate-preflight.mjs",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
"artifactIdentity": {
"source": {
"ref": "origin/main",
"commitId": "1a2efd4915b2d7a6f640a22cc22087e7cae4c654",
"shortCommitId": "1a2efd4"
},
"deployManifest": {
"path": "deploy/deploy.json",
"commitId": "1a2efd4",
"matchesSource": true
},
"artifactCatalog": {
"path": "deploy/artifact-catalog.dev.json",
"commitId": "1a2efd4",
"artifactState": "contract-skeleton",
"ciPublished": false,
"registryVerified": false,
"provenance": "not_available_until_publish",
"digestCounts": {
"sha256": 0,
"notPublished": 13,
"invalid": 0
},
"matchesSource": true
},
"services": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-gateway",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-gateway-simu",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-box-simu",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-patch-panel",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-router",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-tunnel-client",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-edge-proxy",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-cli",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-skills",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
}
],
"serviceCommitIds": [
"1a2efd4"
],
"matchesSource": true,
"publishVerified": false,
"refreshCommands": {
"blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
"published": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json"
}
},
"conclusion": "blocked",
"checks": [
{
@@ -54,16 +212,186 @@
{
"id": "target-commit-pinning",
"category": "contract",
"status": "blocked",
"summary": "deploy/catalog commitId 24eb3bf does not match origin/main 36bf2c4.",
"evidence": []
"status": "pass",
"summary": "Source origin/main 1a2efd4 matches deploy/catalog artifact commit 1a2efd4.",
"evidence": [
{
"source": {
"ref": "origin/main",
"commitId": "1a2efd4915b2d7a6f640a22cc22087e7cae4c654",
"shortCommitId": "1a2efd4"
},
"deployManifest": {
"path": "deploy/deploy.json",
"commitId": "1a2efd4",
"matchesSource": true
},
"artifactCatalog": {
"path": "deploy/artifact-catalog.dev.json",
"commitId": "1a2efd4",
"artifactState": "contract-skeleton",
"ciPublished": false,
"registryVerified": false,
"provenance": "not_available_until_publish",
"digestCounts": {
"sha256": 0,
"notPublished": 13,
"invalid": 0
},
"matchesSource": true
},
"services": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-gateway",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-gateway-simu",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-box-simu",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-patch-panel",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-router",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-tunnel-client",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-edge-proxy",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-cli",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
},
{
"serviceId": "hwlab-agent-skills",
"commitId": "1a2efd4",
"matchesSource": true,
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"imageTag": "1a2efd4",
"digest": "not_published",
"publishState": "skeleton-only"
}
],
"serviceCommitIds": [
"1a2efd4"
],
"matchesSource": true,
"publishVerified": false,
"refreshCommands": {
"blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
"published": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json"
}
}
]
},
{
"id": "artifact-catalog-publish-state",
"category": "registry",
"status": "blocked",
"summary": "Artifact catalog is still a skeleton and does not carry registry digests.",
"evidence": []
"summary": "Artifact catalog publish state is contract-skeleton; ciPublished=false, registryVerified=false, digests sha256=0, not_published=13, invalid=0.",
"evidence": [
{
"path": "deploy/artifact-catalog.dev.json",
"commitId": "1a2efd4",
"artifactState": "contract-skeleton",
"ciPublished": false,
"registryVerified": false,
"provenance": "not_available_until_publish",
"digestCounts": {
"sha256": 0,
"notPublished": 13,
"invalid": 0
},
"matchesSource": true
}
]
},
{
"id": "k8s-manifest-static",
@@ -299,19 +627,17 @@
"evidence": [
{
"serviceId": "hwlab-cloud-api",
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-api:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-cloud-api/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-cloud-api/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
"body": ""
"error": "This operation was aborted"
},
{
"serviceId": "hwlab-cloud-web",
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cloud-web:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-cloud-web/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-cloud-web/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -319,9 +645,9 @@
},
{
"serviceId": "hwlab-agent-mgr",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-mgr/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-mgr/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -329,9 +655,9 @@
},
{
"serviceId": "hwlab-agent-worker",
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-worker:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-worker/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-worker/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -339,9 +665,9 @@
},
{
"serviceId": "hwlab-gateway",
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-gateway/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-gateway/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -349,9 +675,9 @@
},
{
"serviceId": "hwlab-gateway-simu",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-gateway-simu:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-gateway-simu/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-gateway-simu/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -359,9 +685,9 @@
},
{
"serviceId": "hwlab-box-simu",
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-box-simu:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-box-simu/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-box-simu/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -369,9 +695,9 @@
},
{
"serviceId": "hwlab-patch-panel",
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-patch-panel:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-patch-panel/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-patch-panel/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -379,9 +705,9 @@
},
{
"serviceId": "hwlab-router",
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-router:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-router/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-router/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -389,9 +715,9 @@
},
{
"serviceId": "hwlab-tunnel-client",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-tunnel-client:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-tunnel-client/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-tunnel-client/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -399,9 +725,9 @@
},
{
"serviceId": "hwlab-edge-proxy",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-edge-proxy:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-edge-proxy/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-edge-proxy/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -409,9 +735,9 @@
},
{
"serviceId": "hwlab-cli",
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-cli:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-cli/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-cli/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -419,9 +745,9 @@
},
{
"serviceId": "hwlab-agent-skills",
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
"image": "ghcr.io/pikastech/hwlab-agent-skills:1a2efd4",
"ok": false,
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-skills/manifests/24eb3bf",
"url": "https://ghcr.io/v2/pikastech/hwlab-agent-skills/manifests/1a2efd4",
"method": "HEAD",
"status": 401,
"statusText": "Unauthorized",
@@ -431,19 +757,12 @@
}
],
"blockers": [
{
"status": "open",
"type": "contract_blocker",
"scope": "deploy-target",
"summary": "deploy/deploy.json and deploy/artifact-catalog.dev.json still target 24eb3bf, not origin/main 36bf2c4.",
"nextTask": "Publish or select a DEV artifact set for the current origin/main commit and update deploy/deploy.json plus deploy/artifact-catalog.dev.json to that immutable commit/tag."
},
{
"status": "open",
"type": "runtime_blocker",
"scope": "artifact-catalog",
"summary": "deploy/artifact-catalog.dev.json has ciPublished=false, registryVerified=false, and not_published digests.",
"nextTask": "Run the DEV image publishing workflow and record immutable GHCR digests before real deployment."
"summary": "deploy/artifact-catalog.dev.json does not prove published artifacts for origin/main 1a2efd4; ciPublished=false, registryVerified=false, not_published=13.",
"nextTask": "Run the DEV artifact publish workflow, then record only real sha256 digests with `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json`; if publish is still blocked, keep not_published via `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked`."
},
{
"status": "open",
@@ -456,7 +775,7 @@
"status": "open",
"type": "runtime_blocker",
"scope": "dev-artifact-publish",
"summary": "reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main 36bf2c4; current status is blocked with 0/13 published.",
"summary": "reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main 1a2efd4; current status is blocked with 0/13 published.",
"nextTask": "Complete DEV artifact publishing for every frozen HWLAB service at the current origin/main commit and record immutable registry digests."
},
{
+18 -3
View File
@@ -744,11 +744,26 @@ async function publishService(artifact) {
}
const digest = inspectDigest(`${result.stdout}\n${result.stderr}`);
if (!digest) {
return {
...artifact,
status: "published_unverified_digest",
digest: "not_published",
blocker: blocker({
type: "environment_blocker",
scope: artifact.serviceId,
summary: `docker push completed for ${artifact.image} but did not return an immutable digest`,
next: "Verify registry digest reporting, then rerun --publish; do not update the catalog with a digest until a sha256 registry digest is observed."
}),
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
};
}
return {
...artifact,
status: digest ? "published" : "published_unverified_digest",
digest: digest ?? "digest_not_found",
repositoryDigest: digest ? `${repositoryFromImageRef(artifact.image)}@${digest}` : null,
status: "published",
digest,
repositoryDigest: `${repositoryFromImageRef(artifact.image)}@${digest}`,
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
};
}
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const workloadsPath = "deploy/k8s/base/workloads.yaml";
const defaultPublishReportPath = "reports/dev-gate/dev-artifacts.json";
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const commitPattern = /^[a-f0-9]{7,40}$/;
function parseArgs(argv) {
const args = {
targetRef: "HEAD",
publishReportPath: null,
blocked: false,
write: true
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--target-ref") {
args.targetRef = readOption(argv, ++index, arg);
} else if (arg === "--publish-report") {
args.publishReportPath = readOption(argv, ++index, arg);
} else if (arg === "--blocked") {
args.blocked = true;
} else if (arg === "--no-write") {
args.write = false;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`unknown argument ${arg}`);
}
}
if (!args.help) {
assert.notEqual(args.blocked && Boolean(args.publishReportPath), true, "--blocked and --publish-report are mutually exclusive");
assert.ok(args.blocked || args.publishReportPath, "choose --blocked or --publish-report reports/dev-gate/dev-artifacts.json");
}
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 usage() {
return [
"usage: node scripts/refresh-artifact-catalog.mjs --target-ref REF (--blocked|--publish-report PATH) [--no-write]",
"",
"Refresh DEV deploy/catalog artifact identity without faking digest evidence.",
"",
"examples:",
" node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
` node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report ${defaultPublishReportPath}`
].join("\n");
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
}
async function writeJson(relativePath, value) {
await writeFile(path.join(repoRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`);
}
async function gitValue(args) {
const result = await execFileAsync("git", args, {
cwd: repoRoot,
timeout: 5000,
maxBuffer: 1024 * 1024
});
return result.stdout.trim();
}
async function resolveTarget(ref) {
const commitId = await gitValue(["rev-parse", ref]);
const shortCommitId = await gitValue(["rev-parse", "--short=7", ref]);
assert.match(commitId, /^[a-f0-9]{40}$/, `target ref ${ref} must resolve to a full SHA`);
return { ref, commitId, shortCommitId };
}
function parseTaggedImage(image, context) {
assert.equal(typeof image, "string", `${context} image must be a string`);
assert.equal(image.includes("@"), false, `${context} image must not include a digest suffix`);
assert.ok(!/prod|production/iu.test(image), `${context} image must not target prod`);
const slashIndex = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
assert.ok(colonIndex > slashIndex, `${context} image must be a tagged image reference`);
return {
repository: image.slice(0, colonIndex),
tag: image.slice(colonIndex + 1)
};
}
function targetImage(serviceId, shortCommitId) {
return `ghcr.io/pikastech/${serviceId}:${shortCommitId}`;
}
function commitMatchesTarget(value, target) {
return value === target.commitId || value === target.shortCommitId;
}
function assertDevOnlyDeployAndCatalog(deploy, catalog) {
assert.equal(deploy.environment, ENVIRONMENT_DEV, "deploy environment must be dev");
assert.equal(deploy.namespace, "hwlab-dev", "deploy namespace must be hwlab-dev");
assert.equal(deploy.endpoint, DEV_ENDPOINT, "deploy endpoint must stay frozen");
assert.equal(deploy.profiles?.dev?.enabled, true, "deploy dev profile must be enabled");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy prod profile must stay disabled");
assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order");
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.equal(catalog.endpoint, DEV_ENDPOINT, "catalog endpoint must stay frozen");
assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "catalog allowedProfiles must only allow dev");
assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "catalog forbiddenProfiles must forbid prod");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order");
}
function publishRecordsFromReport(report, target) {
const artifactPublish = report.artifactPublish;
assert.ok(artifactPublish && typeof artifactPublish === "object", "publish report missing artifactPublish");
assert.equal(artifactPublish.status, "published", "publish report artifactPublish.status must be published");
assert.ok(commitMatchesTarget(artifactPublish.sourceCommitId, target), "publish report sourceCommitId must match target ref");
assert.equal(artifactPublish.publishedCount, SERVICE_IDS.length, "publish report must publish every frozen service");
assert.equal(artifactPublish.serviceCount, SERVICE_IDS.length, "publish report must cover every frozen service");
const records = new Map();
for (const service of artifactPublish.services ?? []) {
assert.ok(SERVICE_IDS.includes(service.serviceId), `unknown service ${service.serviceId} in publish report`);
assert.equal(service.status, "published", `${service.serviceId} status must be published`);
assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`);
const image = parseTaggedImage(service.image, service.serviceId);
assert.equal(image.tag, target.shortCommitId, `${service.serviceId} image tag must match target short commit`);
records.set(service.serviceId, {
serviceId: service.serviceId,
image: service.image,
imageTag: image.tag,
digest: service.digest,
repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`
});
}
assert.deepEqual([...records.keys()], SERVICE_IDS, "publish report services must cover frozen service IDs in order");
return records;
}
function updateEnvObject(env, service, target, publishRecord) {
if (!env || typeof env !== "object" || Array.isArray(env)) return;
if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = target.shortCommitId;
if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image;
if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag;
if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = target.shortCommitId;
if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published";
}
function serviceIdForWorkload(item, container) {
return (
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
container?.name
);
}
function updateEnvList(envList, service, target, publishRecord) {
if (!Array.isArray(envList)) return;
for (const entry of envList) {
if (entry.name === "HWLAB_COMMIT_ID") entry.value = target.shortCommitId;
if (entry.name === "HWLAB_IMAGE") entry.value = service.image;
if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag;
if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = target.shortCommitId;
if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published";
}
}
function workloadContainers(item) {
return item?.spec?.template?.spec?.containers ?? [];
}
function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, provenancePath }) {
const published = Boolean(publishRecords);
const catalogByService = new Map(catalog.services.map((service) => [service.serviceId, service]));
const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service]));
const refreshedServices = [];
deploy.commitId = target.shortCommitId;
catalog.commitId = target.shortCommitId;
catalog.artifactState = published ? "published" : "contract-skeleton";
catalog.publish = {
...catalog.publish,
ciPublished: published,
registryVerified: published,
provenance: published ? provenancePath : "not_available_until_publish",
note: published
? "Digest fields were copied from a successful DEV artifact publish report for this source commit."
: "Artifact identity was refreshed to this source commit, but no publish report proved registry digests."
};
for (const serviceId of SERVICE_IDS) {
const deployService = deployByService.get(serviceId);
const catalogService = catalogByService.get(serviceId);
assert.ok(deployService, `${serviceId} missing from deploy manifest`);
assert.ok(catalogService, `${serviceId} missing from catalog`);
const publishRecord = publishRecords?.get(serviceId) ?? null;
const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId);
const imageTag = publishRecord?.imageTag ?? target.shortCommitId;
deployService.image = image;
updateEnvObject(deployService.env, { image, imageTag }, target, publishRecord);
catalogService.commitId = target.shortCommitId;
catalogService.image = image;
catalogService.imageTag = imageTag;
catalogService.digest = publishRecord?.digest ?? "not_published";
catalogService.publishState = published ? "published" : "skeleton-only";
refreshedServices.push({
serviceId,
image,
imageTag,
digest: catalogService.digest,
publishState: catalogService.publishState
});
}
for (const item of workloads.items ?? []) {
for (const container of workloadContainers(item)) {
const serviceId = serviceIdForWorkload(item, container);
const service = catalogByService.get(serviceId);
if (!service) continue;
container.image = service.image;
updateEnvList(container.env, service, target, publishRecords?.get(serviceId) ?? null);
}
}
return refreshedServices;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const target = await resolveTarget(args.targetRef);
const [deploy, catalog, workloads, publishReport] = await Promise.all([
readJson(deployPath),
readJson(catalogPath),
readJson(workloadsPath),
args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null)
]);
assertDevOnlyDeployAndCatalog(deploy, catalog);
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target) : null;
const services = refreshDocuments({
deploy,
catalog,
workloads,
target,
publishRecords,
provenancePath: args.publishReportPath ?? defaultPublishReportPath
});
if (args.write) {
await Promise.all([
writeJson(deployPath, deploy),
writeJson(catalogPath, catalog),
writeJson(workloadsPath, workloads)
]);
}
console.log(JSON.stringify({
status: publishRecords ? "published" : "blocked",
targetRef: target.ref,
sourceCommitId: target.commitId,
artifactCommitId: target.shortCommitId,
wrote: args.write ? [deployPath, catalogPath, workloadsPath] : [],
ciPublished: Boolean(publishRecords),
registryVerified: Boolean(publishRecords),
publishedCount: publishRecords?.size ?? 0,
notPublishedCount: publishRecords ? 0 : services.length,
digestPolicy: publishRecords
? "catalog digests copied only from a published DEV artifact report"
: "all catalog digests remain not_published",
services
}, null, 2));
}
main().catch((error) => {
console.error(JSON.stringify({
status: "failed",
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exitCode = 1;
});
+128 -22
View File
@@ -18,7 +18,7 @@ const defaultReportPath = "reports/dev-gate/dev-preflight-report.json";
const issue = "pikasTech/HWLAB#34";
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49"].map(
(id) => `pikasTech/HWLAB${id}`
);
).concat(["pikasTech/HWLAB#33", "pikasTech/HWLAB#35", "pikasTech/HWLAB#48"]);
const forbiddenActions = [
"prod-deploy",
"secret-material-read",
@@ -37,6 +37,7 @@ const blockerTypes = new Set([
"observability_blocker",
"safety_blocker"
]);
const digestPattern = /^sha256:[a-f0-9]{64}$/;
function parseArgs(argv) {
const args = {
@@ -200,6 +201,86 @@ function imageToManifestUrl(image) {
return match ? `https://ghcr.io/v2/pikastech/${match[1]}/manifests/${match[2]}` : null;
}
function matchesTargetCommit(value, targetCommit, targetShortCommit) {
return value === targetCommit || value === targetShortCommit;
}
function uniqueSorted(values) {
return [...new Set(values)].sort();
}
function catalogDigestCounts(catalog) {
const counts = {
sha256: 0,
notPublished: 0,
invalid: 0
};
for (const service of catalog.services ?? []) {
if (digestPattern.test(service.digest)) counts.sha256 += 1;
else if (service.digest === "not_published") counts.notPublished += 1;
else counts.invalid += 1;
}
return counts;
}
function artifactIdentityFor({ deploy, catalog, targetCommit, targetShortCommit, targetRef }) {
const serviceCommitIds = uniqueSorted((catalog.services ?? []).map((service) => service.commitId));
const digestCounts = catalogDigestCounts(catalog);
const catalogMatchesSource = matchesTargetCommit(catalog.commitId, targetCommit, targetShortCommit);
const deployMatchesSource = matchesTargetCommit(deploy.commitId, targetCommit, targetShortCommit);
const servicesMatchSource = serviceCommitIds.length === 1 &&
matchesTargetCommit(serviceCommitIds[0], targetCommit, targetShortCommit);
const allDigestsPublished = digestCounts.sha256 === SERVICE_IDS.length &&
digestCounts.notPublished === 0 &&
digestCounts.invalid === 0;
const publishVerified = catalog.publish?.ciPublished === true &&
catalog.publish?.registryVerified === true &&
catalog.artifactState === "published" &&
allDigestsPublished &&
(catalog.services ?? []).every((service) => service.publishState === "published");
return {
source: {
ref: targetRef,
commitId: targetCommit,
shortCommitId: targetShortCommit
},
deployManifest: {
path: "deploy/deploy.json",
commitId: deploy.commitId,
matchesSource: deployMatchesSource
},
artifactCatalog: {
path: "deploy/artifact-catalog.dev.json",
commitId: catalog.commitId,
artifactState: catalog.artifactState,
ciPublished: catalog.publish?.ciPublished === true,
registryVerified: catalog.publish?.registryVerified === true,
provenance: catalog.publish?.provenance ?? "unknown",
digestCounts,
matchesSource: catalogMatchesSource
},
services: (catalog.services ?? []).map((service) => ({
serviceId: service.serviceId,
commitId: service.commitId,
matchesSource: matchesTargetCommit(service.commitId, targetCommit, targetShortCommit),
image: service.image,
imageTag: service.imageTag,
digest: service.digest,
publishState: service.publishState
})),
serviceCommitIds,
matchesSource: deployMatchesSource && catalogMatchesSource && servicesMatchSource,
publishVerified,
refreshCommands: {
blocked: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --blocked`,
published: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --publish-report reports/dev-gate/dev-artifacts.json`
}
};
}
function assertStaticContract(deploy, catalog) {
assert.equal(deploy.environment, ENVIRONMENT_DEV, "deploy environment must be dev");
assert.equal(deploy.namespace, "hwlab-dev", "deploy namespace must be hwlab-dev");
@@ -299,6 +380,7 @@ function makeReporter() {
function validateLocalContracts(reporter, contracts, targetShortCommit, targetCommit, targetRef) {
const [deploy, catalog, namespace, workloads, services, devKustomization, healthContract, masterEdge] = contracts;
const artifactIdentity = artifactIdentityFor({ deploy, catalog, targetCommit, targetShortCommit, targetRef });
try {
assertStaticContract(deploy, catalog);
@@ -313,32 +395,51 @@ function validateLocalContracts(reporter, contracts, targetShortCommit, targetCo
});
}
const pinnedToTarget = [targetCommit, targetShortCommit].includes(deploy.commitId) &&
[targetCommit, targetShortCommit].includes(catalog.commitId);
if (pinnedToTarget) {
reporter.check("target-commit-pinning", "contract", "pass", `deploy/deploy.json and artifact catalog target ${targetShortCommit}.`);
if (artifactIdentity.matchesSource) {
reporter.check(
"target-commit-pinning",
"contract",
"pass",
`Source ${targetRef} ${targetShortCommit} matches deploy/catalog artifact commit ${artifactIdentity.artifactCatalog.commitId}.`,
[artifactIdentity]
);
} else {
reporter.check("target-commit-pinning", "contract", "blocked", `deploy/catalog commitId ${deploy.commitId} does not match ${targetRef} ${targetShortCommit}.`);
reporter.check(
"target-commit-pinning",
"contract",
"blocked",
`Source ${targetRef} ${targetShortCommit} is not covered by deploy commit ${deploy.commitId}, catalog commit ${catalog.commitId}, service commits ${artifactIdentity.serviceCommitIds.join(", ")}.`,
[artifactIdentity]
);
reporter.block({
type: "contract_blocker",
scope: "deploy-target",
summary: `deploy/deploy.json and deploy/artifact-catalog.dev.json still target ${deploy.commitId}, not ${targetRef} ${targetShortCommit}.`,
nextTask: "Publish or select a DEV artifact set for the current origin/main commit and update deploy/deploy.json plus deploy/artifact-catalog.dev.json to that immutable commit/tag."
scope: "artifact-source-commit",
summary: `source commit ${targetRef} ${targetShortCommit} does not match deploy/catalog artifact identity: deploy=${deploy.commitId}, catalog=${catalog.commitId}, services=${artifactIdentity.serviceCommitIds.join(", ")}.`,
nextTask: `Refresh without fake digests using \`${artifactIdentity.refreshCommands.blocked}\`, or after a successful publish run \`${artifactIdentity.refreshCommands.published}\`.`
});
}
const catalogClaimsPublished = catalog.publish?.ciPublished === true &&
catalog.publish?.registryVerified === true &&
catalog.services.every((service) => /^sha256:[a-f0-9]{64}$/.test(service.digest));
if (catalogClaimsPublished) {
reporter.check("artifact-catalog-publish-state", "registry", "pass", "Artifact catalog claims published images with registry digests.");
if (artifactIdentity.publishVerified) {
reporter.check(
"artifact-catalog-publish-state",
"registry",
"pass",
"Artifact catalog records CI publish, registry verification, and immutable service digests.",
[artifactIdentity.artifactCatalog]
);
} else {
reporter.check("artifact-catalog-publish-state", "registry", "blocked", "Artifact catalog is still a skeleton and does not carry registry digests.");
reporter.check(
"artifact-catalog-publish-state",
"registry",
"blocked",
`Artifact catalog publish state is ${catalog.artifactState}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, digests sha256=${artifactIdentity.artifactCatalog.digestCounts.sha256}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}, invalid=${artifactIdentity.artifactCatalog.digestCounts.invalid}.`,
[artifactIdentity.artifactCatalog]
);
reporter.block({
type: "runtime_blocker",
scope: "artifact-catalog",
summary: "deploy/artifact-catalog.dev.json has ciPublished=false, registryVerified=false, and not_published digests.",
nextTask: "Run the DEV image publishing workflow and record immutable GHCR digests before real deployment."
summary: `deploy/artifact-catalog.dev.json does not prove published artifacts for ${targetRef} ${targetShortCommit}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}.`,
nextTask: `Run the DEV artifact publish workflow, then record only real sha256 digests with \`${artifactIdentity.refreshCommands.published}\`; if publish is still blocked, keep not_published via \`${artifactIdentity.refreshCommands.blocked}\`.`
});
}
@@ -355,7 +456,7 @@ function validateLocalContracts(reporter, contracts, targetShortCommit, targetCo
});
}
return { deploy, catalog, masterEdge };
return { deploy, catalog, masterEdge, artifactIdentity };
}
function validateArtifactPublishReport(reporter, artifactReport, targetShortCommit, targetCommit, targetRef) {
@@ -709,7 +810,7 @@ async function writeReport(report, reportPath) {
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
}
function makeReport(args, targetCommit, targetShortCommit, reporter) {
function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity) {
const conclusion = reporter.blockers.length === 0 &&
reporter.checks.every((item) => item.status === "pass") ? "ready" : "blocked";
@@ -733,10 +834,12 @@ function makeReport(args, targetCommit, targetShortCommit, reporter) {
validationCommands: [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
"node --check scripts/refresh-artifact-catalog.mjs",
"node scripts/dev-gate-preflight.mjs",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
artifactIdentity,
conclusion,
checks: reporter.checks,
blockers: reporter.blockers,
@@ -748,7 +851,10 @@ function printSummary(args, report) {
console.log(JSON.stringify({
issue,
targetRef: args.targetRef,
targetCommit: report.target.shortCommitId,
targetCommit: report.target.commitId,
targetShortCommit: report.target.shortCommitId,
artifactCatalogCommit: report.artifactIdentity?.artifactCatalog?.commitId,
catalogDigests: report.artifactIdentity?.artifactCatalog?.digestCounts,
conclusion: report.conclusion,
report: args.writeReport ? args.reportPath : null,
checks: report.checks.reduce((counts, item) => {
@@ -777,7 +883,7 @@ export async function runPreflight(argv) {
const reporter = makeReporter();
const contracts = await loadContracts();
const optionalReports = await loadOptionalReports();
const { deploy, catalog, masterEdge } = validateLocalContracts(
const { deploy, catalog, masterEdge, artifactIdentity } = validateLocalContracts(
reporter,
contracts,
targetShortCommit,
@@ -792,7 +898,7 @@ export async function runPreflight(argv) {
validateRuntimeBoundary(reporter, deploy);
await validateLiveProbes(reporter, catalog, args.timeoutMs);
const report = makeReport(args, targetCommit, targetShortCommit, reporter);
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity);
if (args.writeReport) {
await writeReport(report, args.reportPath);
}
+42 -15
View File
@@ -13,7 +13,10 @@ const deployPath = "deploy/deploy.json";
const ciPath = "CI.json";
const healthContractPath = "deploy/k8s/dev/health-contract.yaml";
const commitPattern = /^[a-f0-9]{7,40}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
const catalogModes = new Set(["contract-skeleton", "published"]);
const skeletonProvenances = new Set(["not_available_in_mvp_skeleton", "not_available_until_publish"]);
const requiredForbiddenItems = [
"prod-deploy",
"prod-profile-enabled",
@@ -46,11 +49,19 @@ function assertCommitId(value, context) {
function imageParts(image, context) {
assertString(image, context);
const match = image.match(/^(ghcr\.io\/pikastech\/[^:@]+):([^:@]+)$/);
assert.ok(match, `${context} must be a tagged ghcr.io/pikastech image`);
assert.equal(image.includes("@"), false, `${context} must keep digest in the digest field, not the image reference`);
assert.ok(!/prod|production/iu.test(image), `${context} must not target prod`);
const slashIndex = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
assert.ok(colonIndex > slashIndex, `${context} must be a tagged image reference`);
const repository = image.slice(0, colonIndex);
const tag = image.slice(colonIndex + 1);
assertString(repository, `${context} repository`);
assertString(tag, `${context} tag`);
return {
repository: match[1],
tag: match[2]
repository,
repositoryName: repository.slice(repository.lastIndexOf("/") + 1),
tag
};
}
@@ -66,13 +77,22 @@ function assertDevOnlyCatalog(catalog) {
assert.equal(catalog.namespace, "hwlab-dev", "catalog namespace");
assert.equal(catalog.endpoint, DEV_ENDPOINT, "catalog endpoint");
assertCommitId(catalog.commitId, "catalog commitId");
assert.equal(catalog.artifactState, "contract-skeleton", "catalog artifactState");
assert.ok(catalogModes.has(catalog.artifactState), "catalog artifactState must be contract-skeleton or published");
assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "only dev profile is allowed");
assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "prod profile must be forbidden");
assert.equal(catalog.publish.ciPublished, false, "catalog must not claim CI publish");
assert.equal(catalog.publish.registryVerified, false, "catalog must not claim registry verification");
assert.equal(catalog.publish.provenance, "not_available_in_mvp_skeleton", "catalog provenance");
assert.ok(catalog.publish && typeof catalog.publish === "object", "catalog publish must be an object");
if (catalog.artifactState === "contract-skeleton") {
assert.equal(catalog.publish.ciPublished, false, "skeleton catalog must not claim CI publish");
assert.equal(catalog.publish.registryVerified, false, "skeleton catalog must not claim registry verification");
assert.ok(skeletonProvenances.has(catalog.publish.provenance), "skeleton catalog provenance must say publish evidence is unavailable");
} else {
assert.equal(catalog.publish.ciPublished, true, "published catalog must claim CI publish");
assert.equal(catalog.publish.registryVerified, true, "published catalog must claim registry verification");
assertString(catalog.publish.provenance, "published catalog provenance");
assert.equal(skeletonProvenances.has(catalog.publish.provenance), false, "published catalog provenance must name real publish evidence");
}
assert.equal(catalog.healthContract.method, "GET", "health method");
assert.equal(catalog.healthContract.path, "/health/live", "health path");
@@ -86,6 +106,8 @@ function assertDevOnlyCatalog(catalog) {
for (const required of requiredForbiddenItems) {
assert.ok(forbiddenIds.includes(required), `catalog forbiddenItems missing ${required}`);
}
return catalog.artifactState;
}
function assertDeployManifest(deployManifest, catalog) {
@@ -101,7 +123,7 @@ function assertDeployManifest(deployManifest, catalog) {
assert.equal(deployManifest.profiles.prod.enabled, false, "deploy prod profile must stay disabled");
}
function assertCatalogServices(catalog, deployManifest) {
function assertCatalogServices(catalog, deployManifest, catalogMode) {
assert.ok(Array.isArray(catalog.services), "catalog services must be an array");
assert.ok(Array.isArray(deployManifest.services), "deploy services must be an array");
@@ -124,7 +146,7 @@ function assertCatalogServices(catalog, deployManifest) {
assertNoMutableTag(service.imageTag, `${context} imageTag`);
const image = imageParts(service.image, `${context} image`);
assert.equal(image.repository, `ghcr.io/pikastech/${service.serviceId}`, `${context} image repository`);
assert.equal(image.repositoryName, service.serviceId, `${context} image repository must end with serviceId`);
assert.equal(image.tag, service.imageTag, `${context} image tag`);
assert.equal(service.image, deployService.image, `${context} image must match deploy manifest`);
@@ -134,8 +156,13 @@ function assertCatalogServices(catalog, deployManifest) {
assert.equal(service.namespace, deployService.namespace, `${context} namespace must match deploy manifest`);
assert.equal(service.healthPath, catalog.healthContract.path, `${context} healthPath`);
assert.equal(service.healthPath, deployService.healthPath, `${context} healthPath must match deploy manifest`);
assert.equal(service.publishState, "skeleton-only", `${context} publishState`);
assert.equal(service.digest, "not_published", `${context} digest`);
if (catalogMode === "contract-skeleton") {
assert.equal(service.publishState, "skeleton-only", `${context} publishState`);
assert.equal(service.digest, "not_published", `${context} digest`);
} else {
assert.equal(service.publishState, "published", `${context} publishState`);
assert.match(service.digest, digestPattern, `${context} digest`);
}
}
}
@@ -166,10 +193,10 @@ const deployManifest = await readJSON(deployPath);
const ci = await readJSON(ciPath);
const healthContract = await readJSON(healthContractPath);
assertDevOnlyCatalog(catalog);
const catalogMode = assertDevOnlyCatalog(catalog);
assertDeployManifest(deployManifest, catalog);
assertCatalogServices(catalog, deployManifest);
assertCatalogServices(catalog, deployManifest, catalogMode);
assertCIForbidden(ci, catalog);
assertHealthContract(healthContract, catalog);
console.log(`validated ${catalog.services.length} DEV artifact catalog services at ${catalog.commitId}`);
console.log(`validated ${catalog.services.length} DEV artifact catalog services at ${catalog.commitId} (${catalogMode})`);
+92
View File
@@ -48,6 +48,7 @@ const requiredDevDeployApplyValidationCommands = [
const requiredPreflightValidationCommands = [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
"node --check scripts/refresh-artifact-catalog.mjs",
"node scripts/dev-gate-preflight.mjs"
];
const requiredSmokeCommand = "node scripts/m1-contract-smoke.mjs";
@@ -627,6 +628,94 @@ function assertDevDeployApplyReport(report, label) {
}
}
function assertShaOrNotPublished(value, label) {
assertString(value, label);
assert.ok(
value === "not_published" || /^sha256:[a-f0-9]{64}$/.test(value),
`${label} must be not_published or a sha256 digest`
);
}
function assertArtifactIdentity(identity, label, target) {
assertObject(identity, label);
for (const field of [
"source",
"deployManifest",
"artifactCatalog",
"services",
"serviceCommitIds",
"matchesSource",
"publishVerified",
"refreshCommands"
]) {
assert.ok(Object.hasOwn(identity, field), `${label} missing ${field}`);
}
assertObject(identity.source, `${label}.source`);
assert.equal(identity.source.ref, target.ref, `${label}.source.ref`);
assert.equal(identity.source.commitId, target.commitId, `${label}.source.commitId`);
assert.equal(identity.source.shortCommitId, target.shortCommitId, `${label}.source.shortCommitId`);
assertObject(identity.deployManifest, `${label}.deployManifest`);
assert.equal(identity.deployManifest.path, "deploy/deploy.json", `${label}.deployManifest.path`);
assert.match(identity.deployManifest.commitId, /^[a-f0-9]{7,40}$/, `${label}.deployManifest.commitId`);
assert.equal(typeof identity.deployManifest.matchesSource, "boolean", `${label}.deployManifest.matchesSource`);
assertObject(identity.artifactCatalog, `${label}.artifactCatalog`);
assert.equal(identity.artifactCatalog.path, "deploy/artifact-catalog.dev.json", `${label}.artifactCatalog.path`);
assert.match(identity.artifactCatalog.commitId, /^[a-f0-9]{7,40}$/, `${label}.artifactCatalog.commitId`);
assert.ok(["contract-skeleton", "published"].includes(identity.artifactCatalog.artifactState), `${label}.artifactCatalog.artifactState`);
assert.equal(typeof identity.artifactCatalog.ciPublished, "boolean", `${label}.artifactCatalog.ciPublished`);
assert.equal(typeof identity.artifactCatalog.registryVerified, "boolean", `${label}.artifactCatalog.registryVerified`);
assertString(identity.artifactCatalog.provenance, `${label}.artifactCatalog.provenance`);
assert.equal(typeof identity.artifactCatalog.matchesSource, "boolean", `${label}.artifactCatalog.matchesSource`);
assertObject(identity.artifactCatalog.digestCounts, `${label}.artifactCatalog.digestCounts`);
for (const field of ["sha256", "notPublished", "invalid"]) {
assert.equal(typeof identity.artifactCatalog.digestCounts[field], "number", `${label}.artifactCatalog.digestCounts.${field}`);
}
assertArray(identity.services, `${label}.services`);
assert.ok(identity.services.length >= 1, `${label}.services must not be empty`);
assertUnique(identity.services.map((service) => service.serviceId), `${label}.services`);
for (const [index, service] of identity.services.entries()) {
const serviceLabel = `${label}.services[${index}]`;
assertObject(service, serviceLabel);
for (const field of ["serviceId", "commitId", "matchesSource", "image", "imageTag", "digest", "publishState"]) {
assert.ok(Object.hasOwn(service, field), `${serviceLabel} missing ${field}`);
}
assertString(service.serviceId, `${serviceLabel}.serviceId`);
assert.match(service.commitId, /^[a-f0-9]{7,40}$/, `${serviceLabel}.commitId`);
assert.equal(typeof service.matchesSource, "boolean", `${serviceLabel}.matchesSource`);
assertString(service.image, `${serviceLabel}.image`);
assertString(service.imageTag, `${serviceLabel}.imageTag`);
assertShaOrNotPublished(service.digest, `${serviceLabel}.digest`);
assert.ok(["skeleton-only", "published"].includes(service.publishState), `${serviceLabel}.publishState`);
}
assertStringArray(identity.serviceCommitIds, `${label}.serviceCommitIds`, { minLength: 1 });
assert.equal(typeof identity.matchesSource, "boolean", `${label}.matchesSource`);
assert.equal(typeof identity.publishVerified, "boolean", `${label}.publishVerified`);
assertObject(identity.refreshCommands, `${label}.refreshCommands`);
assert.equal(
identity.refreshCommands.blocked,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.ref} --blocked`,
`${label}.refreshCommands.blocked`
);
assert.equal(
identity.refreshCommands.published,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.ref} --publish-report reports/dev-gate/dev-artifacts.json`,
`${label}.refreshCommands.published`
);
if (identity.publishVerified) {
assert.equal(identity.artifactCatalog.ciPublished, true, `${label}.artifactCatalog.ciPublished`);
assert.equal(identity.artifactCatalog.registryVerified, true, `${label}.artifactCatalog.registryVerified`);
assert.equal(identity.artifactCatalog.digestCounts.sha256, identity.services.length, `${label}.artifactCatalog.digestCounts.sha256`);
assert.equal(identity.artifactCatalog.digestCounts.notPublished, 0, `${label}.artifactCatalog.digestCounts.notPublished`);
assert.equal(identity.artifactCatalog.digestCounts.invalid, 0, `${label}.artifactCatalog.digestCounts.invalid`);
}
}
async function validateDevEdgeReport(report, label) {
for (const field of [
"$schema",
@@ -892,6 +981,7 @@ async function validatePreflightReport(relativePath, report) {
"prodDisabled",
"forbiddenActions",
"validationCommands",
"artifactIdentity",
"conclusion",
"checks",
"blockers"
@@ -933,6 +1023,8 @@ async function validatePreflightReport(relativePath, report) {
);
}
assertArtifactIdentity(report.artifactIdentity, `${label}.artifactIdentity`, report.target);
assertArray(report.checks, `${label}.checks`);
assert.ok(report.checks.length >= 1, `${label}.checks must not be empty`);
assertUnique(report.checks.map((check) => check.id), `${label}.checks`);