fix(ci): migrate staging automatically before deploy
Independent Staging Quality Gate / validate (push) Successful in 10m6s
Independent Staging Quality Gate / publish (push) Successful in 4m18s

Quality gate now dispatches Migrate Staging Database, waits for success, then dispatches Deploy staging. Automatic migrate attests the in-progress gate run so the two jobs cannot deadlock. Manual migrate is unchanged. Staging schema changes must stay backward-compatible with the currently deployed app.
This commit is contained in:
jesse-ux
2026-09-15 23:36:09 +08:00
parent 1a73f64ecd
commit 69ede4367f
8 changed files with 192 additions and 27 deletions
+49 -2
View File
@@ -526,10 +526,57 @@ jobs:
exit 1
fi
fi
payload="$(jq -cn --arg ref "refs/heads/staging" --arg deploy_sha "$DEPLOY_SHA" --arg gate_run_id "$gate_run_id" \
'{ref:$ref,inputs:{deploy_sha:$deploy_sha,gate_run_id:$gate_run_id,allow_rollback:"false"}}')"
response_file="$(mktemp "${RUNNER_TEMP:-/tmp}/jyotisha-deploy-dispatch.XXXXXX")"
trap 'rm -f -- "$response_file"' EXIT
migrate_payload="$(jq -cn --arg ref "refs/heads/staging" --arg deploy_sha "$DEPLOY_SHA" --arg gate_run_id "$gate_run_id" \
'{ref:$ref,inputs:{deploy_sha:$deploy_sha,gate_run_id:$gate_run_id}}')"
curl --fail --silent --show-error --request POST \
--header "Authorization: token $GITEA_TOKEN" \
--header "Content-Type: application/json" \
--data "$migrate_payload" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/workflows/migrate-staging-database.yml/dispatches?return_run_details=true" \
--output "$response_file"
if ! migrate_run_id="$(jq -er '.workflow_run_id | select(type == "number" and . > 0)' "$response_file")"; then
echo "dispatch 没有返回 run id,改按 SHA 查找迁移 run"
migrate_run_id=""
for find_attempt in $(seq 1 12); do
migrate_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?head_sha=$DEPLOY_SHA&event=workflow_dispatch&limit=20")"
migrate_run_id="$(jq -r --arg sha "$DEPLOY_SHA" '
[(.workflow_runs // [])[] | select(
(.path | split("@")[0] | endswith("migrate-staging-database.yml")) and
.head_sha == $sha and
.event == "workflow_dispatch"
)] | sort_by(.id) | reverse | first | .id // empty
' <<<"$migrate_runs")"
[[ "$migrate_run_id" =~ ^[0-9]+$ ]] && break
sleep 5
done
[[ "$migrate_run_id" =~ ^[0-9]+$ ]] || { echo "找不到刚触发的 staging 迁移 run" >&2; exit 1; }
fi
echo "triggered staging migration run $migrate_run_id for $DEPLOY_SHA"
migrate_ok=false
for wait_attempt in $(seq 1 120); do
migrate_run="$(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/$migrate_run_id")"
migrate_status="$(jq -r '.status // ""' <<<"$migrate_run")"
migrate_conclusion="$(jq -r '.conclusion // ""' <<<"$migrate_run")"
if [[ "$migrate_conclusion" == success ]]; then
migrate_ok=true
break
fi
if [[ "$migrate_status" == completed || -n "$migrate_conclusion" ]]; then
echo "staging 迁移失败(run $migrate_run_id 结论 ${migrate_conclusion:-$migrate_status}),不部署" >&2
exit 1
fi
sleep 10
done
[[ "$migrate_ok" == true ]] || { echo "staging 迁移超时(run $migrate_run_id 在 20 分钟内没有完成)" >&2; exit 1; }
echo "staging 迁移成功(run $migrate_run_id"
payload="$(jq -cn --arg ref "refs/heads/staging" --arg deploy_sha "$DEPLOY_SHA" --arg gate_run_id "$gate_run_id" \
'{ref:$ref,inputs:{deploy_sha:$deploy_sha,gate_run_id:$gate_run_id,allow_rollback:"false"}}')"
curl --fail --silent --show-error --request POST \
--header "Authorization: token $GITEA_TOKEN" \
--header "Content-Type: application/json" \
+34 -11
View File
@@ -7,6 +7,10 @@ on:
description: 留空=自动用最新一个通过门禁的 staging 提交;填写=迁移到指定的 40 位 SHA(回滚用)
required: false
type: string
gate_run_id:
description: Source staging quality-gate run ID; automatic dispatch supplies it
required: false
type: string
permissions:
contents: read
@@ -38,6 +42,7 @@ jobs:
id: revision
env:
DEPLOY_SHA: ${{ inputs.deploy_sha }}
REQUESTED_GATE_RUN_ID: ${{ inputs.gate_run_id }}
run: |
set -euo pipefail
if [[ -z "${DEPLOY_SHA:-}" || "$DEPLOY_SHA" == "latest" ]]; then
@@ -77,18 +82,36 @@ jobs:
select(test("^[0-9a-f]{40}$"))
'
}
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?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
[.workflow_runs[] | select(
if [[ -n "${REQUESTED_GATE_RUN_ID:-}" ]]; then
[[ "$REQUESTED_GATE_RUN_ID" =~ ^[0-9]+$ ]] || { echo "gate_run_id must be numeric" >&2; exit 1; }
source_gate="$(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/$REQUESTED_GATE_RUN_ID")"
jq -e --arg sha "$DEPLOY_SHA" --argjson id "$REQUESTED_GATE_RUN_ID" '
.id == $id and
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
.head_sha == $sha and .head_branch == "staging" and .event == "push"
' <<<"$source_gate" >/dev/null || { echo "gate_run_id does not attest the requested staging SHA" >&2; exit 1; }
source_conclusion="$(jq -r '.conclusion // ""' <<<"$source_gate")"
if [[ -n "$source_conclusion" && "$source_conclusion" != success ]]; then
echo "source staging quality gate did not succeed: $source_conclusion" >&2
exit 1
fi
gate_run_id="$REQUESTED_GATE_RUN_ID"
else
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?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
fi
staging_head="$(read_ref_sha staging)"
head_check=current
if [[ "$DEPLOY_SHA" != "$staging_head" ]]; then
+1 -1
View File
@@ -103,7 +103,7 @@
3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写"原值 / 新值 / 原因"三栏说明,不得静默弱化。
4. 合同测试的 fixture 必须来自真实引擎响应(golden),不得手造形状。
5. 改 UI 的提交同时更新 `frontend/DESIGN.md`;新文案对照 `frontend/docs/VOICE.md`
6. 不改数据库结构的轮次不得顺带动迁移;动表的轮次必须真跑 `npm run test:db`
6. 不改数据库结构的轮次不得顺带动迁移;动表的轮次必须真跑 `npm run test:db`staging 迁移在部署之前自动应用,因此必须对当前已部署的那一版代码向后兼容:加列、加表、加索引、加触发器、加函数可以同轮;删列、删表、重命名、收紧 `NOT NULL` / `CHECK`、改类型必须拆成两轮——先加新的并部署代码,再删旧的。
7. 不得顺手升级依赖、不得顺手修不在任务书里的 warning;发现了写进 `BLOCKED.md` 或进度记录。
## 8. 隐私与安全
+10 -11
View File
@@ -191,10 +191,10 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se
1. Complete the server and Gitea bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, and configure the listed Actions variables/secrets. No repository-level Supabase variables are required. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect.
2. Push the reviewed test revision directly to `staging`. It may advance independently of `main`; do not merge or reset `main` merely to satisfy staging release mechanics.
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.
4. The successful publish job rechecks that `staging` still points at the exact SHA, then dispatches `Migrate Staging Database` for that SHA, waits for it to succeed (no-op when nothing is pending), and only then dispatches `Deploy staging`. You do not click either button for a normal push. Migration failure fails the gate and does not deploy.
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**. 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.
6. The **Migrate Staging Database** button remains for rollback catch-up and one-off data repairs. Leave `deploy_sha` empty unless you are targeting an earlier gated SHA. A manual run does not dispatch deployment.
7. Confirm `https://staging.jyotisha.chat/api/health` reports the SHA from step 2.
### Resetting one staging account
@@ -261,16 +261,15 @@ PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mappi
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 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.
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 first dispatches `Migrate Staging Database` for that SHA and waits for success (a no-op when nothing is pending), then dispatches `Deploy staging`. Do not click either button for a normal push. If `staging` already advanced by a gated path, dispatch is refused.
3. The dispatched `Deploy staging` workflow still validates the source gate run and checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. After the automatic migrate, that check should be clean. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration.
4. Use **Migrate Staging Database** manually only for rollback catch-up or a one-off data repair. Leave `deploy_sha` empty unless targeting an earlier gated SHA. A manual run does not dispatch deployment.
5. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
6. After health verification, create the local encrypted backup described below.
**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.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker exits before app changes when a migration is pending. Its message includes the exact SHA and the `Migrate Staging Database` workflow name. A failed migration does not re-dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker still exits before app changes when a migration is pending. After the automatic pre-deploy migrate that should be rare. A failed automatic migration fails the quality gate and does not dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
### Production recovery point before schema migration
+13
View File
@@ -89,6 +89,19 @@ compose=("${docker_command[@]}" compose -p jyotisha-staging -f deploy/docker-com
"${compose[@]}" up -d --no-build --pull never --wait postgres
"${compose[@]}" exec -T postgres psql -v ON_ERROR_STOP=1 -U postgres -d jyotisha \
-f /dev/stdin < deploy/postgres/002-ensure-business-compatibility-roles.sql
set +e
pending_output="$("${compose[@]}" --profile migration-check run --rm migration-checker 2>&1)"
pending_status=$?
set -e
if [ "$pending_status" -eq 0 ]; then
echo "无待应用迁移"
elif [ "$pending_status" -eq 3 ]; then
echo "待应用迁移:"
printf '%s\n' "$pending_output"
else
echo "待应用迁移检查退出码 $pending_status;继续交给 migrator"
printf '%s\n' "$pending_output"
fi
"${compose[@]}" --profile migration run --rm migrator
"${compose[@]}" exec -T postgres psql -U postgres -d jyotisha -Atc \
'select filename from migration.schema_migrations order by filename'
@@ -0,0 +1,50 @@
# PROGRESS · staging 部署自动先迁移(2026-09-15
工作树:`.worktrees/staging-auto-migrate-on-deploy-20260915`
分支:`codex/staging-auto-migrate-on-deploy-20260915`
任务书基线:`de47c06d`;开工时 `origin/staging` = **`1a73f64e`**(已含 SHA 留空自动解析)。
本机 Windows。
未开 BUG 号。未改 `CHANGELOG.md`。未改 `deploy-production.yml` / `migrate-production-database.yml` / `deploy-staging.yml`。质量门禁未加入 `staging-mutation`。回滚部署仍不触发迁移。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 1 gate 先触发迁移再部署 | 完成 | dispatch 段:先 migrate,等成功(20 分钟),再 deploy。失败/超时不部署,gate 这一步红 |
| 2 日志说清有没有迁移 | 完成 | `run-staging-migration.sh` 应用前跑 `migration-check`;0=「无待应用迁移」,3=列出文件名。退出码 3 **不**阻断随后的 migrator |
| 3 AGENTS §7.6 + README | 完成 | 向后兼容纪律已写入;staging 正常推送不用手点 |
## 相对任务书 §4.1 的必要偏离
任务书写「只改 quality-gate 的 dispatch 段、不得改 migrate 的校验」。按字面做会死锁:
- 迁移 workflow 原来要求「已经成功的精确 SHA 门禁 run」
- 自动触发时,那次门禁 **还在跑**(正在等迁移),查 `status=success` 必然失败
- 若迁移再去等门禁成功,而门禁又在等迁移,两边一起超时
因此 migrate 增加了 **可选** `gate_run_id`(与 deploy-staging 同名、同语义):
- 自动路径:gate 把自己的 run id 传进去。迁移只核验这是 `backend-quality-gate.yml`、SHA/branch/event 对得上、结论不是失败;**不等**整次 run 结束。artifact 在 dispatch 之前已经上传,可以接着下载。
- 手动路径:不传 `gate_run_id`,仍走原来的「必须找到成功门禁 run」。
没有走让步 2(改成迁移成功后再 dispatch 部署),因为门禁里等待做得到,且硬红线 3 要求迁移失败时 **gate 自己变红**
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `staging-backend-workflows.test.ts` 门禁 dispatch | 只锁 `deploy-staging.yml/dispatches` | 先 migrate 再 deploy,并锁超时/失败文案 | 本单任务 1 |
| 同上,手动迁移输入 | 只有 `deploy_sha` | 加上可选 `gate_run_id` | 自动路径需要给还在跑的门禁做背书 |
| 同上,`run-staging-migration.sh` 顺序 | postgres → roles SQL → migrator | 中间加 `migration-check` | 任务 2 应用前打印待应用文件 |
未弱化生产两个 workflow 的 `required: true`。未弱化手动迁移的精确 SHA 门禁查找。
## 验证
| 命令 | 结果 |
| --- | --- |
| `python -c "import yaml; yaml.safe_load(...)"` 两份 workflow | **yaml-ok** |
| `npx tsx --test tests/staging-backend-workflows.test.ts` | **43 / 37 pass / 6 fail**。本单改动的断言(门禁先 migrate 再 deploy、可选 `gate_run_id`、migration-check 日志、AGENTS 纪律)全部通过。失败 6 条为既有 Windows 缺口(`python3` 9009、bash/rsync),与上一单同一清单。 |
真人:这次推送会触发门禁,应走出「迁移 → 部署」。Gitea 上三个 workflow 都应还在,手动迁移表单仍能打开。
+1 -1
View File
@@ -230,7 +230,7 @@
| `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` | `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-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-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+ | 待领取 | — |
## 命名与归档
@@ -681,12 +681,28 @@ test("Gitea staging deployment is dispatched from staging after the exact push g
assert.match(quality, /GITEA_REPOSITORY: \$\{\{ gitea\.repository \}\}/);
assert.match(quality, /GITEA_TOKEN: \$\{\{ secrets\.GITEA_TOKEN \}\}/);
assert.match(quality, /staging advanced before deployment dispatch; refusing stale release/);
assert.match(quality, /actions\/workflows\/migrate-staging-database\.yml\/dispatches\?return_run_details=true/);
assert.match(quality, /actions\/workflows\/deploy-staging\.yml\/dispatches\?return_run_details=true/);
assert.match(quality, /triggered staging migration run \$migrate_run_id for \$DEPLOY_SHA/);
assert.match(quality, /staging 迁移成功(run \$migrate_run_id/);
assert.match(quality, /staging 迁移失败(run \$migrate_run_id 结论/);
assert.match(quality, /staging 迁移超时(run \$migrate_run_id 在 20 分钟内没有完成)/);
assert.match(quality, /--arg ref "refs\/heads\/staging"/);
assert.match(quality, /gate_run_id="\$\{GITHUB_RUN_ID:-\}"/);
assert.match(quality, /--arg gate_run_id "\$gate_run_id"/);
assert.match(quality, /allow_rollback:"false"/);
assert.match(quality, /\.workflow_run_id \| select\(type == "number" and \. > 0\)/);
assertOrder(quality, [
"Dispatch exact-SHA staging deployment",
"migrate-staging-database.yml/dispatches?return_run_details=true",
"triggered staging migration run $migrate_run_id for $DEPLOY_SHA",
"staging 迁移成功(run $migrate_run_id",
"deploy-staging.yml/dispatches?return_run_details=true",
"Dispatched Deploy staging run $deploy_run_id for $DEPLOY_SHA",
]);
const agents = read(new URL("../../AGENTS.md", import.meta.url));
assert.match(agents, /staging 迁移在部署之前自动应用/);
assert.match(agents, /先加新的并部署代码,再删旧的/);
});
test("Gitea migration remains manual and consumes only the gate-pinned web image", () => {
@@ -729,8 +745,10 @@ test("manual staging migration can leave deploy_sha empty and still requires a s
// 原因: 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/,
/deploy_sha:\n\s+description: 留空=自动用最新一个通过门禁的 staging 提交;填写=迁移到指定的 40 位 SHA(回滚用)\n\s+required: false\n\s+type: string\n\s+gate_run_id:\n\s+description: Source staging quality-gate run ID; automatic dispatch supplies it\n\s+required: false\n\s+type: string/,
);
assert.match(workflow, /REQUESTED_GATE_RUN_ID: \$\{\{ inputs\.gate_run_id \}\}/);
assert.match(workflow, /gate_run_id does not attest the requested staging SHA/);
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\)/);
@@ -1022,9 +1040,16 @@ test("manual migration uses only PostgreSQL and the digest-pinned migrator", ()
assertOrder(runner, [
"up -d --no-build --pull never --wait postgres",
"002-ensure-business-compatibility-roles.sql",
"--profile migration-check run --rm migration-checker",
"--profile migration run --rm migrator",
]);
// 原值: 应用前没有 --check
// 新值: 先 migration-check 打出待应用文件名或「无待应用迁移」,再跑 migrator
// 原因: TASK-staging-auto-migrate-on-deploy-20260915 任务 2check 退出码 3 不阻断应用
assert.match(runner, /--profile migration run --rm migrator/);
assert.match(runner, /无待应用迁移/);
assert.match(runner, /待应用迁移:/);
assert.match(runner, /pending_status" -eq 3/);
assert.match(runner, /select filename from migration\.schema_migrations order by filename/);
assert.doesNotMatch(runner, /docker-compose\.server\.yml/);
assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)/);
@@ -1427,6 +1452,14 @@ test("publish dispatch and staging deploy accept docs-only advances only through
assert.match(dispatch, /bash deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_staging_sha"/);
assert.match(dispatch, /staging advanced before deployment dispatch; refusing stale release/);
assert.match(dispatch, /--arg deploy_sha "\$DEPLOY_SHA"/);
assert.match(dispatch, /migrate-staging-database\.yml\/dispatches\?return_run_details=true/);
assert.match(dispatch, /triggered staging migration run \$migrate_run_id for \$DEPLOY_SHA/);
assert.match(dispatch, /staging 迁移成功(run \$migrate_run_id/);
assertOrder(dispatch, [
"migrate-staging-database.yml/dispatches?return_run_details=true",
"staging 迁移成功(run $migrate_run_id",
"deploy-staging.yml/dispatches?return_run_details=true",
]);
// deploy-staging never checks out a branch: the checker comes from the
// gate-attested controller bundle and decides via the Gitea compare API.