From e282b7c6e8de259b158f8a0b750240eb950b19fd Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 15:23:52 +0000 Subject: [PATCH] fix: gate deploy desired-state promotion drift --- deploy/README.md | 15 +- deploy/artifact-catalog.dev.json | 168 ++++++++++----------- deploy/deploy.json | 36 ++--- deploy/k8s/base/workloads.yaml | 34 ++--- docs/artifact-catalog.md | 13 +- docs/dev-artifact-publish.md | 13 +- docs/dev-base-image-preflight.md | 10 +- docs/dev-gate-preflight.md | 15 +- docs/reference/deployment-publish.md | 14 +- scripts/deploy-desired-state-plan.test.mjs | 90 +++++++++-- scripts/src/deploy-desired-state-plan.mjs | 96 +++++++++--- 11 files changed, 329 insertions(+), 175 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 9ead6542..cbbf522c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -40,10 +40,11 @@ PROD. Later work can wire the same source fields into `hwlab edge apply` or Desired-state commit/image convergence review: ```sh -node scripts/deploy-desired-state-plan.mjs --pretty +node scripts/deploy-desired-state-plan.mjs --plan --pretty node scripts/deploy-desired-state-plan.mjs --check node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty node scripts/deploy-desired-state-plan.mjs --target-tag --pretty +node scripts/deploy-desired-state-plan.mjs --promotion-commit --check ``` The desired-state plan reads only `deploy/deploy.json`, @@ -52,8 +53,16 @@ optional artifact report snapshot. It checks that commit IDs, service image tags, workload images, and present env mirrors such as `HWLAB_COMMIT_ID`, `HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. With `--target-ref` or `--target-tag`, a uniform older desired-state is reported as a -read-only promotion plan; `--check` fails only for missing/invalid source files, -internal mirror drift, invalid target tags, or partial target drift. +read-only promotion plan. Use `--promotion-commit ` when reviewing or +automating a concrete promotion; then `--check` also fails if any authoritative +desired-state commit, image tag, workload image, or env mirror still points to a +different commit/tag. + +`deploy/deploy.json` is the deploy truth, but artifact catalog and workload +refs are part of the same desired-state contract. Promotion updates must move +all three together by using the refresh command in blocked mode or +publish-report mode, then run the promotion commit check. Report files are +contextual evidence only and must not override these desired-state files. This is source/dry-run support only. It does not prove a registry image exists, does not build, pull, push, apply, restart, or touch PROD, and must not be used diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 4ac22f53..c17551b5 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -5,13 +5,13 @@ "profile": "dev", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", - "commitId": "7e29522", - "artifactState": "published", + "commitId": "c7de474", + "artifactState": "contract-skeleton", "publish": { - "ciPublished": true, - "registryVerified": true, - "provenance": "reports/dev-gate/dev-artifacts.json", - "note": "Digest fields were copied from a successful DEV artifact publish report for this source commit." + "ciPublished": false, + "registryVerified": false, + "provenance": "not_available_until_publish", + "note": "Artifact identity was refreshed to this source commit, but no publish report proved registry digests." }, "healthContract": { "method": "GET", @@ -66,11 +66,11 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", - "imageTag": "7e29522", - "digest": "sha256:090b6f919be3b3c7594ca4073b193351689fc525f478b64d54d393725cb01ecf", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -78,15 +78,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-cloud-web", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522", - "imageTag": "7e29522", - "digest": "sha256:bc6e836b2bb796b30562ed3ae93d8534146a3b74a310906481c8641646d1b8f0", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -94,15 +94,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-agent-mgr", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522", - "imageTag": "7e29522", - "digest": "sha256:4d9b8944bc52012bb0a4a3a345f8939381d2804032e544d0c98f49fac0e46bfd", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -110,15 +110,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-agent-worker", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", - "imageTag": "7e29522", - "digest": "sha256:2582176e2c35a2bfbce4edfd922749397d45f6f1f8c04d0394c501f7d87323ee", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -126,15 +126,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-gateway", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522", - "imageTag": "7e29522", - "digest": "sha256:290341fd6e6f036aa953943072ad58e0d0cfcc0e13950e6090f941b3310755a6", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -142,15 +142,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-gateway-simu", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522", - "imageTag": "7e29522", - "digest": "sha256:02627f880c7ddf2102e2255a43097d1f604cfb7a22215736e328287e27fc5f03", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -158,15 +158,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-box-simu", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522", - "imageTag": "7e29522", - "digest": "sha256:e02c270c71f83a2f4624722bb84616bea8374ef43439f27bdce310e4baa8dcca", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -174,15 +174,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-patch-panel", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522", - "imageTag": "7e29522", - "digest": "sha256:f730323f83c62797f382932582a05df921045c86383dad7951f3d639b32456a9", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -190,15 +190,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-router", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522", - "imageTag": "7e29522", - "digest": "sha256:7c40c573078a9c325402bd47b2eb78a46c0c80564a228cf7c9fcf0cfba5bc6d2", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -206,15 +206,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-tunnel-client", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", - "imageTag": "7e29522", - "digest": "sha256:b141dc88557b8f6391aa082fdb37c6f0db5bc3a76c97bb39ba00354a9208abd8", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -222,15 +222,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-edge-proxy", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", - "imageTag": "7e29522", - "digest": "sha256:792505139793c3f3da7769f9dc9d1cb3203ff92797305b62e8f12207c69a9831", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -238,15 +238,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-cli", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", - "imageTag": "7e29522", - "digest": "sha256:43d1c175091bfb462eddd7dd22a780d6485d86bdb052c36b78201606f48fadc1", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -254,15 +254,15 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" }, { "serviceId": "hwlab-agent-skills", - "commitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522", - "imageTag": "7e29522", - "digest": "sha256:c75510038dc33196dc254472e78101d545c0d777a7b33b531f02b39faf2588b5", - "publishState": "published", + "commitId": "c7de474", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474", + "imageTag": "c7de474", + "digest": "not_published", + "publishState": "skeleton-only", "profile": "dev", "namespace": "hwlab-dev", "healthPath": "/health/live", @@ -270,7 +270,7 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "notPublishedReason": null + "notPublishedReason": "publish_not_run" } ], "serviceInventory": { diff --git a/deploy/deploy.json b/deploy/deploy.json index 1bca6dfa..3ec3aba2 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -1,7 +1,7 @@ { "manifestVersion": "v1", "environment": "dev", - "commitId": "7e29522", + "commitId": "c7de474", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "health": { @@ -167,7 +167,7 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -175,9 +175,9 @@ "env": { "HWLAB_ENVIRONMENT": "dev", "HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667", - "HWLAB_COMMIT_ID": "7e29522", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", - "HWLAB_IMAGE_TAG": "7e29522", + "HWLAB_COMMIT_ID": "c7de474", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", + "HWLAB_IMAGE_TAG": "c7de474", "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", @@ -189,7 +189,7 @@ }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -201,7 +201,7 @@ }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -213,7 +213,7 @@ }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -225,7 +225,7 @@ }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -237,7 +237,7 @@ }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -249,7 +249,7 @@ }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -261,7 +261,7 @@ }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -273,7 +273,7 @@ }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -285,7 +285,7 @@ }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -299,7 +299,7 @@ }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -311,7 +311,7 @@ }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -322,13 +322,13 @@ }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", "replicas": 1, "env": { - "HWLAB_SKILLS_COMMIT_ID": "7e29522" + "HWLAB_SKILLS_COMMIT_ID": "c7de474" } } ], diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 3744f2fb..f851d324 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -31,7 +31,7 @@ "containers": [ { "name": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", "ports": [ { "name": "http", @@ -49,15 +49,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "7e29522" + "value": "c7de474" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474" }, { "name": "HWLAB_IMAGE_TAG", - "value": "7e29522" + "value": "c7de474" }, { "name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN", @@ -147,7 +147,7 @@ "containers": [ { "name": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474", "ports": [ { "name": "http", @@ -207,7 +207,7 @@ "containers": [ { "name": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474", "ports": [ { "name": "http", @@ -263,7 +263,7 @@ "containers": [ { "name": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474", "env": [ { "name": "HWLAB_AGENT_SESSION_MODE", @@ -306,7 +306,7 @@ "containers": [ { "name": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474", "ports": [ { "name": "http", @@ -366,7 +366,7 @@ "containers": [ { "name": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474", "ports": [ { "name": "http", @@ -426,7 +426,7 @@ "containers": [ { "name": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474", "ports": [ { "name": "http", @@ -486,7 +486,7 @@ "containers": [ { "name": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474", "ports": [ { "name": "http", @@ -540,7 +540,7 @@ "containers": [ { "name": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474", "ports": [ { "name": "http", @@ -600,7 +600,7 @@ "containers": [ { "name": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474", "ports": [ { "name": "http", @@ -672,7 +672,7 @@ "containers": [ { "name": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474", "ports": [ { "name": "http", @@ -733,7 +733,7 @@ "containers": [ { "name": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474", "env": [ { "name": "HWLAB_CLI_ENDPOINT", @@ -775,7 +775,7 @@ "containers": [ { "name": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474", "ports": [ { "name": "http", @@ -785,7 +785,7 @@ "env": [ { "name": "HWLAB_SKILLS_COMMIT_ID", - "value": "7e29522" + "value": "c7de474" } ], "readinessProbe": { diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index 32a2661d..dd15b119 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -74,10 +74,11 @@ when the base-image blocker is already closed. Use the read-only planner before refreshing or reviewing deploy desired-state: ```sh -node scripts/deploy-desired-state-plan.mjs --pretty +node scripts/deploy-desired-state-plan.mjs --plan --pretty node scripts/deploy-desired-state-plan.mjs --check node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty node scripts/deploy-desired-state-plan.mjs --target-tag --pretty +node scripts/deploy-desired-state-plan.mjs --promotion-commit --check ``` The planner treats `deploy/deploy.json`, @@ -94,6 +95,14 @@ not. A uniform older state under `--target-ref` is a promotion review plan, not failure. The optional report snapshot is contextual evidence only and is not an authoritative desired-state source. +For PR review or CI/CD promotion handoff, use `--promotion-commit +--check`. That mode is stricter than a `--target-ref` plan: every top-level +commit, catalog service commit, deploy service image tag, workload image tag, +and present mirror env (`HWLAB_COMMIT_ID`, `HWLAB_IMAGE`, +`HWLAB_IMAGE_TAG`, `HWLAB_SKILLS_COMMIT_ID`, `HWLAB_IMAGE_DIGEST`) must align +with the promotion commit/tag. A stale but internally consistent manifest is a +blocker in promotion mode because applying it would roll DEV back. + The planner does not prove registry existence, publish success, DEV apply, or M3 DEV-LIVE. Only real DEV observation of `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` with operation, @@ -106,6 +115,7 @@ catalog and deploy manifests in blocked mode: ```sh node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked +node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check ``` This updates `deploy/deploy.json`, `deploy/artifact-catalog.dev.json`, and @@ -118,6 +128,7 @@ report: ```sh node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json +node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check ``` The publish-report mode refuses to update the catalog unless the report says diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index 53e9a679..08d64878 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -153,12 +153,13 @@ remain `status: "blocked"`. Do not turn a base-image or registry blocker into a fake digest or published state. Do not treat runner-process loopback HTTP failure as a publish failure by itself. -Current rebaseline: the committed DEV artifact report is `status: "published"` -for source commit `73b379f`, with `baseImagePreflight.status: "ready"`, -`baseImage: "node:20-bookworm-slim"`, and 13/13 required services carrying -real registry digests. If a later read-only runner cannot fetch catalog -manifests from `127.0.0.1:5000`, treat that as a `#66` registry reachability -dimension, not as a missing base-image or missing-publish blocker. +Committed DEV artifact reports are evidence snapshots and may lag the deploy +desired-state files. Use `scripts/deploy-desired-state-plan.mjs` to see whether +the report source matches the desired-state commit, then refresh the catalog +from a publish report only when that report proves the same target source +commit. If a later read-only runner cannot fetch catalog manifests from +`127.0.0.1:5000`, treat that as a `#66` registry reachability dimension, not as +a missing base-image or missing-publish blocker. ## Known Implementation States diff --git a/docs/dev-base-image-preflight.md b/docs/dev-base-image-preflight.md index 28b9e53a..afb3b98a 100644 --- a/docs/dev-base-image-preflight.md +++ b/docs/dev-base-image-preflight.md @@ -22,11 +22,11 @@ PROD, read secrets, or use UniDesk runtime services as substitutes. preflight only decides whether #35 may proceed to build/publish; it does not prove that any service image was published. -Current rebaseline: `reports/dev-gate/dev-artifacts.json` records -`baseImagePreflight.status: "ready"` for local `node:20-bookworm-slim` and -13/13 published DEV artifacts from source commit `73b379f`. A failed catalog -manifest read from a runner process is a registry reachability question for -`#66`, not proof that the base image or publish digests are missing. +Committed artifact reports may record successful base-image and publish evidence +for an older source commit. Compare their source commit with the desired-state +commit before using them for refresh or apply review. A failed catalog manifest +read from a runner process is a registry reachability question for `#66`, not +proof that the base image or publish digests are missing. ## Command diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index b15e475e..47d62b6a 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -133,15 +133,12 @@ node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json ``` -Current rebaseline: the committed DEV artifact report proves 13/13 required -DEV artifacts from source `73b379f` with `ciPublished=true`, -`registryVerified=true`, and real `sha256` digests, and the base-image -preflight is `ready` on local `node:20-bookworm-slim`. If the selected -`origin/main` target later drifts onto files that change artifact build inputs, -the preflight can still reopen `artifact-source-commit` or -`dev-artifact-publish` even though the base-image blocker stays closed. -Remaining registry manifest-read failures are #66 reachability evidence, not a -reason to reopen the base-image blocker. +Committed DEV artifact reports can prove required service digests only for the +source commit they name. If the selected `origin/main` target drifts onto files +that change artifact build inputs, the preflight can still reopen +`artifact-source-commit` or `dev-artifact-publish` even though the base-image +blocker stays closed. Remaining registry manifest-read failures are #66 +reachability evidence, not a reason to reopen the base-image blocker. This is M3 virtual hardware trusted-loop support only. It does not claim M3 DEV-LIVE unless a real `DO1 -> patch-panel -> DI1` chain is observed through diff --git a/docs/reference/deployment-publish.md b/docs/reference/deployment-publish.md index ff14d7f7..29da9c06 100644 --- a/docs/reference/deployment-publish.md +++ b/docs/reference/deployment-publish.md @@ -58,14 +58,26 @@ publish report: ```sh node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json +node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check ``` If publish is blocked, keep the report blocked rather than inventing digests: ```sh node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked +node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check ``` +The desired-state files are a single deployment truth: `deploy/deploy.json`, +`deploy/artifact-catalog.dev.json`, and `deploy/k8s/base/workloads.yaml` must +carry the same promotion commit and immutable image tag before they are used as +apply input. A `--target-ref` desired-state plan may report a uniform older +state as `promotion_pending`; PR review and CI/CD promotion gates must use +`--promotion-commit --check` so stale or partially refreshed commit, +image, workload, and mirror-env fields are blocked before any DEV apply. +Reports under `reports/` are evidence snapshots only and cannot override this +desired-state contract. + ## API, Edge, Health, And FRP The public route is fixed: @@ -170,7 +182,7 @@ use the repo-owned scripts below instead of ad hoc shell fragments. | Action | Current controlled entrypoint | Gap to close | | --- | --- | --- | | Check deployment contract and FRP/endpoint drift | `node scripts/deploy-contract-plan.mjs --check` | Fold into `hwlab deploy check --env dev`. | -| Plan desired image/workload state | `node scripts/deploy-desired-state-plan.mjs --check` | Fold into `hwlab deploy plan --env dev --file deploy/deploy.json`. | +| Plan desired image/workload state | `node scripts/deploy-desired-state-plan.mjs --plan --pretty` and `node scripts/deploy-desired-state-plan.mjs --promotion-commit --check` | Fold into `hwlab deploy plan --env dev --file deploy/deploy.json` with a promotion commit gate. | | Preflight artifact publish | `node scripts/dev-artifact-publish.mjs --preflight` | Keep as backend for `hwlab artifact publish --preflight`. | | Publish DEV images | `node scripts/dev-artifact-publish.mjs --publish` | CI job should run this and upload digest reports. | | Refresh catalog/desired state | `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json` | Make refresh an explicit CLI/CD step after publish. | diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 8ff47ae7..88bab249 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -6,11 +6,26 @@ import test from "node:test"; import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs"; -async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workloadEnvTag = commitId } = {}) { +async function makeFixture({ + commitId = "abc1234", + catalogCommitId = commitId, + catalogServiceCommitId = catalogCommitId, + catalogImageTag = catalogServiceCommitId.slice(0, 7), + deployEnvCommitId = commitId, + deployEnvImageTag = commitId, + deployEnvImage = null, + deploySkillsCommitId = null, + workloadTag = commitId, + workloadEnvCommitId = commitId, + workloadEnvImageTag = commitId, + workloadEnvImage = null, + workloadSkillsCommitId = null +} = {}) { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-")); await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true }); await mkdir(path.join(root, "reports/dev-gate"), { recursive: true }); const image = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId}`; + const catalogImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${catalogImageTag}`; const workloadImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${workloadTag}`; const deploy = { manifestVersion: "v1", @@ -25,9 +40,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl profile: "dev", replicas: 1, env: { - HWLAB_COMMIT_ID: commitId, - HWLAB_IMAGE: image, - HWLAB_IMAGE_TAG: commitId + HWLAB_COMMIT_ID: deployEnvCommitId, + HWLAB_IMAGE: deployEnvImage ?? image, + HWLAB_IMAGE_TAG: deployEnvImageTag, + ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}) } } ] @@ -39,7 +55,7 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl profile: "dev", namespace: "hwlab-dev", endpoint: "http://74.48.78.17:16667", - commitId, + commitId: catalogCommitId, artifactState: "contract-skeleton", publish: { ciPublished: false, @@ -49,9 +65,9 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl services: [ { serviceId: "hwlab-cloud-api", - commitId, - image, - imageTag: commitId, + commitId: catalogServiceCommitId, + image: catalogImage, + imageTag: catalogImageTag, digest: "not_published", publishState: "skeleton-only", artifactRequired: true @@ -80,9 +96,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl name: "hwlab-cloud-api", image: workloadImage, env: [ - { name: "HWLAB_COMMIT_ID", value: commitId }, - { name: "HWLAB_IMAGE", value: image }, - { name: "HWLAB_IMAGE_TAG", value: workloadEnvTag } + { name: "HWLAB_COMMIT_ID", value: workloadEnvCommitId }, + { name: "HWLAB_IMAGE", value: workloadEnvImage ?? image }, + { name: "HWLAB_IMAGE_TAG", value: workloadEnvImageTag }, + ...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : []) ] } ] @@ -116,8 +133,35 @@ test("target tag review is read-only promotion_pending when current state is uni assert.match(plan.services[0].promotion.deployImage, /:def5678$/u); }); +test("promotion commit check blocks when current desired-state is uniformly older", async () => { + const repoRoot = await makeFixture(); + const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" }); + assert.equal(plan.status, "blocked"); + assert.equal(plan.target.convergence.state, "promotion_mismatch"); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_commit_mismatch")); +}); + +test("promotion commit check passes when every desired-state field is converged", async () => { + const repoRoot = await makeFixture({ commitId: "def5678" }); + const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" }); + assert.equal(plan.status, "pass"); + assert.equal(plan.target.convergence.state, "already_promoted"); + assert.deepEqual(plan.diagnostics, []); +}); + +test("promotion commit check blocks an explicit non-matching target tag", async () => { + const repoRoot = await makeFixture({ commitId: "def5678" }); + const plan = await buildDesiredStatePlan({ + repoRoot, + promotionCommit: "def5678", + targetTag: "abc1234" + }); + assert.equal(plan.status, "blocked"); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_tag_mismatch")); +}); + test("blocks on mirror drift", async () => { - const repoRoot = await makeFixture({ workloadEnvTag: "badcafe" }); + const repoRoot = await makeFixture({ workloadEnvImageTag: "badcafe" }); const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "blocked"); assert.equal(plan.summary.blockers, 1); @@ -125,6 +169,16 @@ test("blocks on mirror drift", async () => { assert.match(plan.diagnostics[0].path, /HWLAB_IMAGE_TAG/u); }); +test("blocks on skills commit mirror drift", async () => { + const repoRoot = await makeFixture({ deploySkillsCommitId: "badcafe" }); + const plan = await buildDesiredStatePlan({ repoRoot }); + assert.equal(plan.status, "blocked"); + assert.ok(plan.diagnostics.some((diagnostic) => + diagnostic.code === "mirror_mismatch" && + diagnostic.path.endsWith("HWLAB_SKILLS_COMMIT_ID") + )); +}); + test("blocks on workload image drift", async () => { const repoRoot = await makeFixture({ workloadTag: "badcafe" }); const plan = await buildDesiredStatePlan({ repoRoot }); @@ -133,8 +187,16 @@ test("blocks on workload image drift", async () => { assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_mismatch")); }); -test("reports partial target drift as a blocker", async () => { - const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvTag: "def5678" }); +test("reports partial target drift when catalog top-level commit moves but services stay old", async () => { + const repoRoot = await makeFixture({ catalogCommitId: "def5678" }); + const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" }); + assert.equal(plan.status, "blocked"); + assert.equal(plan.target.convergence.state, "partial_drift"); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "partial_target_drift")); +}); + +test("reports partial target drift as a blocker", async () => { + const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvImageTag: "def5678" }); const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" }); assert.equal(plan.status, "blocked"); assert.equal(plan.target.convergence.state, "partial_drift"); diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index da60c4a6..a7a04497 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -27,11 +27,21 @@ const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const commitPattern = /^[a-f0-9]{7,40}$/; function parseArgs(argv) { - const args = { check: false, pretty: false, targetRef: null, targetTag: null, help: false }; + const args = { + check: false, + plan: false, + pretty: false, + targetRef: null, + targetTag: null, + promotionCommit: null, + help: false + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--check") { args.check = true; + } else if (arg === "--plan") { + args.plan = true; } else if (arg === "--pretty") { args.pretty = true; } else if (arg === "--target-ref") { @@ -40,6 +50,9 @@ function parseArgs(argv) { } else if (arg === "--target-tag") { args.targetTag = requireValue(argv, index, arg); index += 1; + } else if (arg === "--promotion-commit") { + args.promotionCommit = requireValue(argv, index, arg); + index += 1; } else if (arg === "--help" || arg === "-h") { args.help = true; } else { @@ -59,15 +72,18 @@ function requireValue(argv, index, arg) { function usage() { return [ - "usage: node scripts/deploy-desired-state-plan.mjs [--check] [--pretty] [--target-ref REF] [--target-tag TAG]", + "usage: node scripts/deploy-desired-state-plan.mjs [--plan] [--check] [--pretty] [--target-ref REF] [--target-tag TAG] [--promotion-commit SHA]", "", "Read-only DEV desired-state commit/image planner.", "", "Options:", - " --check exit non-zero only when desired-state sources are missing, invalid, or partially drifted", + " --plan explicitly request the default read-only JSON plan", + " --check exit non-zero when desired-state sources are invalid, drifted, or do not match --promotion-commit", " --pretty print indented JSON", " --target-ref REF resolve REF with git and review promotion to its short commit tag", " --target-tag TAG review promotion to an explicit image tag without proving registry existence", + " --promotion-commit SHA", + " require every authoritative desired-state commit/image/tag field to match this promotion commit", "", "This command reads repository files only. It does not build, pull, push, kubectl apply, restart services, or touch PROD." ].join("\n"); @@ -285,8 +301,8 @@ function assertTargetTag(ctx, tag, pathName) { } } -async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) { - if (!targetRef && !targetTag) return null; +async function resolveTarget(repoRoot, { targetRef, targetTag, promotionCommit }, ctx) { + if (!targetRef && !targetTag && !promotionCommit) return null; let commitId = null; let shortCommitId = null; if (targetRef) { @@ -304,14 +320,31 @@ async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) { }); } } + if (promotionCommit) { + if (!commitPattern.test(String(promotionCommit))) { + addMismatch(ctx, "invalid_promotion_commit", "--promotion-commit", "promotion commit must be a short or full lowercase Git SHA", "7-40 lowercase hex", promotionCommit); + } else { + const promotionShortCommitId = promotionCommit.slice(0, 7); + if (commitId && !commitEquivalent(promotionCommit, commitId)) { + addMismatch(ctx, "promotion_commit_ref_mismatch", "--promotion-commit", "promotion commit must match the resolved target ref", commitId, promotionCommit); + } + commitId = commitId ?? promotionCommit; + shortCommitId = shortCommitId ?? promotionShortCommitId; + } + } const tag = targetTag ?? shortCommitId; if (tag) assertTargetTag(ctx, tag, "--target-tag"); + if (promotionCommit && commitPattern.test(String(promotionCommit)) && targetTag && targetTag !== promotionCommit.slice(0, 7)) { + addMismatch(ctx, "promotion_tag_mismatch", "--target-tag", "promotion target tag must be the short promotion commit", promotionCommit.slice(0, 7), targetTag); + } return { targetRef, + promotionCommit, commitId, shortCommitId, tag, - tagSource: targetTag ? "target-tag" : "target-ref-short-commit" + tagSource: targetTag ? "target-tag" : promotionCommit ? "promotion-commit-short" : "target-ref-short-commit", + requireConvergence: Boolean(promotionCommit) }; } @@ -350,6 +383,7 @@ function buildTargetConvergence(ctx, target) { const matching = comparable.filter((observation) => observation.matchesTarget === true); const pending = comparable.filter((observation) => observation.matchesTarget === false); const partial = matching.length > 0 && pending.length > 0; + const promotionRequired = target.requireConvergence === true; if (partial) { addDiagnostic(ctx, { @@ -362,13 +396,28 @@ function buildTargetConvergence(ctx, target) { pending: pending.length } }); + } else if (promotionRequired && pending.length > 0) { + addDiagnostic(ctx, { + code: "promotion_commit_mismatch", + path: "desired-state", + message: "desired-state fields do not match the required promotion commit/tag", + expected: { + commitId: target.commitId, + imageTag: target.tag + }, + actual: { + pending: pending.length + } + }); } const state = partial ? "partial_drift" : pending.length === 0 ? "already_promoted" - : "promotion_pending"; + : promotionRequired + ? "promotion_mismatch" + : "promotion_pending"; return { state, comparableFields: comparable.length, @@ -379,7 +428,7 @@ function buildTargetConvergence(ctx, target) { }; } -function reportHints(report) { +function reportHints(report, desiredCommitId) { if (!report) { return { path: artifactReportPath, @@ -389,15 +438,19 @@ function reportHints(report) { }; } const artifactPublish = report.artifactPublish ?? {}; + const reportCommitId = report.commitId ?? null; + const artifactSourceCommitId = artifactPublish.sourceCommitId ?? null; return { path: artifactReportPath, present: true, authoritative: false, note: "report snapshots are contextual evidence only; deploy desired-state remains authoritative in deploy/, not reports/", - commitId: report.commitId ?? null, + commitId: reportCommitId, + matchesDesiredState: reportCommitId ? commitEquivalent(reportCommitId, desiredCommitId) : null, artifactPublish: { status: artifactPublish.status ?? null, - sourceCommitId: artifactPublish.sourceCommitId ?? null, + sourceCommitId: artifactSourceCommitId, + sourceMatchesDesiredState: artifactSourceCommitId ? commitEquivalent(artifactSourceCommitId, desiredCommitId) : null, publishedCount: artifactPublish.publishedCount ?? null, serviceCount: artifactPublish.serviceCount ?? null, registryVerified: artifactPublish.registryVerified ?? null, @@ -422,12 +475,19 @@ function servicePromotion(service, target) { } function targetCommands(target) { - if (!target?.targetRef) return []; - return [ + if (!target?.targetRef && !target?.promotionCommit) return []; + const commands = []; + if (target.promotionCommit) { + commands.push(`node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.promotionCommit} --check --pretty`); + } + if (target.targetRef) { + commands.push( `node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`, `node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked`, `node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath}` - ]; + ); + } + return commands; } export async function buildDesiredStatePlan(options = {}) { @@ -441,7 +501,8 @@ export async function buildDesiredStatePlan(options = {}) { ]); const target = await resolveTarget(repoRoot, { targetRef: options.targetRef ?? null, - targetTag: options.targetTag ?? null + targetTag: options.targetTag ?? null, + promotionCommit: options.promotionCommit ?? null }, ctx); const deployServices = deploy.services ?? []; @@ -674,7 +735,7 @@ export async function buildDesiredStatePlan(options = {}) { prod: false, devLiveClaim: false }, - checkSemantics: "--check exits non-zero only for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, or partial target drift. A uniform older desired-state under --target-ref is a read-only promotion plan, not a check failure.", + checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, partial target drift, or any mismatch with --promotion-commit. A uniform older desired-state under --target-ref alone is a read-only promotion plan, not a check failure.", summary: { desiredCommitId, desiredImageTag, @@ -699,7 +760,7 @@ export async function buildDesiredStatePlan(options = {}) { nonAuthoritativeEvidence: [artifactReportPath], note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence." }, - reportHints: reportHints(artifactReport), + reportHints: reportHints(artifactReport, desiredCommitId), services, diagnostics: ctx.diagnostics }; @@ -714,7 +775,8 @@ export async function runDeployDesiredStatePlanCli(argv = process.argv.slice(2), const plan = await buildDesiredStatePlan({ repoRoot: options.repoRoot, targetRef: args.targetRef, - targetTag: args.targetTag + targetTag: args.targetTag, + promotionCommit: args.promotionCommit }); process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`); if (args.check && plan.status === "blocked") {