From 1a73f64ecd1591a47c13ed6f3628fead30dc5cd5 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 15 Sep 2026 23:18:39 +0800 Subject: [PATCH] fix(ci): let staging migration resolve the latest gated SHA Leave Migrate Staging Database deploy_sha empty (or type latest) to use the newest successful backend-quality-gate push. Filled 40-hex still takes the original path. Production workflows stay required. --- .gitea/workflows/migrate-staging-database.yml | 29 +++++++++++- deploy/README.md | 8 ++-- ...-staging-dispatch-autofill-sha-20260915.md | 47 +++++++++++++++++++ docs/tasks/README.md | 2 +- .../tests/staging-backend-workflows.test.ts | 31 ++++++++++++ 5 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 docs/tasks/PROGRESS-staging-dispatch-autofill-sha-20260915.md diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml index ba844864..9f6b66d7 100644 --- a/.gitea/workflows/migrate-staging-database.yml +++ b/.gitea/workflows/migrate-staging-database.yml @@ -4,8 +4,8 @@ on: workflow_dispatch: inputs: deploy_sha: - description: Exact tested 40-character staging commit SHA - required: true + description: 留空=自动用最新一个通过门禁的 staging 提交;填写=迁移到指定的 40 位 SHA(回滚用) + required: false type: string permissions: @@ -40,6 +40,31 @@ jobs: DEPLOY_SHA: ${{ inputs.deploy_sha }} run: | set -euo pipefail + if [[ -z "${DEPLOY_SHA:-}" || "$DEPLOY_SHA" == "latest" ]]; then + if ! latest_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?branch=staging&event=push&status=success&limit=100")"; then + echo "查不到 staging 的门禁运行记录,Gitea API 请求失败" >&2 + exit 1 + fi + if ! selected_latest="$(jq -cer ' + [(.workflow_runs // [])[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .head_branch == "staging" and + .event == "push" and + .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$latest_runs")"; then + echo "staging 上还没有任何通过门禁的提交,先等门禁跑完再迁移" >&2 + exit 1 + fi + DEPLOY_SHA="$(jq -er '.head_sha' <<<"$selected_latest")" || true + if ! [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "staging 上还没有任何通过门禁的提交,先等门禁跑完再迁移" >&2 + exit 1 + fi + echo "resolved deploy_sha=$DEPLOY_SHA (latest gated staging commit)" + fi [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } read_ref_sha() { local branch="$1" diff --git a/deploy/README.md b/deploy/README.md index d7571589..2765cebf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -193,7 +193,7 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se 3. The `Independent Staging Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests and the allowlisted staging controller bundle. 4. The successful publish job rechecks that `staging` still points at the exact SHA, then dispatches `Deploy staging` from `refs/heads/staging` with the source gate run ID. The deploy workflow waits for that gate's final success, validates the artifact, and performs the normal forward-only release under the shared staging host lock. 5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` using **Use workflow from: staging**, the same successful SHA in `deploy_sha`, an empty `gate_run_id`, and `allow_rollback=false`; the workflow resolves a successful exact-SHA staging push gate before mutation. -6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually using **Use workflow from: staging** with the same full SHA. Migration success does not dispatch deployment. +6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually using **Use workflow from: staging**. Leave `deploy_sha` empty unless you are rolling back to an earlier gated SHA. Migration success does not dispatch deployment. 7. After migration succeeds, manually start `Deploy staging` from `staging` with that same exact SHA and `allow_rollback=false`, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health. ### Resetting one staging account @@ -263,8 +263,10 @@ Use this order for every staging revision: 1. Review the test change, then push its exact commit directly to `staging`; `main` may remain at a different SHA. 2. Wait for `Independent Staging Quality Gate` to pass and publish that exact full SHA's API/web digest and controller artifact. Its publish job dispatches the staging-ref deployment and refuses dispatch if `staging` already advanced. 3. The dispatched `Deploy staging` workflow validates the source gate run and checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration. -4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: staging**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The workflow requires a successful exact-SHA staging push gate. If `staging` is already ahead, the gate-attested `deploy/is-docs-only-range.sh` must prove the extra commits are docs-only; a gated-path advance still refuses. It starts only PostgreSQL and runs the digest-pinned migrator from the gate-attested controller bundle. -5. A successful migration rechecks that `staging` is still the migrated SHA or only docs-only ahead, then prints the ordered migration ledger, but does not dispatch deployment. The operator must then open **Deploy staging -> Run workflow**, select **Use workflow from: staging**, enter the same exact SHA in `deploy_sha`, leave `gate_run_id` empty, and set `allow_rollback=false`. If `staging` advanced by a gated path, stop rather than substituting a branch name, short SHA, or newer commit. +4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: staging**, and leave `deploy_sha` empty. The workflow resolves the latest staging commit that has a successful `backend-quality-gate` push run, then applies the same exact-SHA gate check as a filled SHA. Fill the 40-character SHA only when rolling back to an earlier gated revision. If `staging` is already ahead, the gate-attested `deploy/is-docs-only-range.sh` must prove the extra commits are docs-only; a gated-path advance still refuses. It starts only PostgreSQL and runs the digest-pinned migrator from the gate-attested controller bundle. Check the first log line `resolved deploy_sha=…` to see which revision will migrate. +5. A successful migration rechecks that `staging` is still the migrated SHA or only docs-only ahead, then prints the ordered migration ledger, but does not dispatch deployment. The operator must then open **Deploy staging -> Run workflow**, select **Use workflow from: staging**, enter the same exact SHA in `deploy_sha` (the value printed as `resolved deploy_sha`), leave `gate_run_id` empty, and set `allow_rollback=false`. If `staging` advanced by a gated path, stop rather than substituting a branch name, short SHA, or newer commit. + +**Deploy Production** and **Migrate Production Database** still require a hand-filled 40-character SHA, plus `allow_rollback` / recovery attestation. That is a guardrail: production must name the exact revision out loud. Do not copy the staging empty-SHA shortcut onto those two buttons. 6. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2. 7. After health verification, create the local encrypted backup described below. diff --git a/docs/tasks/PROGRESS-staging-dispatch-autofill-sha-20260915.md b/docs/tasks/PROGRESS-staging-dispatch-autofill-sha-20260915.md new file mode 100644 index 00000000..7db9c027 --- /dev/null +++ b/docs/tasks/PROGRESS-staging-dispatch-autofill-sha-20260915.md @@ -0,0 +1,47 @@ +# PROGRESS · Migrate Staging Database 不必手填 SHA(2026-09-15) + +工作树:`.worktrees/staging-dispatch-autofill-sha-20260915` +分支:`codex/staging-dispatch-autofill-sha-20260915` +任务书基线:`origin/staging` @ `de47c06d`(任务书本身;代码基线 `ebd6175b`) +本机 Windows。 + +未开 BUG 号(任务书规定)。未改 `CHANGELOG.md`。未改 `deploy-production.yml` / `migrate-production-database.yml` / `backend-quality-gate.yml`。`concurrency.group: staging-mutation` 未动。第一步之后的步骤一行未改。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 `migrate-staging-database.yml` SHA 可选 | 完成 | `required: false`;空或 `latest` 解析最新成功门禁 SHA,再走原格式/门禁/head 校验 | +| 2 `deploy-staging.yml` | **不做** | 见下 | +| 3 文档 | 完成 | `deploy/README.md`、本文件、状态板 | + +## 任务 2 结论 + +**不做。** `Deploy staging` 在门禁通过后会被自动 dispatch,并带上 `deploy_sha` 与 `gate_run_id`。手动表单只在重跑或回滚时用,而回滚必须手填 SHA 并勾选 `allow_rollback`。自动解析永远给出最新修订,帮不上回滚。改它还要动更复杂的 rollback 分支与合同测试,收益低。`allow_rollback` 保持 required。 + +## 实现要点 + +只改了 `workflow_dispatch.inputs` 和第一步开头的解析。后面步骤仍读 `steps.revision.outputs.sha` / `gate_run_id` / `head_check`,语义不变。 + +- 非空且不是 `latest`:完全走原逻辑(格式 → 精确 SHA 门禁查找 → head 比对)。 +- 空或 `latest`:查 `actions/runs?branch=staging&event=push&status=success&limit=100`,同一套 jq 过滤,取 `id` 最大的 `head_sha`,打印 `resolved deploy_sha= (latest gated staging commit)`,然后继续原校验(再查一遍精确 SHA 门禁)。 +- 解析失败:硬失败。没有成功门禁运行时:「staging 上还没有任何通过门禁的提交,先等门禁跑完再迁移」。API 失败:「查不到 staging 的门禁运行记录,Gitea API 请求失败」。**不会**退回 staging head。 + +`latest` 三个字母是任务书让步 1:若 Gitea 这个版本把留空做成缺省失败而不是空串,可以填 `latest` 代替 40 位。留空是主路径。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `staging-backend-workflows.test.ts` 新增一条 | 无 | 锁 `required: false`、自动解析、人话失败、手填路径仍做 40 位与精确 SHA 门禁;生产两个 workflow 仍 `required: true` | 本单合同。未弱化既有断言 | + +## 验证 + +| 命令 | 结果 | +| --- | --- | +| `python -c "import yaml; yaml.safe_load(...)"` | **yaml-ok** | +| `npx tsx --test tests/staging-backend-workflows.test.ts` | **43 / 37 pass / 6 fail**。本单新增「manual staging migration can leave deploy_sha empty…」**通过**。失败 6 条为既有 Windows 缺口(`python3` 退出码 9009、bash/rsync 脚本),与本单无关。 | +| 手填路径源码 | 原 `^[0-9a-f]{40}$`、精确 SHA 门禁查找、`head_check=deferred` 仍在解析之后 | +| 生产两个按钮 | 仍 `required: true` | + +真人:推 staging 后在 Gitea 打开 `Migrate Staging Database`,确认按钮还在、表单能开、SHA 可留空。见任务书 §9。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 37ad66aa..dbe40f5b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -229,7 +229,7 @@ | `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待验收 | `codex/readonly-pages-fix-20260916` | | `TASK-chart-page-blocking-open-20260915.md` | `PROGRESS-chart-page-blocking-open-20260915.md` | **P1**:星盘页开一次要等很久且常常只给一句「过一会儿再打开」。实测引擎五个调用合计 0.75 秒、mapper 13 种形态零抛出——瓶颈在 `/chart` 是动态路由 + 侧栏改成硬文档跳转,整页 SSR 等完 1 串 4 并才开始画,白屏最长 45 秒(BUG-716);`postEngine` 把 429/500/超时/坏 JSON 全碾成 `null` 且零日志,两种性质相反的故障共用一句文案(BUG-715);开页并行打两个重计算限流端点(配额 2)、无缓存,且「打开即有」印在失败页上(BUG-717)。**串行在 readonly-pages-fix 之后** | 待验收 | `codex/chart-page-blocking-open-20260915` | | `TASK-rectification-title-repair-migration-20260915.md` | — | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待领取 | `codex/rectification-title-repair-migration-20260915` | -| `TASK-staging-dispatch-autofill-sha-20260915.md` | — | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待领取 | `codex/staging-dispatch-autofill-sha-20260915` | +| `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` | | `TASK-staging-auto-migrate-on-deploy-20260915.md` | — | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待领取 | `codex/staging-auto-migrate-on-deploy-20260915` | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index d5539dde..4f0d7a93 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -719,6 +719,37 @@ test("Gitea migration remains manual and consumes only the gate-pinned web image ]); }); +test("manual staging migration can leave deploy_sha empty and still requires a successful gate", () => { + const workflow = read(giteaMigrationWorkflow); + const productionDeploy = read(giteaProductionWorkflow); + const productionMigrate = read(giteaProductionMigrationWorkflow); + + // 原值: deploy_sha required: true + // 新值: required: false;留空或 latest 自动解析最新成功门禁 SHA,填了仍走原校验 + // 原因: TASK-staging-dispatch-autofill-sha-20260915 产品授权;手抄 40 位与机器能算的值相同 + assert.match( + workflow, + /deploy_sha:\n\s+description: 留空=自动用最新一个通过门禁的 staging 提交;填写=迁移到指定的 40 位 SHA(回滚用)\n\s+required: false\n\s+type: string/, + ); + assert.match(workflow, /if \[\[ -z "\$\{DEPLOY_SHA:-\}" \|\| "\$DEPLOY_SHA" == "latest" \]\]/); + assert.match(workflow, /actions\/runs\?branch=staging&event=push&status=success&limit=100/); + assert.match(workflow, /resolved deploy_sha=\$DEPLOY_SHA \(latest gated staging commit\)/); + assert.match(workflow, /staging 上还没有任何通过门禁的提交,先等门禁跑完再迁移/); + assert.match(workflow, /查不到 staging 的门禁运行记录,Gitea API 请求失败/); + assert.match(workflow, /\[\[ "\$DEPLOY_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo "deploy_sha must be a lowercase full commit SHA"/); + assert.match(workflow, /actions\/runs\?head_sha=\$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100/); + assert.match(workflow, /no successful exact-SHA staging quality gate run found/); + assertOrder(workflow, [ + 'if [[ -z "${DEPLOY_SHA:-}" || "$DEPLOY_SHA" == "latest" ]]; then', + "resolved deploy_sha=$DEPLOY_SHA (latest gated staging commit)", + '[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA"', + "actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100", + "no successful exact-SHA staging quality gate run found", + ]); + assert.match(productionDeploy, /deploy_sha:[\s\S]*required: true/); + assert.match(productionMigrate, /deploy_sha:[\s\S]*required: true/); +}); + test("Gitea staging mutations use deploy-owned temporary paths", () => { for (const workflow of [read(giteaDeployWorkflow), read(giteaMigrationWorkflow)]) { assert.match(workflow, /mktemp -d \/tmp\/jyotisha-staging\.XXXXXXXXXX/);