From da15eb91b62e810b47b1cb8b3e6cedc062129a60 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 14:44:26 +0000 Subject: [PATCH] feat(infra): Wave 2C same-image digest pin for test/main promote Build/apply candidate images once, require digests on promote, and keep pinned env-service from rebuilding divergent images. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 5 +- .../2026-09-16-wave2c-same-image-digest.md | 41 +++++ ...eature-matrix-production-readiness-plan.md | 4 +- .doc/status/FEATURE-MATRIX.md | 2 +- ...16-wave2c-same-image-digest-test-report.md | 69 ++++++++ ...ed14febafe33f72ae14232d938f7e5.images.json | 19 +++ ...08bdcfced14febafe33f72ae14232d938f7e5.json | 18 +++ ...fffffffffffffffffffffffffffffffffffff.json | 16 ++ deploy/compose/images.pinned.env.example | 12 ++ docker-compose.main.yml | 4 +- docker-compose.test.yml | 4 +- scripts/candidate-images-apply.sh | 147 ++++++++++++++++++ scripts/candidate-images-build.sh | 110 +++++++++++++ scripts/env-service.sh | 31 +++- scripts/git-workflow-helper.sh | 14 +- scripts/promotion-receipt-check.sh | 28 ++-- scripts/promotion-receipt-write.sh | 33 +++- scripts/run-nightly-real-pi.sh | 16 ++ 18 files changed, 546 insertions(+), 27 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-16-wave2c-same-image-digest.md create mode 100644 .doc/test-reports/2026-09-16-wave2c-same-image-digest-test-report.md create mode 100644 .doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.images.json create mode 100644 .doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.json create mode 100644 .doc/test-reports/assets/promotion-receipts/ffffffffffffffffffffffffffffffffffffffff.json create mode 100644 deploy/compose/images.pinned.env.example create mode 100755 scripts/candidate-images-apply.sh create mode 100755 scripts/candidate-images-build.sh diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 6599a02f..1e5d154c 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **Wave 2B done**:nightly PI + promotion receipt 已合入;Artifact same-image 仍 partial +1. **Wave 2C done**:same-image digest pin 已合入;下一相 Wave 3 go/no-go 2. **Wave 1 done**:PU5A race + PU3 IAM fail-closed 已合入 `dev` 3. **Wave 3 候选**:Playwright 双标签 / MULTI_INTENT tier;Artifact same-image digest 晋升仍开放 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-16-wave2c-same-image-digest | feature/wave2c-same-image-digest | - | infra | done | | Cursor | 2026-09-16-wave2b-nightly-promotion-receipt | feature/wave2b-nightly-promotion-receipt | - | ci | done | | Cursor | 2026-09-16-wave2-real-pi-evidence | feature/wave2-real-pi-evidence | - | intent-plan | done | | Cursor | 2026-09-16-wave1-race-tempdir-cleanup | fix/race-tempdir-cleanup | - | ci | done | @@ -101,7 +102,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-16T14:50Z | wave2c-same-image-digest | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-16-wave2c-same-image-digest.md b/.doc/ops/short-term/2026-09-16-wave2c-same-image-digest.md new file mode 100644 index 00000000..c80d1e4b --- /dev/null +++ b/.doc/ops/short-term/2026-09-16-wave2c-same-image-digest.md @@ -0,0 +1,41 @@ +--- +title: "Wave 2C:同 digest 镜像晋升" +status: done +created: 2026-09-16 +updated: 2026-09-16 +owner: Cursor +branch: feature/wave2c-same-image-digest +worktree: "-" +mutex: infra +--- + +# Wave 2C:同 digest 镜像晋升 + +## 目标 + +候选 commit 一次构建;test/main 按 receipt digests 启动;pinned 禁止 rebuild 另造镜像。 + +## 范围 + +- candidate-images-build/apply +- compose + env-service pinned +- promote/release 钩子 + receipt --require-digests +- nightly digests 对齐 + +## 非目标 + +- 远程 registry;升 tier;Playwright;U8 quality_failed(Phase 3) + +## 步骤 + +1. [x] 认领 + 开分支 +2. [x] build/apply + check digests +3. [x] compose/env-service/hooks +4. [x] nightly align +5. [x] 报告合入 + +## 验收标准 + +- [x] PASS receipt 含 digests;apply 后 ID 一致 +- [x] pinned rebuild 不换 ID(env-service 拒绝 --build) +- [x] 缺 digest 拒绝晋升 diff --git a/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md b/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md index 04097012..26f05ce7 100644 --- a/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md +++ b/.doc/remediation/2026-09-16-feature-matrix-production-readiness-plan.md @@ -184,7 +184,7 @@ flowchart TB | PI change-report narrative | deterministic report 已有 | 产品 go/no-go;只读 narrative、fallback 永久保留 | 当前 `deferred`;获批 short-term 开始后转 `partial` | | Frontend workbench | 当前声明范围均有证据 | 修正文档状态;增强另列 | `done` | | CI / E2E | PR CI、PG、backup、web 已有 | nightly real PI;TempDir race flake | `partial` | -| Artifact promotion | 同 commit 可追溯,环境内仍可能重建;receipt + 晋升钩子已落地 | 一次构建、digest 晋升流水线未完成 | 当前 `partial`(Wave 2B);不得声称 same-image | +| Artifact promotion | 同 commit + receipt digests + 单机 same-image pin 已落地 | 远程 registry 不可变推送另立 | 当前 `done`(Wave 2C;单机 retag) | | Reliable events | 核心 live 且旧 hook 退出 | 与 FAV 拆行 | `done` | | FAV | 明确 off | 独立审计与 go/no-go | `deferred` | @@ -414,7 +414,7 @@ flowchart TB - [x] PU4 的 multi-intent U8 与 full/none 真实 PI/browser/restart/cross-user 证据独立归档;失败未被文档改写掩盖。 (2026-09-16:Wave2 报告已签;Playwright 双标签仍 gated,API 刷新/重启已签;不升 tier) - [x] PU5A 的 TempDir race flake 被真正关闭;PU5B 的 nightly job、promotion receipt 和人工豁免链能区分 infrastructure/product failure。 - (2026-09-16:PU5A 已关闭;PU5B **nightly+receipt+晋升钩子**已交付——见 [Wave2B 报告](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md)。**同镜像 digest 晋升**未完成,矩阵 Artifact 行保持 partial。) + (2026-09-16:PU5A 已关闭;PU5B **nightly+receipt+晋升钩子**已交付——见 [Wave2B 报告](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md)。**同镜像 digest 晋升**已由 [Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) 单机 pin 关闭;远程 registry 另立。) - [ ] PU6–PU8 已分别完成产品 go/no-go;未获批项保持 deferred,获批项另建 short-term 后才进入实现与 rollout DoD。 - [ ] 任何真实 PI 工作均满足 R13 的最小出站、租户绑定、脱敏、凭证、留存和泄漏负例。 - [ ] deterministic summary/change-report、legacy 单意图、B1–B4 和三环境隔离均无回归。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 6e75e8b8..5b5c7517 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -64,7 +64,7 @@ | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 schema **47** | — | `infra` | | CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁;**nightly** schedule/`workflow_dispatch` + infra/product 分类 receipt | partial | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[B3](../test-reports/2026-09-16-chat-refresh-gate-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md);[Wave2B nightly/receipt](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md) | 关闭条件:nightly 入口 + 失败分类 + promotion-receipt 门禁已交付;TempDir race **已修**;PR CI 不依赖外部模型。Actions secret/runner 需环境配置 | `ci` | -| Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | partial | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B 报告](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md) | **本轮**:same-commit + receipt 内 `image_digests` 可追溯 + 晋升钩子校验 PASS/waiver。**未完成**:compose 按 digest pull 的 same-image 流水线 — 不得声称 same-image promotion | `infra` | +| Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | done | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) | **关闭**:same-image pin(`candidate-images-*` + `PINNED_IMAGE_MODE` + promote `--require-digests`)。**声明**:单机 retag/digest pin;远程 registry 推送另立任务 | `infra` | | 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` | | Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | deferred | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);CURRENT 旁支纪律 | 独立审计与 go/no-go;**禁止**整支合入 `feature/final-artifact-vault` | `events` | diff --git a/.doc/test-reports/2026-09-16-wave2c-same-image-digest-test-report.md b/.doc/test-reports/2026-09-16-wave2c-same-image-digest-test-report.md new file mode 100644 index 00000000..fd4f8c84 --- /dev/null +++ b/.doc/test-reports/2026-09-16-wave2c-same-image-digest-test-report.md @@ -0,0 +1,69 @@ +# Wave 2C:同 digest 镜像晋升测试报告 + +## 一、 测试背景 (Background) + +Wave 2B 已交付 nightly + promotion receipt,但 test/main 仍可能 `docker compose up --build` 另造镜像。本轮收口 PU5B:候选 commit 一次构建(或等价 retag)、receipt 携带 digests、晋升 apply + pinned `--no-build`。 + +## 二、 测试目标 (Test Objectives) + +- PASS receipt 含非空 `image_digests`(app/events/pi-agent) +- `candidate-images-apply test` 后三服务 ImageID 与 receipt 一致 +- `--require-digests` 在缺 digest 时拒绝晋升 +- pinned 模式下 `env-service` 使用 `--no-build` + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| 分支 | `feature/wave2c-same-image-digest` | +| 基线 commit | `7c808bdcfced…`(fixture 源) | +| 脚本 | `candidate-images-{build,apply}.sh`、`promotion-receipt-{write,check}.sh`、`env-service.sh` | +| Compose | `docker-compose.test.yml` / `main.yml` 支持 `APP_IMAGE` 等 | + +## 四、 预期结果 (Expected Results) + +- app/events 共用同一 candidate ID;pi-agent 独立 ID +- apply 后 `shuimu-*-:test` ID 与 receipt 一致 +- 空 digests 的 PASS → check BLOCKED + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### C1 — require-digests PASS + +- **Input**:images.json + `promotion-receipt-write --verdict pass` + `check --require-digests` +- **Actual**:`OK — PASS receipt for 7c808bdcfced` +- **Verdict**:PASS + +### C2 — apply test ID 一致 + +- **Input**:`./scripts/candidate-images-apply.sh test --commit 7c808bd…` +- **Actual**: + +```text +tagged shuimu-agent-app:test -> sha256:44c114af… +tagged shuimu-agent-events:test -> sha256:44c114af… (same as app) +tagged shuimu-pi-agent:test -> sha256:b8f9b525… +app PASS / events PASS / pi-agent PASS +``` + +- **Verdict**:PASS + +### C3 — 缺 digests 负例 + +- **Input**:fixture `ffff…` PASS 且 digests 全 null + `--require-digests` +- **Actual**:`BLOCKED — PASS receipt missing image_digests … app, events, pi-agent` +- **Verdict**:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +合入冒烟(洁净环境):`go vet ./...`;`go test -short ./...`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**:单机 same-image digest pin + 晋升钩子已交付;FEATURE-MATRIX Artifact 可标 `done`(注明无远程 registry)。 +- **后续**:Phase 2 Wave3 go/no-go;Phase 3 N7 U8 / Playwright 等遗留。 + +## 证据 + +- `.doc/test-reports/assets/promotion-receipts/7c808bd….json` + `.images.json` +- `configs/.env.test.images`(本地 pin,gitignore) diff --git a/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.images.json b/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.images.json new file mode 100644 index 00000000..feb524a9 --- /dev/null +++ b/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.images.json @@ -0,0 +1,19 @@ +{ + "commit": "7c808bdcfced14febafe33f72ae14232d938f7e5", + "recorded_at": "2026-09-16T14:43:48Z", + "tags": { + "app": "shuimu-agent-app:candidate-7c808bdcfced", + "events": "shuimu-agent-events:candidate-7c808bdcfced", + "pi-agent": "shuimu-pi-agent:candidate-7c808bdcfced" + }, + "image_ids": { + "app": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "events": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "pi-agent": "sha256:b8f9b5255b212ad95da0fefc13fad66b755bbfb2d9b2f8d2da8e96dccf77f814" + }, + "image_digests": { + "app": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "events": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "pi-agent": "sha256:b8f9b5255b212ad95da0fefc13fad66b755bbfb2d9b2f8d2da8e96dccf77f814" + } +} diff --git a/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.json b/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.json new file mode 100644 index 00000000..10352b4d --- /dev/null +++ b/.doc/test-reports/assets/promotion-receipts/7c808bdcfced14febafe33f72ae14232d938f7e5.json @@ -0,0 +1,18 @@ +{ + "verdict": "pass", + "commit": "7c808bdcfced14febafe33f72ae14232d938f7e5", + "recorded_at": "2026-09-16T14:43:49Z", + "runner_url": "http://127.0.0.1:18865", + "model_id": "MiniMax-M2.7-highspeed", + "protocol": "typed-result-v1", + "image_digests": { + "app": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "events": "sha256:44c114af4032bafe26a90adca718cd3a343d4c34fbb0f569290fd0f24ec412db", + "pi-agent": "sha256:b8f9b5255b212ad95da0fefc13fad66b755bbfb2d9b2f8d2da8e96dccf77f814" + }, + "suite": "wave2c-fixture", + "failure_class": "none", + "evidence_refs": [ + ".doc/test-reports/assets/promotion-receipts/.gitkeep" + ] +} diff --git a/.doc/test-reports/assets/promotion-receipts/ffffffffffffffffffffffffffffffffffffffff.json b/.doc/test-reports/assets/promotion-receipts/ffffffffffffffffffffffffffffffffffffffff.json new file mode 100644 index 00000000..b35f87f6 --- /dev/null +++ b/.doc/test-reports/assets/promotion-receipts/ffffffffffffffffffffffffffffffffffffffff.json @@ -0,0 +1,16 @@ +{ + "verdict": "pass", + "commit": "ffffffffffffffffffffffffffffffffffffffff", + "recorded_at": "2026-09-16T00:00:00Z", + "runner_url": "x", + "model_id": "x", + "protocol": "x", + "image_digests": { + "app": null, + "events": null, + "pi-agent": null + }, + "suite": "neg", + "failure_class": "none", + "evidence_refs": [] +} diff --git a/deploy/compose/images.pinned.env.example b/deploy/compose/images.pinned.env.example new file mode 100644 index 00000000..2cd7bd7e --- /dev/null +++ b/deploy/compose/images.pinned.env.example @@ -0,0 +1,12 @@ +# Example pinned image env for test/main (Wave 2C same-image promotion). +# Generated by scripts/candidate-images-apply.sh as configs/.env..images +# Do not commit live digests that are machine-specific unless intentional fixtures. +# +# APP_IMAGE=shuimu-agent-app:test +# EVENTS_IMAGE=shuimu-agent-events:test +# PI_AGENT_IMAGE=shuimu-pi-agent:test +# PINNED_IMAGE_MODE=1 +# PINNED_COMMIT= +# PINNED_APP_DIGEST=sha256:... +# PINNED_EVENTS_DIGEST=sha256:... +# PINNED_PI_AGENT_DIGEST=sha256:... diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 8de3feb0..0a7cd2b1 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -46,10 +46,10 @@ services: - main-net pi-agent: + image: ${PI_AGENT_IMAGE:-shuimu-pi-agent:main} build: context: . dockerfile: deploy/pi-agent/Dockerfile - image: shuimu-pi-agent:main container_name: shuimu-pi-agent-main environment: PORT: 18867 @@ -78,6 +78,7 @@ services: - main-net app: + image: ${APP_IMAGE:-shuimu-agent-app:main} build: context: . dockerfile: deploy/app/Dockerfile @@ -143,6 +144,7 @@ services: - shared-net events: + image: ${EVENTS_IMAGE:-shuimu-agent-events:main} build: context: . dockerfile: deploy/app/Dockerfile diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 8e5b049e..fb0b8a30 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -46,10 +46,10 @@ services: - test-net pi-agent: + image: ${PI_AGENT_IMAGE:-shuimu-pi-agent:test} build: context: . dockerfile: deploy/pi-agent/Dockerfile - image: shuimu-pi-agent:test container_name: shuimu-pi-agent-test environment: PORT: 18866 @@ -78,6 +78,7 @@ services: - test-net app: + image: ${APP_IMAGE:-shuimu-agent-app:test} build: context: . dockerfile: deploy/app/Dockerfile @@ -143,6 +144,7 @@ services: - shared-net events: + image: ${EVENTS_IMAGE:-shuimu-agent-events:test} build: context: . dockerfile: deploy/app/Dockerfile diff --git a/scripts/candidate-images-apply.sh b/scripts/candidate-images-apply.sh new file mode 100755 index 00000000..c93b0637 --- /dev/null +++ b/scripts/candidate-images-apply.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Apply candidate image digests from a promotion receipt to test|main. +# Retags local images and writes configs/.env..images for pinned compose. +# +# Usage: +# ./scripts/candidate-images-apply.sh [--commit SHA] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +ENV_NAME="${1:-}" +shift || true +COMMIT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --commit) COMMIT="${2:-}"; shift 2 ;; + -h|--help) + sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 1 ;; + esac +done + +case "${ENV_NAME}" in + test|main) ;; + *) echo "usage: $0 [--commit SHA]" >&2; exit 1 ;; +esac + +if [[ -z "${COMMIT}" ]]; then + if [[ "${ENV_NAME}" == "test" ]]; then + COMMIT="$(git rev-parse dev)" + else + COMMIT="$(git rev-parse test)" + fi +fi +COMMIT="$(git rev-parse --verify "${COMMIT}^{commit}" 2>/dev/null || echo "${COMMIT}")" +COMMIT="$(echo "${COMMIT}" | tr 'A-F' 'a-f')" + +RECEIPT_DIR="${ROOT}/.doc/test-reports/assets/promotion-receipts" +RECEIPT="" +if [[ -f "${RECEIPT_DIR}/${COMMIT}.json" ]]; then + RECEIPT="${RECEIPT_DIR}/${COMMIT}.json" +else + shopt -s nullglob + for f in "${RECEIPT_DIR}"/*.json; do + base="$(basename "${f}" .json)" + [[ "${base}" == *.waiver ]] && continue + [[ "${base}" == *.images ]] && continue + if [[ "${COMMIT}" == "${base}"* || "${base}" == "${COMMIT}"* ]]; then + RECEIPT="${f}" + break + fi + done +fi + +if [[ -z "${RECEIPT}" || ! -f "${RECEIPT}" ]]; then + echo "error: no promotion receipt for ${COMMIT}" >&2 + exit 1 +fi + +export RECEIPT ENV_NAME ROOT +python3 <<'PY' +import json, os, subprocess, sys + +receipt_path = os.environ["RECEIPT"] +env_name = os.environ["ENV_NAME"] +root = os.environ["ROOT"] + +with open(receipt_path, encoding="utf-8") as f: + receipt = json.load(f) + +verdict = receipt.get("verdict") +if verdict != "pass": + # allow apply when digests present on a pass-only path; still warn + print(f"warning: receipt verdict={verdict!r} (expected pass)", file=sys.stderr) + +digests = receipt.get("image_digests") or {} +app_d = digests.get("app") or "" +events_d = digests.get("events") or "" +pi_d = digests.get("pi-agent") or "" +missing = [k for k, v in (("app", app_d), ("events", events_d), ("pi-agent", pi_d)) if not v] +if missing: + print(f"error: receipt missing image_digests: {', '.join(missing)}", file=sys.stderr) + sys.exit(1) + +def resolve_local_id(ref: str) -> str: + # ref may be sha256:ID, name@sha256:..., or full image id + candidates = [ref] + if ref.startswith("sha256:"): + candidates.append(ref) + elif "@sha256:" in ref: + candidates.append(ref.split("@", 1)[1]) + for c in candidates: + try: + out = subprocess.check_output( + ["docker", "image", "inspect", "--format", "{{.Id}}", c], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + if out: + return out + except subprocess.CalledProcessError: + continue + print(f"error: image not found locally for digest/ref: {ref}", file=sys.stderr) + print("hint: run scripts/candidate-images-build.sh for this commit first", file=sys.stderr) + sys.exit(1) + +app_id = resolve_local_id(app_d) +events_id = resolve_local_id(events_d) +pi_id = resolve_local_id(pi_d) + +tags = { + "app": f"shuimu-agent-app:{env_name}", + "events": f"shuimu-agent-events:{env_name}", + "pi-agent": f"shuimu-pi-agent:{env_name}", +} +for img_id, tag in ((app_id, tags["app"]), (events_id, tags["events"]), (pi_id, tags["pi-agent"])): + subprocess.check_call(["docker", "tag", img_id, tag]) + got = subprocess.check_output( + ["docker", "image", "inspect", "--format", "{{.Id}}", tag], text=True + ).strip() + if got != img_id: + print(f"error: retag mismatch for {tag}: want {img_id} got {got}", file=sys.stderr) + sys.exit(1) + print(f"tagged {tag} -> {got}") + +# Pin compose to retagged tags (same IDs as receipt). +env_path = os.path.join(root, f"configs/.env.{env_name}.images") +lines = [ + f"# generated by candidate-images-apply.sh from {os.path.basename(receipt_path)}", + f"APP_IMAGE={tags['app']}", + f"EVENTS_IMAGE={tags['events']}", + f"PI_AGENT_IMAGE={tags['pi-agent']}", + f"PINNED_IMAGE_MODE=1", + f"PINNED_COMMIT={receipt.get('commit') or ''}", + f"PINNED_APP_DIGEST={app_d}", + f"PINNED_EVENTS_DIGEST={events_d}", + f"PINNED_PI_AGENT_DIGEST={pi_d}", + "", +] +with open(env_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) +print(f"wrote {env_path}") +PY diff --git a/scripts/candidate-images-build.sh b/scripts/candidate-images-build.sh new file mode 100755 index 00000000..4377866a --- /dev/null +++ b/scripts/candidate-images-build.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Build candidate app (+events) and pi-agent images once for a commit. +# Writes .doc/test-reports/assets/promotion-receipts/.images.json +# +# Usage: +# ./scripts/candidate-images-build.sh [--commit SHA] [--force] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +COMMIT="" +FORCE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --commit) COMMIT="${2:-}"; shift 2 ;; + --force) FORCE=1; shift ;; + -h|--help) + sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "${COMMIT}" ]]; then + COMMIT="$(git rev-parse HEAD)" +fi +COMMIT="$(git rev-parse --verify "${COMMIT}^{commit}")" +SHORT="${COMMIT:0:12}" +RECEIPT_DIR="${ROOT}/.doc/test-reports/assets/promotion-receipts" +OUT="${RECEIPT_DIR}/${COMMIT}.images.json" +mkdir -p "${RECEIPT_DIR}" + +if [[ -f "${OUT}" && "${FORCE}" -ne 1 ]]; then + echo "images.json already exists (immutable): ${OUT}" >&2 + echo "hint: use --force to rebuild local fixtures" >&2 + cat "${OUT}" + exit 0 +fi + +APP_TAG="shuimu-agent-app:candidate-${SHORT}" +EVENTS_TAG="shuimu-agent-events:candidate-${SHORT}" +PI_TAG="shuimu-pi-agent:candidate-${SHORT}" + +echo "==> building app/events image ${APP_TAG}..." +docker build -f deploy/app/Dockerfile -t "${APP_TAG}" . +# events binary lives in the same image +docker tag "${APP_TAG}" "${EVENTS_TAG}" + +echo "==> building pi-agent image ${PI_TAG}..." +docker build -f deploy/pi-agent/Dockerfile -t "${PI_TAG}" . + +image_id() { + docker inspect --format '{{.Id}}' "$1" +} + +repo_digest_or_id() { + local name="$1" + local repo + repo="$(docker inspect --format '{{index .RepoDigests 0}}' "${name}" 2>/dev/null || true)" + if [[ -n "${repo}" && "${repo}" != "" ]]; then + printf '%s' "${repo}" + else + image_id "${name}" + fi +} + +APP_ID="$(image_id "${APP_TAG}")" +EVENTS_ID="$(image_id "${EVENTS_TAG}")" +PI_ID="$(image_id "${PI_TAG}")" +APP_DIGEST="$(repo_digest_or_id "${APP_TAG}")" +EVENTS_DIGEST="$(repo_digest_or_id "${EVENTS_TAG}")" +PI_DIGEST="$(repo_digest_or_id "${PI_TAG}")" + +RECORDED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +export COMMIT SHORT RECORDED_AT APP_TAG EVENTS_TAG PI_TAG +export APP_ID EVENTS_ID PI_ID APP_DIGEST EVENTS_DIGEST PI_DIGEST OUT + +python3 -c ' +import json, os +payload = { + "commit": os.environ["COMMIT"], + "recorded_at": os.environ["RECORDED_AT"], + "tags": { + "app": os.environ["APP_TAG"], + "events": os.environ["EVENTS_TAG"], + "pi-agent": os.environ["PI_TAG"], + }, + "image_ids": { + "app": os.environ["APP_ID"], + "events": os.environ["EVENTS_ID"], + "pi-agent": os.environ["PI_ID"], + }, + "image_digests": { + "app": os.environ["APP_DIGEST"], + "events": os.environ["EVENTS_DIGEST"], + "pi-agent": os.environ["PI_DIGEST"], + }, +} +with open(os.environ["OUT"], "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) + f.write("\n") +print(os.environ["OUT"]) +' + +echo "wrote candidate images manifest: ${OUT}" +echo "app=${APP_DIGEST}" +echo "events=${EVENTS_DIGEST}" +echo "pi-agent=${PI_DIGEST}" diff --git a/scripts/env-service.sh b/scripts/env-service.sh index 88e86f7a..39e9a7ab 100755 --- a/scripts/env-service.sh +++ b/scripts/env-service.sh @@ -100,12 +100,26 @@ run_compose() { if [[ -f "${env_file}" ]]; then env_args+=(--env-file "${env_file}") fi + # Wave 2C: optional pinned image digests from candidate-images-apply.sh + local images_file="configs/.env.${target}.images" + local pinned=0 + if [[ -f "${images_file}" ]]; then + env_args+=(--env-file "${images_file}") + if grep -q '^PINNED_IMAGE_MODE=1' "${images_file}" 2>/dev/null; then + pinned=1 + fi + fi case "${act}" in start) ensure_shared_network echo "==> [${target}] 启动服务 (${compose_file})..." - docker compose -f "${compose_file}" "${env_args[@]}" up -d + if [[ "${pinned}" -eq 1 && ( "${target}" == "test" || "${target}" == "main" ) ]]; then + echo "==> [${target}] pinned image mode: up -d --no-build" + docker compose -f "${compose_file}" "${env_args[@]}" up -d --no-build + else + docker compose -f "${compose_file}" "${env_args[@]}" up -d + fi echo "==> [${target}] 服务已启动" ;; stop) @@ -119,13 +133,22 @@ run_compose() { ;; rebuild) ensure_shared_network - echo "==> [${target}] 重建镜像并重建容器..." - docker compose -f "${compose_file}" "${env_args[@]}" up -d --build - echo "==> [${target}] 镜像与容器已更新" + if [[ "${pinned}" -eq 1 && ( "${target}" == "test" || "${target}" == "main" ) ]]; then + echo "==> [${target}] pinned image mode: recreate from pinned digests (refusing docker build)" + docker compose -f "${compose_file}" "${env_args[@]}" up -d --no-build --force-recreate + echo "==> [${target}] 已按 pin 重建容器(未重新 build 镜像)" + else + echo "==> [${target}] 重建镜像并重建容器..." + docker compose -f "${compose_file}" "${env_args[@]}" up -d --build + echo "==> [${target}] 镜像与容器已更新" + fi ;; status|ps) echo "------------------------------------------------------------" echo "环境: [${target}] (编排: ${compose_file})" + if [[ "${pinned}" -eq 1 ]]; then + echo "image pin: ${images_file}" + fi echo "------------------------------------------------------------" docker compose -f "${compose_file}" "${env_args[@]}" ps echo "" diff --git a/scripts/git-workflow-helper.sh b/scripts/git-workflow-helper.sh index b6e78ef1..73987bd3 100755 --- a/scripts/git-workflow-helper.sh +++ b/scripts/git-workflow-helper.sh @@ -100,16 +100,19 @@ case "${COMMAND}" in exit 1 fi # Wave 2B / PU5B: require PASS receipt (or timed waiver) for local `dev` HEAD. + # Wave 2C: require image_digests for same-image promote. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEV_SHA="$(git rev-parse dev)" echo "==> promotion-receipt gate for dev@${DEV_SHA:0:12}..." - "${SCRIPT_DIR}/promotion-receipt-check.sh" --commit "${DEV_SHA}" + "${SCRIPT_DIR}/promotion-receipt-check.sh" --commit "${DEV_SHA}" --require-digests echo "==> 切换至 test 分支并合并本地 dev 当前稳定节点..." git checkout test # 本地晋升默认不 rebase origin(分叉时会把数百提交重放并易冲突)。需要同步远端时请显式 pull --ff-only/merge。 git merge --no-ff dev -m "release-candidate: 汇集 dev 功能至 test 分支,准备长期实测 [Tag: ${TAG}]" git tag -a "${TAG}" -m "实测候选版本: ${TAG}" - echo "已成功合并至 test 并打上标签: ${TAG}!请启动 test 环境进行运维与功能测试。" + echo "==> applying candidate image digests to test..." + "${SCRIPT_DIR}/candidate-images-apply.sh" test --commit "${DEV_SHA}" + echo "已成功合并至 test 并打上标签: ${TAG}!请用 env-service.sh test start/rebuild(pinned 不重建镜像)。" ;; release-to-main) @@ -119,16 +122,19 @@ case "${COMMAND}" in exit 1 fi # Wave 2B / PU5B: require PASS receipt (or timed waiver) for local `test` HEAD. + # Wave 2C: require image_digests for same-image promote. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEST_SHA="$(git rev-parse test)" echo "==> promotion-receipt gate for test@${TEST_SHA:0:12}..." - "${SCRIPT_DIR}/promotion-receipt-check.sh" --commit "${TEST_SHA}" + "${SCRIPT_DIR}/promotion-receipt-check.sh" --commit "${TEST_SHA}" --require-digests echo "==> 切换至 main 分支并合并 test 验证节点..." git checkout main git pull --rebase origin main 2>/dev/null || true git merge --no-ff test -m "release: 经长期测试与运维验证通过,正式发布 ${TAG}" git tag -a "${TAG}" -m "正式发布版本: ${TAG}" - echo "已成功合并至 main 并打上正式发布标签: ${TAG}!" + echo "==> applying candidate image digests to main..." + "${SCRIPT_DIR}/candidate-images-apply.sh" main --commit "${TEST_SHA}" + echo "已成功合并至 main 并打上正式发布标签: ${TAG}!请用 env-service.sh main start/rebuild(pinned 不重建镜像)。" ;; *) diff --git a/scripts/promotion-receipt-check.sh b/scripts/promotion-receipt-check.sh index a2d59782..62616bd7 100755 --- a/scripts/promotion-receipt-check.sh +++ b/scripts/promotion-receipt-check.sh @@ -3,19 +3,21 @@ # or a non-expired audited waiver binding a successful receipt. # # Usage: -# ./scripts/promotion-receipt-check.sh [--commit SHA] +# ./scripts/promotion-receipt-check.sh [--commit SHA] [--require-digests] # # Exit 0: eligible to promote -# Exit 1: blocked (missing / product_fail / infra_fail / stale waiver / mismatch) +# Exit 1: blocked (missing / product_fail / infra_fail / stale waiver / mismatch / missing digests) set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RECEIPT_DIR="${ROOT}/.doc/test-reports/assets/promotion-receipts" COMMIT="" +REQUIRE_DIGESTS=0 while [[ $# -gt 0 ]]; do case "$1" in --commit) COMMIT="${2:-}"; shift 2 ;; + --require-digests) REQUIRE_DIGESTS=1; shift ;; -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' exit 0 @@ -48,6 +50,7 @@ find_receipt() { local base base="$(basename "${candidate}" .json)" [[ "${base}" == *.waiver ]] && continue + [[ "${base}" == *.images ]] && continue if [[ "${sha}" == "${base}"* || "${base}" == "${sha}"* ]]; then echo "${candidate}" return 0 @@ -88,7 +91,7 @@ else WAIVER_PATH="" fi -export RECEIPT_PATH WAIVER_PATH COMMIT RECEIPT_DIR ROOT +export RECEIPT_PATH WAIVER_PATH COMMIT RECEIPT_DIR ROOT REQUIRE_DIGESTS python3 <<'PY' import json, os, sys @@ -99,6 +102,7 @@ receipt_path = os.environ.get("RECEIPT_PATH") or "" waiver_path = os.environ.get("WAIVER_PATH") or "" receipt_dir = os.environ["RECEIPT_DIR"] root = os.environ.get("ROOT") or "." +require_digests = os.environ.get("REQUIRE_DIGESTS") == "1" def load(path): with open(path, encoding="utf-8") as f: @@ -112,6 +116,14 @@ def ok(msg): print(f"promotion-receipt-check: OK — {msg}") sys.exit(0) +def require_image_digests(r, label): + if not require_digests: + return + digests = r.get("image_digests") or {} + missing = [k for k in ("app", "events", "pi-agent") if not digests.get(k)] + if missing: + fail(f"{label} missing image_digests for same-image promote: {', '.join(missing)}") + def resolve_bound_receipt(binds): if not binds: return None @@ -123,10 +135,10 @@ def resolve_bound_receipt(binds): candidates.append(os.path.join(receipt_dir, binds)) candidates.append(os.path.join(receipt_dir, f"{binds}.json")) for c in candidates: - if os.path.isfile(c): + if os.path.isfile(c) and not c.endswith(".images.json") and not c.endswith(".waiver.json"): return c for name in os.listdir(receipt_dir): - if name.endswith(".waiver.json") or not name.endswith(".json"): + if name.endswith(".waiver.json") or name.endswith(".images.json") or not name.endswith(".json"): continue stem = name[:-5] if binds.startswith(stem) or stem.startswith(binds): @@ -142,18 +154,16 @@ if receipt_path: if r_commit and not (commit.startswith(r_commit) or r_commit.startswith(commit)): fail(f"receipt commit mismatch: receipt={r_commit} target={commit}") if verdict == "pass": + require_image_digests(r, "PASS receipt") ok(f"PASS receipt for {commit[:12]} ({receipt_path})") elif verdict == "product_fail": fail(f"product_fail receipt rejects promotion ({receipt_path}); failure_class={r.get('failure_class')}") elif verdict == "infra_fail": - # infra_fail freezes next promote unless a valid timed waiver is present if not waiver_path: fail(f"infra_fail receipt freezes promotion ({receipt_path}); provide timed waiver or re-run nightly") - # fall through to waiver validation else: fail(f"unknown verdict {verdict!r} in {receipt_path}") -# No PASS receipt — require a valid waiver if not waiver_path: fail(f"missing promotion receipt for {commit[:12]} under {receipt_dir}") @@ -166,7 +176,6 @@ expires = w.get("expires_at") if not expires: fail(f"waiver missing expires_at ({waiver_path})") try: - # support Z and offset exp = datetime.fromisoformat(expires.replace("Z", "+00:00")) except Exception as e: fail(f"waiver expires_at unparseable: {expires} ({e})") @@ -187,6 +196,7 @@ if not bound_path: br = load(bound_path) if br.get("verdict") != "pass": fail(f"waiver bound receipt is not PASS: {bound_path}") +require_image_digests(br, "waiver-bound PASS receipt") ok( f"audited waiver for {commit[:12]} by {owner} until {expires}; " diff --git a/scripts/promotion-receipt-write.sh b/scripts/promotion-receipt-write.sh index 911a0c62..9285a419 100755 --- a/scripts/promotion-receipt-write.sh +++ b/scripts/promotion-receipt-write.sh @@ -90,9 +90,36 @@ digest_for() { docker inspect --format '{{.Id}}' "${name}" 2>/dev/null || true } -APP_DIGEST="$(digest_for shuimu-agent-app-dev "${RECEIPT_IMAGE_APP:-}")" -EVENTS_DIGEST="$(digest_for shuimu-agent-events-dev "${RECEIPT_IMAGE_EVENTS:-}")" -PI_DIGEST="$(digest_for shuimu-pi-agent-dev "${RECEIPT_IMAGE_PI_AGENT:-}")" +# Prefer candidate-images-build manifest when present. +IMAGES_JSON="" +if [[ -f "${RECEIPT_DIR}/${COMMIT}.images.json" ]]; then + IMAGES_JSON="${RECEIPT_DIR}/${COMMIT}.images.json" +else + # Resolve abbreviated commit to images.json via prefix. + shopt -s nullglob + for f in "${RECEIPT_DIR}"/*.images.json; do + base="$(basename "${f}" .images.json)" + if [[ "${COMMIT}" == "${base}"* || "${base}" == "${COMMIT}"* ]]; then + IMAGES_JSON="${f}" + break + fi + done +fi + +if [[ -n "${IMAGES_JSON}" ]]; then + APP_DIGEST="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("image_digests",{}).get("app") or "")' "${IMAGES_JSON}")" + EVENTS_DIGEST="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("image_digests",{}).get("events") or "")' "${IMAGES_JSON}")" + PI_DIGEST="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("image_digests",{}).get("pi-agent") or "")' "${IMAGES_JSON}")" +else + APP_DIGEST="$(digest_for shuimu-agent-app-dev "${RECEIPT_IMAGE_APP:-}")" + EVENTS_DIGEST="$(digest_for shuimu-agent-events-dev "${RECEIPT_IMAGE_EVENTS:-}")" + PI_DIGEST="$(digest_for shuimu-pi-agent-dev "${RECEIPT_IMAGE_PI_AGENT:-}")" + # Also try candidate tags for this commit. + SHORT12="${COMMIT:0:12}" + [[ -z "${APP_DIGEST}" ]] && APP_DIGEST="$(digest_for "shuimu-agent-app:candidate-${SHORT12}" "")" + [[ -z "${EVENTS_DIGEST}" ]] && EVENTS_DIGEST="$(digest_for "shuimu-agent-events:candidate-${SHORT12}" "")" + [[ -z "${PI_DIGEST}" ]] && PI_DIGEST="$(digest_for "shuimu-pi-agent:candidate-${SHORT12}" "")" +fi mkdir -p "${RECEIPT_DIR}" OUT="${RECEIPT_DIR}/${COMMIT}.json" diff --git a/scripts/run-nightly-real-pi.sh b/scripts/run-nightly-real-pi.sh index 2da4353f..8c73b873 100755 --- a/scripts/run-nightly-real-pi.sh +++ b/scripts/run-nightly-real-pi.sh @@ -71,6 +71,21 @@ classify_test_log() { echo "==> nightly real-PI suite commit=${COMMIT:0:12} runner=${RUNNER_URL}" +# Wave 2C: ensure candidate image digests exist for PASS receipts / promote gate. +ensure_candidate_images() { + local images_json="${ROOT}/.doc/test-reports/assets/promotion-receipts/${COMMIT}.images.json" + if [[ -f "${images_json}" ]]; then + echo "==> reusing candidate images manifest ${images_json}" + return 0 + fi + if [[ "${NIGHTLY_SKIP_IMAGE_BUILD:-0}" == "1" ]]; then + echo "warning: NIGHTLY_SKIP_IMAGE_BUILD=1 and no images.json; PASS may lack digests" >&2 + return 0 + fi + echo "==> building candidate images for digest pin..." + "${ROOT}/scripts/candidate-images-build.sh" --commit "${COMMIT}" +} + if [[ "${DRY_INFRA_FAIL}" -eq 1 ]]; then echo "forced infra failure (--dry-infra-fail)" | tee "${EVIDENCE_DIR}/infra-forced.txt" write_receipt infra_fail infra "${EVIDENCE_DIR}/infra-forced.txt" @@ -123,6 +138,7 @@ TEST_RC=${PIPESTATUS[0]} set -e if [[ "${TEST_RC}" -eq 0 ]]; then + ensure_candidate_images write_receipt pass none "${LOG_FILE}" "${EVIDENCE_DIR}/runner-health.json" echo "nightly real-PI PASS; receipt written for ${COMMIT:0:12}" exit 0