Files
Jyotisha/docs/tasks/TASK-release-path-production-20261014.md
T

142 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 生产发布通路改规则 + 内测首发方案(2026-10-14)
- 日期:2026-10-14
- 来源:产品 10-14 内测体检后拍板:**内测跑生产**;发布规则**授权修改**(含 `.gitea/workflows/**`)。体检证据见本单 §2。
- 执行方:**Grok(Windows)**,分支 `codex/release-path-production-20261014`,只推这个分支。Grok 碰不到生产主机、不能 dispatch 生产工作流:本单产出的是 workflow / 脚本 / 测试 / runbook,生产上的每一步由产品在 Gitea 上点按钮执行。
- 验收:Claude(Linux)。合入 staging 由 Claude 做;**提升 main 与生产 dispatch 只由产品做**。
- BUG 编号:开工时核对最大号顺延(写作时 BUG-1317)。注意:其他已推任务书在 `docs/tasks/` 里已预占到 BUG-1346,开工时取 `docs/BUG_HISTORY.md` 与 `docs/tasks/` 已引用编号两者的最大号顺延。
- 串行:与 `TASK-beta-safety-net-20261014` 都可能改 `deploy/README.md`、`AGENTS.md`;**本单先做**,安全网单在本单合入后开工。与 issue #57(校正重算)、#58(Node 进程资源)不改同一文件;若 #58 改了 `deploy/docker-compose.server.yml`,本单不碰该文件。
## 1. 基线
- 基线 commit:`origin/staging` `48a2fadd`(`0be4cb29` 之后只多文档提交);最近一次含门禁改动且已部署 staging 的提交以开工时 `/api/health` 为准。
- 生产:`https://jyotisha.chat/api/health` `deployment.gitCommit = 7b620c7a`(08-16);`origin/main = 7b620c7a`;main..staging 约 1674 个提交。
- 生产相关工作流(全部 `workflow_dispatch`,由产品手动触发):`release-quality-gate.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`deploy-production.yml`。Gitea Actions 记录:四者最后一次运行都在 **08-16**(release 1872/1876/1880、recovery 1881、migrate 1882、deploy 1883)。
## 2. 事故实证(按符号定位)
### 2.1 纯文档 head 卡死发布
`deploy-production.yml` 步骤「Validate tested revision and gate run」:
1. 要求 `REQUESTED_SHA` 自己有 `backend-quality-gate.yml` 的 `push / staging / success` 运行。纯文档提交不在 `deploy/gated-paths.txt` 内,**不触发门禁,永远没有这条记录**。
2. `controller_sha`(= main head)`== staging_head`,否则拒绝。
3. 非回滚时 `REQUESTED_SHA == staging_head`,否则 `stale production revision refused`。
4. `controller_sha != REQUESTED_SHA` 时再找 controller 的门禁运行(纯文档 controller 同样找不到)。
5. 要求 `REQUESTED_SHA` 有 `release-quality-gate.yml` 的 dispatch 成功运行;该工作流 checkout 的是 `gitea.sha`(被 dispatch 的分支 head),所以 head_sha 只能等于分支 head。
6. 部署前后 `require_current_release_heads`:`current_main == DEPLOY_SHA && current_staging == DEPLOY_SHA`。
`create-production-recovery.yml`(`GITEA_SHA == DEPLOY_SHA`,`main_head == staging_head == DEPLOY_SHA`,release 运行 head_sha == DEPLOY_SHA)与 `migrate-production-database.yml`(同样三条 + push 门禁运行)有相同约束。
结果:staging 只要在最后一次代码提交之后再推过任何文档(agent 循环每天都会推),四个生产工作流全部无法通过;唯一出路是冻结 staging。产品选择改规则。
### 2.2 待上线迁移:63 个,全部是新增文件
`git diff --name-status origin/main origin/staging -- frontend/supabase/migrations frontend/db/migrations`:63 个 `A`,`frontend/db/migrations` 0 个。按顶层语句(去掉函数体后)分级:
| 级别 | 数量 | 迁移 | 说明 |
| --- | --- | --- | --- |
| **A 破坏性 / 改写用户数据** | 2 | `20260925020000_chart_subject_typed_columns.sql`:先 backfill 到类型化列,再顶层 `delete from public.chart_profiles where role = 'self'`(本人资料改由 `profiles` 承载)。`20260921010000_retire_chat_session_archive.sql`:`update public.chat_sessions set archived_at = null where archived_at is not null`(所有已归档会话回到列表) | 不可逆;必须先在恢复点副本上演练 |
| **B 改写数据(范围有限)** | 5 | `20260816010000_accept_exact_family_birth_times`(`profiles` 家人精确时间直接 accepted)、`20260822010000_declared_birth_window_clocks`(按时段回填申报窗口)、`20260823010000_rectification_occupation_dateless`(职业/外貌/胎记证据置 confirmed)、`20260906030000_rectification_block_scan_stage`(minute 阶段未采用 case 改 block_scan)、`20260925030000_chart_subject_trust_and_limit`(**他人档案的 accepted / confirmed 时间全部退回 reported、清空 active_birth_***) | 演练时逐条记行数;`20260925030000` 会让他人档案失去已采用时间,需产品知情 |
| **C 权限收紧** | 约 45 | 绝大多数文件含 `revoke`(函数 / 表 EXECUTE、SELECT 收回) | 上线后 web / worker 角色可能失权(同类问题见 BUG-1063:07-18 迁移收回了 worker 对人物资料表的权限);演练后必须跑主路径冒烟 |
| **D 纯新增** | 其余 | 加表 / 列 / 函数 / 索引 | 低风险 |
注:A+B 合计 7 条是按「顶层 `delete` / `update`」机械筛出;函数体内的 `delete`(注销、清理)不计。执行方需复核每条分级并补全 C/D 清单。
另:重复时间戳仍有 3 组(`20260806010000`、`20260816010000`、`20260920010000`),`frontend/db/migrations` 另有 `20260807010000` 一组。迁移器按文件名记账,功能无害,本单只记录不改名(改名会让已迁移的 staging 重复执行)。
### 2.3 生产可以误开测试验证码
`frontend/src/modules/identity/config.ts::readTestOtp`:只要设置了 `IDENTITY_TEST_OTP`(6 位数字)就接受固定验证码,不区分环境。`deploy/validate-production-env.sh` 不检查这个变量。生产登录页当前负载 `testOtp: null`(只读 GET 核对),但没有任何防误配机制;内测跑生产后,误配一次即任何人可用固定码登录任意邮箱。
## 3. 根因
- 生产工作流在 08-16 前设计,当时没有「纯文档提交不触发门禁」(`deploy/gated-paths.txt` 与 `deploy/is-docs-only-range.sh` 是之后才加的),四个工作流都假设「每个 staging head 都有门禁运行」。staging 侧已用 `is-docs-only-range.sh` 解决(AGENTS §2.4),生产侧没跟上。
- 生产停在 08-16 两个月,63 个迁移在 staging 逐个上线、逐个验证,但从未作为一批在生产数据上演练过。
- 测试验证码只在 staging 配置文档里出现,生产校验脚本是白名单思路,没把它列为禁止项。
## 4. 决策记录(产品 2026-10-14 授权)
1. **授权修改 `.gitea/workflows/` 下的 `deploy-production.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`release-quality-gate.yml`**(只为本单的发布规则;不改 staging 工作流的触发与部署逻辑)。这推翻 AGENTS.md §2.7「不改 `.gitea/workflows/**`」在本单范围内的限制,§2.7 其余(不自行提升 main、不动 DNS)不变。
2. **新发布规则**(取代「请求 SHA == staging head」):
- 记 `H` = 当前 staging head,`main` 必须快进到 `H`(继续满足 AGENTS §2.5「main 与 staging 同一 SHA」,不得 merge)。
- 发布 SHA `R` = **最近一次 `backend-quality-gate` push 成功的 staging 提交**,并且 `deploy/is-docs-only-range.sh R H` 退出 0(R 是 H 的祖先,R..H 之间只改了门禁清单之外的路径)。
- 由于 R..H 不改门禁路径(含 `deploy/**`、`.gitea/**`、`frontend/**`、`scripts/**`),R 与 H 的部署脚本、镜像输入完全相同;controller 一律取 R 的门禁产物。
- `release-quality-gate` 仍从 main dispatch(跑在 H 上),记录的 head_sha = H;生产三个工作流接受「release 运行的 head_sha ∈ {R, 当前 main},且该 SHA 与 R 之间纯文档」。
- staging 公网 `/api/health` 的 `deployment.gitCommit` 必须等于 R(与 AGENTS §2.4 一致)。
- `allow_rollback=true` 的回滚语义不变(回滚目标仍须在 main 历史里、有门禁运行)。
3. 同步改 AGENTS.md §2.4–§2.5 与 `deploy/README.md` 的生产发布段落,文字与新规则一致。
4. 生产首发的执行顺序固定为:**release 门 → 恢复点(含还原验证)→ 迁移演练报告确认 → 生产迁移 → 部署 → 核对**。每一步由产品在 Gitea 点按钮;Grok 不 dispatch 任何生产工作流。
5. 生产运行时拒绝测试验证码(双保险:env 校验脚本 + 运行时)。
6. 不在本单做:备份定时化、监控告警、日志轮转(见 `TASK-beta-safety-net-20261014`);按用户限流与并发压测(另单);备案 / 主体 / 律师(产品后续自理)。
## 5. 硬红线
1. 不得放宽任何安全检查:仍要求 R 有 push 门禁成功运行、有 release 门成功运行、有 ≤24h 且 `restore_verified=true` 的恢复点(迁移)、`main == staging`、生产 `previous_sha` 到 R 的单向前进校验、回滚显式确认。新规则只把「等于 staging head」换成「是 staging head 且之后纯文档」,`is-docs-only-range.sh` 返回 1 或 2 一律拒绝。
2. `is-docs-only-range.sh` 不改语义;若需在工作流里调用,用已 checkout 的 R 或 H 版本,API 模式用现有 `GITEA_TOKEN`,不新增密钥。
3. 不改 staging 部署工作流、不改 `deploy/gated-paths.txt`(若发现必须改,停下回报)。
4. 不在任何日志 / 回报里打印密钥、数据库口令、`.env.production*` 内容、恢复点加密密钥。
5. 迁移文件**不改**(已在 staging 执行过,改内容会造成两库不一致);需要补救就新增迁移,并在回报里说明。
6. Grok 不 dispatch 任何生产工作流、不 push main、不推 staging。
7. 不写真实用户资料。演练报告只写行数和表名,不写任何行内容。
## 6. 任务分解
| 项 | 内容 | 验收标准 |
| --- | --- | --- |
| **T1 发布 SHA 解析脚本** | 新增 `deploy/resolve-release-revision.sh`(名字可调):输入 `REQUESTED_SHA`、读 main / staging head(Gitea API),校验 `main == staging == H`、`REQUESTED_SHA` 有 push 门禁成功运行、`is-docs-only-range.sh REQUESTED_SHA H` 退出 0;输出 `release_sha`、`gate_run_id`、`head_sha` 供后续步骤使用。四个生产工作流共用这一个脚本,不各写一份 jq。 | Python 测试(mock Gitea API 响应:本地 HTTP 桩或把 curl 换成可注入的读文件函数)覆盖:R==H 通过;R<H 且纯文档通过;R<H 但中间改了 `frontend/**` 拒绝;R 不是 H 祖先拒绝;R 无门禁运行拒绝;main≠staging 拒绝;API 失败(exit 2)拒绝 |
| **T2 deploy-production** | 「Validate tested revision」改用 T1;controller 一律取 R 的门禁产物;release 门检查接受 head_sha ∈ {R, H}(且纯文档);`require_current_release_heads` 改为 `main == staging` 且 `is-docs-only-range R current_head` 退出 0;回滚分支逻辑不变 | 扩展 `frontend/tests/staging-backend-workflows.test.ts`(或新增同类测试)对 workflow 文本做合同断言:仍检查 push 门禁、release 门、main==staging、回滚确认;不再出现「`REQUESTED_SHA != staging_head` 即拒绝」的非回滚条件;调用 T1 脚本 |
| **T3 create-production-recovery / migrate-production-database / release-quality-gate** | recovery 与 migrate 的 `GITEA_SHA == DEPLOY_SHA`、`main == staging == DEPLOY_SHA` 改为 T1 语义(dispatch 自 main,`DEPLOY_SHA` = R);release 门不改 checkout,但在 job summary / 输出里写明「H 相对最近门禁 R 是否纯文档」 | 同 T2 的合同测试;三个工作流对「R 落后 H 且纯文档」给出同一结论 |
| **T4 文档** | AGENTS.md §2.4–§2.5、§2.7(注明 10-14 授权范围);`deploy/README.md` 生产发布段落;新增 `docs/operations/production-release-runbook-20261014.md`:产品逐步点哪个按钮、填什么、看到什么算成功、失败怎么办 | runbook 每一步可照做;与新 workflow 文本一致(Claude 逐条对照) |
| **T5 迁移演练脚本与报告模板** | 新增 `deploy/rehearse-production-migrations.sh`(只在主机上由产品执行):从最新恢复点解密 → 还原到一次性数据库 `rehearse_<时间>` → 记录迁移前 7 条 A/B 迁移涉及表的行数 → 按迁移器顺序执行 63 个迁移 → 记录迁移后行数与每个迁移的 `RAISE NOTICE` → 用 web 运行角色做只读冒烟(能读本人 profile、能读会话列表、worker 角色能读人物资料表——对应 BUG-1063)→ 删除一次性库 → 输出不含任何行内容的报告。复用 `run-production-recovery.sh` 的加密 / 还原 / 锁 / 磁盘检查写法,不另造。另写 `docs/testing/production-migration-rehearsal-20261014.md` 报告模板与通过判据 | 在**本地 PostgreSQL**(Docker 或 Claude 机器的替身)上用 staging 迁移前的 schema 快照跑通脚本(演示数据只能是虚构数据);判据写清:A 类两条的行数变化与预期一致、C 类冒烟全过、无迁移报错 |
| **T6 迁移清单** | `docs/operations/production-migrations-20261014.md`:63 个迁移逐条:文件名、级别(A/B/C/D)、一句话作用、对生产用户的影响、演练要看的指标。§2.2 是初筛,执行方逐条复核 | 63 条齐全;A/B 每条有「产品需知」一句 |
| **T7 禁止生产测试验证码** | `deploy/validate-production-env.sh`:出现 `IDENTITY_TEST_OTP=`(任何值,含空)即失败。`readTestOtp` 或其调用处:当 `AUTH_USER_ORIGIN` 为生产域名(`https://jyotisha.chat`)时,设置了测试码直接抛错拒绝启动(fail closed);staging 行为不变 | Shell 测试:生产 env 含该变量失败、不含通过;TS 测试:生产 origin + 测试码抛错、staging origin + 测试码返回码、未设置返回 null |
| **T8 首发后核对清单** | `docs/testing/production-first-release-20261014.md`:`/api/health` 200 且 `deployment.gitCommit == R`、`apiGitCommit == R`、`swisseph_available == true`(内部 health)、`database.requiredMigrationsPresent == true` 且 `latestMigration` 等于 staging 同字段;`/login` 200 且负载 `testOtp` 为 null;未登录 `/api/account` 401;用受控邮箱走一遍登录 → 建盘 → 一次对话 → 打开报告页;后台 `/admin` 只有管理员能进 | 清单可照做,每条有预期值 |
## 7. 让步顺序
T1、T2、T3、T7 不可让(没有它们发不了版或有安全缺口)。T4 不可让(文档与规则不一致会让下一个人照旧文档操作)。T5 可先交脚本 + 本地验证,主机演练由产品执行后回填。T6、T8 可与 T5 一起交。
## 8. 验收(执行方自测 + Claude 复验)
- 前端:`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败名单与开工基线逐名一致(Windows 已知失败按 `tools/agent-loop/windows-known-failures.txt`)。
- Python:新增测试 + 快速门 `scripts/run_quality_gate.py --profile quick`。
- Shell:`bash -n` 全部新脚本;`shellcheck`(有则跑)。
- 工作流:本地无法运行 Gitea Actions——合同测试断言 workflow 文本;Claude 合入 staging 后,`backend-quality-gate` 会跑在含新 workflow 的提交上(`.gitea/**` 是门禁路径)。生产工作流的实跑由产品首发时完成,结果回填 T8 清单。
- 文档:`docs/BUG_HISTORY.md`(发布卡死、测试验证码各一条)、`CHANGELOG.md`(一句:生产可以在 staging 之后有文档提交时照常发布)、`docs/tasks/PROGRESS-release-path-production-20261014.md`、`docs/tasks/README.md` 状态。
## 9. 开工前置
```
git fetch origin --prune
git status -sb
git worktree add -b codex/release-path-production-20261014 .worktrees/release-path-production-20261014 origin/staging
```
开工时记录 `origin/staging` SHA 与 staging `/api/health` 的 `deployment.gitCommit`,写进进度记录。
## 9a. 生产首发步骤(写进 T4 runbook,由产品执行,本单合入 staging 并部署 staging 之后)
1. 确认 staging `/api/health` 的 gitCommit = R,且 `deploy/is-docs-only-range.sh R <staging head>` 退出 0。
2. 产品把 main 快进到 staging head:`git push origin <staging head>:main`(或 Gitea 界面快进),不得 merge。
3. Gitea → Release Quality Gate,Use workflow from: `main`,等成功。
4. Gitea → Create Production Recovery Point,`deploy_sha = R`,记下 `recovery_reference`、`recovery_created_at`。
5. 主机上按 T5 脚本演练迁移(或由 Claude 远程指导),报告填进 `docs/testing/production-migration-rehearsal-20261014.md`;A 类两条行数、C 类冒烟全部符合判据才继续。
6. Gitea → Migrate Production Database,填 R、恢复点引用、时间、`restore_verified=true`(24 小时内)。
7. Gitea → Deploy production,`deploy_sha = R`,`allow_rollback=false`,`verification_mode=public`。
8. 按 T8 清单核对。失败:应用层用 Deploy production `allow_rollback=true` 回到 `7b620c7a`(注意:迁移已执行后旧代码可能读不了新 schema——回滚以「恢复点还原 + 回滚应用」为准,runbook 里写清由谁、怎么还原)。
## 10. 需产品提供 / 拍板
1. **首发时间窗**:建议选晚上低峰,整个流程约 1–2 小时;首发前一天冻结 agent 循环向 staging 推代码(文档可以推)。
2. **`20260925030000` 他人档案已采用时间退回 reported**:内测前接受,还是另立补救迁移(例如只退回无校正记录的)——演练报告出来后拍板。
3. **`20260921010000` 已归档会话全部回到列表**:确认这是想要的(该迁移对应「取消归档功能」的产品决定)。
4. **迁移演练在哪执行**:主机上由产品执行脚本,或给 Claude 一次受控的主机只读 + 一次性库操作权限(默认前者)。
5. **回滚口径**:首发失败时是否接受「恢复点整库还原」(会丢失首发后写入的数据);内测开放前失败则无影响。
## 11. 产品答复(2026-10-14,覆盖上文对应建议值)
1. 首发时间窗:首发前一天冻结代码推送(只许纯文档);首发失败**接受整库还原**到首发前恢复点。
2. 迁移演练由**产品本人**在 Gitea 上点按钮执行(Grok 只交脚本与 runbook);runbook 写成产品可照做的步骤,每步写清看哪个输出判断通过。
3. `20260925030000`(他人档案时间退回 reported):演练时先数生产受影响行数(预期 0,生产停在 08-16、人物档案 09 月下旬才上线),非 0 则停下报产品。