diff --git a/docs/issue/issue-9-k3s-user-service-cicd-state.md b/docs/issue/issue-9-k3s-user-service-cicd-state.md index c798d20f..78cd95fc 100644 --- a/docs/issue/issue-9-k3s-user-service-cicd-state.md +++ b/docs/issue/issue-9-k3s-user-service-cicd-state.md @@ -2,16 +2,17 @@ Snapshot date: 2026-05-21 -This matrix closes the current review pass for the `decision-center`, `mdtodo`, `claudeqq` and `todo-note` lane. It records only focused smoke evidence: health, runtime commit/digest or deployment labels, private proxy API checks and registry artifact presence. No full e2e/Playwright run, backend-core restart, Code Queue restart, backend-core prod deploy or Code Queue prod deploy is part of this lane. +This matrix closes the current review pass for the `decision-center`, `mdtodo`, `claudeqq`, `todo-note` and frontend artifact lane. It records only focused smoke evidence: health, runtime commit/digest or deployment labels, private proxy API checks and registry artifact presence. No full e2e/Playwright run, backend-core restart, Code Queue restart, backend-core prod deploy, frontend deploy, publish, or Code Queue prod deploy is part of this lane. ## Shared Findings | Area | State | Impact | Next step | | --- | --- | --- | --- | -| Artifact producer catalog | `CI.json` has source-build producer entries for all four services. | The standard producer route is modeled for the whole lane. | Keep producer commands as the only source-build entrypoint: `ci publish-user-service --service --commit `. | -| CD consumer plan | `deploy plan --env dev|prod --service ` resolves standard consumers for all four services: k3s-managed for `decision-center`, `mdtodo`, `claudeqq`; main-server Compose artifact consumer for `todo-note`. | The dry-run contract exists, but live apply still depends on artifact and verification readiness. | Use dev -> focused smoke -> prod only after the desired artifact exists and the preflight channel is healthy. | -| Artifact-registry probe | Direct D601 registry API access is healthy, but remote frontend `artifact-registry health` and CI publish dry-run registry checks fail because the provider Host SSH command is rejected as too long (`4039` bytes). | The registry itself is reachable from D601, but the standard preflight tool path reports `registry` as infra-blocked. | Split or shrink the registry health probe before relying on remote CI dry-run as release evidence. | +| Artifact producer catalog | `CI.json` has source-build producer entries for the reviewed services, including `frontend`. | The standard producer route is modeled for the lane. | Keep producer commands as the only source-build entrypoint: `ci publish-user-service --service --commit `. | +| CD consumer plan | `deploy plan --env dev|prod --service ` resolves standard consumers: k3s-managed for `decision-center`, `mdtodo`, `claudeqq` and dev `frontend`; main-server Compose artifact consumer for `todo-note` and prod `frontend`. | The dry-run contract exists, but live apply still depends on artifact and verification readiness. | Use dev -> focused smoke -> prod only after the desired artifact exists and the preflight channel is healthy. | +| Artifact-registry probe | Remote frontend Host SSH probing is no longer blocked by command length. Registry runtime API is reachable and frontend publish dry-run can report ready, while `artifact-registry health` still reports service drift in rendered config / registry image scopes. | Runtime artifact reads are usable for contract evidence; registry service drift remains an infra hygiene item and should not be confused with a frontend publish blocker. | Repair registry rendered-config/image drift separately before treating registry health as fully clean. | | Dev service catalog | `decision-center-dev` exists in k3s, while `mdtodo-dev` and `claudeqq-dev` do not. The adapter public catalog did not expose the dev aliases during this pass. | Dev promotion cannot be claimed for `mdtodo` or `claudeqq`; `decision-center-dev` is reachable through direct k3s service proxy only. | Publish artifacts first, then create/verify dev consumers and register dev aliases where intended. | +| Frontend closure contract | `bun scripts/frontend-artifact-lane-contract-test.ts` is the standard lightweight contract for the frontend artifact sample. | Frontend lane evidence is now repeatable without live deploy, publish, service restart, or Playwright. | Keep the contract green whenever frontend deploy.json, health metadata, artifact digest, producer preflight, or CD dry-run shape changes. | ## Service Matrix @@ -21,11 +22,13 @@ This matrix closes the current review pass for the `decision-center`, `mdtodo`, | `mdtodo` | `127.0.0.1:5000/unidesk/mdtodo:75fb6757b2504ba86d61f2587fb34a9c9ed4019a`; registry HEAD returned 404, so no digest was available. | k3s-managed artifact consumer on D601. CI producer is UniDesk source-build from `src/components/microservices/mdtodo/Dockerfile`. | `unidesk-dev/mdtodo-dev` does not exist. | `unidesk/mdtodo` is ready 1/1. Deployment annotations record deploy and requested commit `75fb6757b2504ba86d61f2587fb34a9c9ed4019a`; health returned `ok=true`, and `/live` returned 200. Health does not expose deploy metadata. | Partial. Prod is healthy and annotated with the desired commit, but the desired registry artifact is absent and dev is absent. | Publish the desired artifact, add deploy metadata to health or keep strict label/annotation verification, then run dev -> focused smoke -> prod if prod replacement is still needed. | | `claudeqq` | `127.0.0.1:5000/unidesk/claudeqq:203b1f46684c91340ecbbd8a74502bd55e4f2011`; registry HEAD returned 404, so no digest was available. | k3s-managed artifact consumer on D601. CI producer uses the external Gitee source plus UniDesk adapter/overlay. | `unidesk-dev/claudeqq-dev` does not exist. | `unidesk/claudeqq` is ready 1/1. Deployment annotations and `/health` report commit/requested commit `203b1f46684c91340ecbbd8a74502bd55e4f2011`; health also reports NapCat `logged_in`. Focused API probes for `/api/events/recent` and `/api/events/subscriptions` returned 404. | Partial. Prod commit alignment and health are good, but the desired registry artifact is absent, dev is absent and the expected event API surface is not verified. | Publish the desired artifact, create/verify dev, and either fix or document the current event API paths before any prod artifact replacement. | | `todo-note` | `127.0.0.1:5000/unidesk/todo-note:a14ce0eb855a685fa17b47adacd54623e72cd2ff`; registry HEAD returned 404, so no digest was available. | Main-server Compose artifact consumer. CI producer uses the external Gitee source. CD plan is pull-only and no-build for Compose service `todo-note`, container `todo-note-backend`. | The dev/prod consumer plans resolve, but no live dev apply was attempted because the desired artifact is absent. | Runtime health returned 200 with PostgreSQL storage and running reminders. Private proxy `/api/instances` returned 200. The running container image is `unidesk-todo-note`; runtime labels do not expose `unidesk.ai/source-commit`, and health does not expose deploy metadata. | Not yet. Runtime behavior is healthy, but image digest/commit proof is missing and the desired registry artifact is absent. | Publish the desired artifact, then use the Compose artifact consumer to recreate only `todo-note` with no build/no deps and verify image labels plus health deploy metadata. | +| `frontend` | `127.0.0.1:5000/unidesk/frontend:b5486a61ab0aa6c227366a95d1afa68281584359`, registry v2 manifest digest `sha256:76d7c47e797605470959ca2274f116149bdc367e6fa155913d19f42516e5b9e4`. | CI producer is `ci publish-user-service` from `src/components/frontend/Dockerfile`. Dev CD is D601 native k3s `frontend-dev`; prod CD is master-server Compose `frontend` / `unidesk-frontend`; both are pull-only artifact consumers. | Public dev `http://74.48.78.17:18083/health` reports `ok=true`, `environment=dev`, `namespace=unidesk-dev`, and deploy commit/requestedCommit `b5486a61ab0aa6c227366a95d1afa68281584359`. | Public prod `http://74.48.78.17:18081/health` reports `ok=true` and deploy commit/requestedCommit `b5486a61ab0aa6c227366a95d1afa68281584359`. Remote `ci publish-user-service --service frontend --commit b5486... --dry-run` reports `runnerDisposition=ready`, all control channels ready, and no missing control channels. | Complete for CI/CD contract. Dev/prod desired and live commit match the artifact; CD dry-runs are non-mutating and no-build. | Remaining UI route acceptance is a manual product/UI gate and is independent of CI/CD artifact correctness. | ## Execution Decision -No live deployment was executed in this pass. +No live deployment or publish was executed in this pass. - `decision-center` has an available desired artifact, but both dev and prod are currently healthy on `b5486a61...`; the task scope asked for review and gap recording unless a repair is clearly safe. The standard remote registry preflight path is currently infra-blocked, so this pass records drift instead of changing prod/dev. - `mdtodo`, `claudeqq` and `todo-note` do not have the desired registry artifact tags, so live apply would not satisfy the artifact-consumer contract. +- `frontend` is the first batch sample that can be marked complete for the CI/CD artifact lane: desired commit, registry artifact digest, dev/prod health metadata, publish dry-run readiness and dev/prod CD no-build dry-runs are aligned. - Focused smoke stayed limited to health, deployment metadata, registry HEAD/tag checks and small private proxy API calls. diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index 1d3d7179..c3566c20 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -268,6 +268,14 @@ This matrix describes the next promotion stage after dry-run coverage is in plac | `k3sctl-adapter` | `master` | source-build supported | plan/dry-run only | no normal dev target; only control-bridge health and recovery evidence | prod live apply requires supervisor confirmation | bridge recovery, k3s fault-domain isolation, no worker self-replacement | `GPT-5.5` | | `filebrowser` / `filebrowser-d601` | `master` | upstream-image blocked | pull-only mirror target | digest resolution, mirror governance and private proxy health only | not in this phase | upstream digest/mirror worker not yet implemented | `DeepSeek` for evidence summarization, `GPT-5.5` for blocker resolution | +Frontend lane closure evidence is intentionally lightweight and non-mutating: + +```bash +bun scripts/frontend-artifact-lane-contract-test.ts +``` + +The contract fixes the current sample around one artifact lane: `deploy.json` dev/prod both request the same frontend commit, `CI.json` keeps the producer as `ci publish-user-service`, the D601 registry manifest digest is recorded, dev and prod health expose matching `deploy.commit` / `deploy.requestedCommit`, `ci publish-user-service --dry-run` is ready, and both CD dry-runs are artifact consumers with no source build. + Planned parallelism for the next wave should be three lanes: 1. Lane A: `frontend`, `baidu-netdisk`, `project-manager`, `oa-event-flow`. diff --git a/docs/reference/user-service-delivery.md b/docs/reference/user-service-delivery.md index 2284d554..17164480 100644 --- a/docs/reference/user-service-delivery.md +++ b/docs/reference/user-service-delivery.md @@ -65,6 +65,7 @@ Frontend is the canonical user-service UI sample. It is not released by target-s - The minimal standard artifact command is `bun scripts/cli.ts ci publish-user-service --service frontend --commit --wait-ms 1200000`. - The expected artifact is `127.0.0.1:5000/unidesk/frontend:` plus its registry digest from the CI output. +- The lightweight closure contract is `bun scripts/frontend-artifact-lane-contract-test.ts`. It checks `deploy.json` dev/prod desired commits, the `CI.json` producer entry, the observed registry digest, dev/prod `/health.deploy.commit` and `deploy.requestedCommit`, producer dry-run readiness, and dev/prod CD dry-run no-build targets. - Dev CD consumes the same artifact with `bun scripts/cli.ts deploy apply --env dev --service frontend`; it imports the image into D601 native k3s, rolls out `frontend-dev`, syncs auth/session metadata from main-server config, and verifies `/health.deploy.commit`. - Production CD consumes the same artifact with `bun scripts/cli.ts deploy apply --env prod --service frontend`; it recreates only the master-server Compose `frontend` service with `--no-build --no-deps --force-recreate` and verifies image labels plus `/health.deploy.commit`. - `server rebuild frontend` remains a maintenance/local rebuild path only. It is not the standard versioned release truth for frontend. diff --git a/scripts/frontend-artifact-lane-contract-test.ts b/scripts/frontend-artifact-lane-contract-test.ts new file mode 100644 index 00000000..9e82941a --- /dev/null +++ b/scripts/frontend-artifact-lane-contract-test.ts @@ -0,0 +1,304 @@ +import { readFileSync } from "node:fs"; +import { rootPath } from "./src/config"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; + +type JsonRecord = Record; + +const frontendCommit = "b5486a61ab0aa6c227366a95d1afa68281584359"; +const frontendDigest = "sha256:76d7c47e797605470959ca2274f116149bdc367e6fa155913d19f42516e5b9e4"; +const frontendRepo = "https://github.com/pikasTech/unidesk"; +const frontendDockerfile = "src/components/frontend/Dockerfile"; +const frontendRegistryRepository = "unidesk/frontend"; +const frontendImageRef = `127.0.0.1:5000/${frontendRegistryRepository}:${frontendCommit}`; + +const observedEvidence = { + registry: { + imageRef: frontendImageRef, + contentType: "application/vnd.docker.distribution.manifest.v2+json", + digest: frontendDigest, + }, + health: { + dev: { + ok: true, + service: "unidesk-frontend", + deploy: { + serviceId: "frontend", + repo: frontendRepo, + commit: frontendCommit, + requestedCommit: frontendCommit, + }, + environment: "dev", + namespace: "unidesk-dev", + databaseName: "unidesk_dev", + serviceId: "frontend", + deployRef: "origin/master:deploy.json#environments.dev.services.frontend", + }, + prod: { + ok: true, + service: "unidesk-frontend", + deploy: { + serviceId: "frontend", + repo: frontendRepo, + commit: frontendCommit, + requestedCommit: frontendCommit, + }, + }, + }, + publishDryRun: { + ok: true, + mode: "dry-run-preflight", + runnerDisposition: "ready", + supportedArtifactPublish: true, + serviceId: "frontend", + commit: frontendCommit, + missingControlChannels: [], + controlChannels: [ + { channel: "backend-core", ok: true }, + { channel: "database", ok: true }, + { channel: "provider", ok: true }, + { channel: "registry", ok: true }, + ], + registry: { + runtimeApiHealthy: true, + decision: "service-degraded", + failedScopes: ["rendered-config", "registry-image"], + healthyScopes: ["systemd", "docker", "registry-container", "loopback-listener", "registry-api", "storage"], + }, + artifactSummary: { + serviceId: "frontend", + sourceCommit: frontendCommit, + sourceRepo: frontendRepo, + dockerfile: frontendDockerfile, + registry: "127.0.0.1:5000", + repository: "127.0.0.1:5000/unidesk/frontend", + tag: frontendCommit, + imageRef: frontendImageRef, + digest: null, + digestRef: null, + }, + controlledPublish: { + environment: "D601", + namespace: "unidesk-ci", + pipeline: "unidesk-user-service-artifact-publish", + command: `bun scripts/cli.ts ci publish-user-service --service frontend --commit ${frontendCommit} --wait-ms 1200000`, + requiresReadyControlChannels: ["backend-core", "database", "provider", "registry"], + }, + boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart", + }, +} satisfies JsonRecord; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): JsonRecord { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); + return value as JsonRecord; +} + +function asArray(value: unknown, label: string): unknown[] { + assertCondition(Array.isArray(value), `${label} must be an array`, { value }); + return value as unknown[]; +} + +function stringArray(value: unknown, label: string): string[] { + return asArray(value, label).map(String); +} + +function manifestService(manifest: JsonRecord, environment: "dev" | "prod", serviceId: string): JsonRecord { + const environments = asRecord(manifest.environments, "deploy.json.environments"); + const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); + const services = asArray(env.services, `deploy.json.environments.${environment}.services`); + const service = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === serviceId); + assertCondition(service !== undefined, `deploy.json ${environment} must include ${serviceId}`, env); + return service as JsonRecord; +} + +function assertDeployJson(): void { + const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); + assertCondition(manifest.schemaVersion === 2, "deploy.json must use schemaVersion=2", manifest); + for (const environment of ["dev", "prod"] as const) { + const service = manifestService(manifest, environment, "frontend"); + assertCondition(service.repo === frontendRepo, `${environment} frontend repo must stay on UniDesk`, service); + assertCondition(service.commitId === frontendCommit, `${environment} frontend desired commit must match the reviewed artifact`, service); + } +} + +function assertCiCatalog(): void { + const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); + const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); + const frontend = artifacts.find((item) => item.serviceId === "frontend"); + assertCondition(frontend !== undefined, "CI.json must include frontend", catalog); + const artifact = frontend as JsonRecord; + const source = asRecord(artifact.source, "CI.json frontend.source"); + const image = asRecord(artifact.image, "CI.json frontend.image"); + assertCondition(artifact.kind === "source-build", "frontend CI producer must be source-build", artifact); + assertCondition(artifact.status === "supported", "frontend CI producer must be supported", artifact); + assertCondition(artifact.producer === "ci publish-user-service", "frontend CI producer must use publish-user-service", artifact); + assertCondition(source.repo === frontendRepo, "frontend source repo mismatch", source); + assertCondition(source.dockerfile === frontendDockerfile, "frontend Dockerfile mismatch", source); + assertCondition(image.repository === frontendRegistryRepository, "frontend registry repository mismatch", image); +} + +function assertObservedHealth(environment: "dev" | "prod"): void { + const health = asRecord(asRecord(observedEvidence.health, "health evidence")[environment], `${environment} health`); + const deploy = asRecord(health.deploy, `${environment} health.deploy`); + assertCondition(health.ok === true, `${environment} health must be ok`, health); + assertCondition(health.service === "unidesk-frontend", `${environment} health service mismatch`, health); + assertCondition(deploy.serviceId === "frontend", `${environment} health deploy service id mismatch`, deploy); + assertCondition(deploy.repo === frontendRepo, `${environment} health deploy repo mismatch`, deploy); + assertCondition(deploy.commit === frontendCommit, `${environment} health deploy commit mismatch`, deploy); + assertCondition(deploy.requestedCommit === frontendCommit, `${environment} health requested commit mismatch`, deploy); + if (environment === "dev") { + assertCondition(health.environment === "dev", "dev health must expose dev environment", health); + assertCondition(health.namespace === "unidesk-dev", "dev health must expose unidesk-dev namespace", health); + assertCondition(health.deployRef === "origin/master:deploy.json#environments.dev.services.frontend", "dev health deployRef mismatch", health); + } +} + +function assertRegistryDigest(): void { + const registry = asRecord(observedEvidence.registry, "registry evidence"); + assertCondition(registry.imageRef === frontendImageRef, "registry image ref mismatch", registry); + assertCondition(registry.contentType === "application/vnd.docker.distribution.manifest.v2+json", "registry digest must come from the v2 manifest", registry); + assertCondition(registry.digest === frontendDigest, "registry digest mismatch", registry); + assertCondition(/^sha256:[0-9a-f]{64}$/u.test(String(registry.digest)), "registry digest must be a sha256 manifest digest", registry); +} + +function assertPublishDryRunReady(): void { + const preflight = asRecord(observedEvidence.publishDryRun, "publish dry-run evidence"); + const missingControlChannels = stringArray(preflight.missingControlChannels, "publish missingControlChannels"); + const artifactSummary = asRecord(preflight.artifactSummary, "publish artifactSummary"); + const controlledPublish = asRecord(preflight.controlledPublish, "publish controlledPublish"); + const registry = asRecord(preflight.registry, "publish registry"); + const controlChannels = asArray(preflight.controlChannels, "publish controlChannels").map((item, index) => asRecord(item, `publish controlChannels[${index}]`)); + + assertCondition(preflight.ok === true, "frontend publish dry-run must be ready", preflight); + assertCondition(preflight.mode === "dry-run-preflight", "frontend publish dry-run mode mismatch", preflight); + assertCondition(preflight.runnerDisposition === "ready", "frontend publish dry-run runnerDisposition mismatch", preflight); + assertCondition(preflight.supportedArtifactPublish === true, "frontend publish must be supported", preflight); + assertCondition(missingControlChannels.length === 0, "frontend publish dry-run must not miss control channels", preflight); + for (const channel of ["backend-core", "database", "provider", "registry"]) { + assertCondition(controlChannels.some((item) => item.channel === channel && item.ok === true), `publish dry-run ${channel} channel must be ready`, controlChannels); + } + assertCondition(registry.runtimeApiHealthy === true, "registry runtime API must be healthy for publish readiness", registry); + assertCondition(stringArray(registry.healthyScopes, "publish registry.healthyScopes").includes("registry-api"), "registry-api scope must be healthy", registry); + assertCondition(artifactSummary.imageRef === frontendImageRef, "publish artifact image must be commit-pinned", artifactSummary); + assertCondition(artifactSummary.digest === null && artifactSummary.digestRef === null, "publish dry-run must not fake a digest", artifactSummary); + assertCondition(controlledPublish.environment === "D601", "controlled publish environment mismatch", controlledPublish); + assertCondition(controlledPublish.namespace === "unidesk-ci", "controlled publish namespace mismatch", controlledPublish); + assertCondition(controlledPublish.pipeline === "unidesk-user-service-artifact-publish", "controlled publish pipeline mismatch", controlledPublish); + assertCondition(stringArray(controlledPublish.requiresReadyControlChannels, "controlled publish channels").join(",") === "backend-core,database,provider,registry", "controlled publish required channel order mismatch", controlledPublish); + assertCondition(String(preflight.boundary).includes("read-only"), "publish dry-run boundary must be read-only", preflight); + assertCondition(!String(preflight.boundary).includes("service restart") || String(preflight.boundary).includes("no service restart"), "publish dry-run boundary must forbid restarts", preflight); +} + +function assertNoRuntimeBuild(plan: JsonRecord, environment: "dev" | "prod"): void { + const build = asRecord(plan.build, `${environment} build`); + const registry = asRecord(plan.registry, `${environment} registry`); + const source = asRecord(plan.source, `${environment} source`); + const target = asRecord(plan.target, `${environment} target`); + const validation = stringArray(plan.validation, `${environment} validation`); + const labels = asRecord(plan.requiredLabels, `${environment} labels`); + const registryProbe = asRecord(plan.registryProbe, `${environment} registryProbe`); + + assertCondition(plan.ok === true && plan.supported === true, `${environment} frontend dry-run must be supported`, plan); + assertCondition(plan.dryRun === true && plan.mutation === false, `${environment} frontend dry-run must be non-mutating`, plan); + assertCondition(plan.environment === environment, `${environment} dry-run environment mismatch`, plan); + assertCondition(plan.providerId === "D601", `${environment} dry-run provider mismatch`, plan); + assertCondition(plan.serviceId === "frontend", `${environment} dry-run service id mismatch`, plan); + assertCondition(plan.commit === frontendCommit, `${environment} dry-run commit mismatch`, plan); + assertCondition(plan.sourceImage === frontendImageRef, `${environment} dry-run source image mismatch`, plan); + assertCondition(source.repo === frontendRepo && source.commit === frontendCommit && source.dockerfile === frontendDockerfile, `${environment} source boundary mismatch`, source); + assertCondition(registry.imageRef === frontendImageRef, `${environment} registry image ref mismatch`, registry); + assertCondition(registry.digest === null, `${environment} dry-run must not fake registry digest`, registry); + assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${environment} digest source must name live registry HEAD`, registry); + assertCondition(registryProbe.method === "HEAD", `${environment} registry probe must be HEAD-only`, registryProbe); + assertCondition(labels["unidesk.ai/service-id"] === "frontend", `${environment} service label mismatch`, labels); + assertCondition(labels["unidesk.ai/source-commit"] === frontendCommit, `${environment} source commit label mismatch`, labels); + assertCondition(labels["unidesk.ai/dockerfile"] === frontendDockerfile, `${environment} Dockerfile label mismatch`, labels); + assertCondition(build.willCompile === false, `${environment} CD must not compile`, build); + assertCondition(build.willRunCargoBuild === false, `${environment} CD must not run cargo build`, build); + assertCondition(build.willRunDockerBuild === false, `${environment} CD must not run docker build`, build); + assertCondition(build.willRunDockerComposeBuild === false, `${environment} CD must not run docker compose build`, build); + assertCondition(build.producerBoundary === "ci publish-user-service", `${environment} producer boundary mismatch`, build); + assertCondition(String(plan.boundary ?? "").includes("artifact-consumer only"), `${environment} boundary must say artifact-consumer only`, plan); + assertCondition(String(plan.boundary ?? "").includes("never builds source"), `${environment} boundary must forbid runtime source builds`, plan); + + if (environment === "dev") { + assertCondition(target.kind === "d601-k3s", "dev frontend target must be D601 k3s", target); + assertCondition(target.namespace === "unidesk-dev", "dev frontend namespace mismatch", target); + assertCondition(target.deployment === "frontend-dev", "dev frontend deployment mismatch", target); + assertCondition(target.service === "frontend-dev", "dev frontend service mismatch", target); + assertCondition(target.runtimeImage === `unidesk-frontend:${frontendCommit}`, "dev frontend runtime image mismatch", target); + assertCondition(validation.some((line) => line.includes("Kubernetes API service proxy")), "dev validation must use Kubernetes API service proxy", validation); + } else { + assertCondition(target.kind === "compose", "prod frontend target must be Compose", target); + assertCondition(target.runtimeHost === "main-server", "prod frontend runtime host mismatch", target); + assertCondition(target.composeService === "frontend", "prod frontend Compose service mismatch", target); + assertCondition(target.containerName === "unidesk-frontend", "prod frontend container mismatch", target); + assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate frontend", "prod frontend deploy command shape mismatch", target); + assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), "prod validation must require health commit metadata", validation); + } +} + +async function main(): Promise { + assertDeployJson(); + assertCiCatalog(); + assertRegistryDigest(); + assertObservedHealth("dev"); + assertObservedHealth("prod"); + assertPublishDryRunReady(); + + const devDryRun = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "dev", + "--service", + "frontend", + "--commit", + frontendCommit, + "--dry-run", + ]), "dev artifact dry-run"); + const prodDryRun = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "prod", + "--service", + "frontend", + "--commit", + frontendCommit, + "--dry-run", + ]), "prod artifact dry-run"); + + assertNoRuntimeBuild(devDryRun, "dev"); + assertNoRuntimeBuild(prodDryRun, "prod"); + + process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "deploy.json dev/prod desired frontend commit matches the reviewed artifact", + "CI.json frontend producer remains ci publish-user-service source-build", + "registry v2 manifest digest is pinned as contract evidence", + "dev and prod health report matching deploy.commit/requestedCommit", + "publish-user-service dry-run is ready and read-only", + "dev CD dry-run is D601 k3s artifact-only and non-mutating", + "prod CD dry-run is main-server Compose artifact-only and non-mutating", + ], + frontend: { + commit: frontendCommit, + artifact: frontendImageRef, + digest: frontendDigest, + devHealthCommit: frontendCommit, + prodHealthCommit: frontendCommit, + uiAcceptanceIndependentOfCiCd: true, + }, + dryRunTargets: { + dev: asRecord(devDryRun.target, "dev target"), + prod: asRecord(prodDryRun.target, "prod target"), + }, + }, null, 2)}\n`); +} + +if (import.meta.main) { + await main(); +}