diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 041cca4f..dd9e2a5d 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -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", diff --git a/deploy/deploy.json b/deploy/deploy.json index 5acc8c35..115292df 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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" } } ], diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index eb330d6c..d928a0ae 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -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": { diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index 4f43d7ac..2a884c85 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -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. diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index aeb575d7..f5cbd543 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -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 diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 7e318c8c..080a8b55 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -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 +``` diff --git a/package.json b/package.json index 889a5df2..bc9a5d52 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/reports/dev-gate/dev-preflight-report.json b/reports/dev-gate/dev-preflight-report.json index 65716627..957f43db 100644 --- a/reports/dev-gate/dev-preflight-report.json +++ b/reports/dev-gate/dev-preflight-report.json @@ -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." }, { diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index d781269c..b7700712 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -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) }; } diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs new file mode 100644 index 00000000..90e6da7a --- /dev/null +++ b/scripts/refresh-artifact-catalog.mjs @@ -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; +}); diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index fbd560e1..11012c57 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -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); } diff --git a/scripts/validate-artifact-catalog.mjs b/scripts/validate-artifact-catalog.mjs index 713fdf35..973d031c 100644 --- a/scripts/validate-artifact-catalog.mjs +++ b/scripts/validate-artifact-catalog.mjs @@ -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})`); diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index df860416..d5d7cd88 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -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`);