fix(release): gate channel and distribution acceptance

This commit is contained in:
key
2026-07-29 12:51:31 +08:00
parent 5ee002ddcd
commit 46f4616bd4
10 changed files with 209 additions and 8 deletions
+1 -1
View File
@@ -318,7 +318,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_BACKEND=true
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh
``` ```
四个远端核验开关可以按实际发布单元独立组合,但只要启用任一开关就必须提供 `RELEASE_REMOTE_URL`。定向模式只要求对应的本地产物保持新鲜:静态核验要求管理端/H5,后端核验要求后端 JAR,单独 schema 或 8 月 1 日业务就绪核验不要求无关构建产物。默认以当前 `HEAD` 判断产物时间;冻结 RC 应同时设置 `RELEASE_ARTIFACT_COMMIT` 和 `RELEASE_LOCAL_BACKEND_PATH`,前者必须是当前 `HEAD` 的祖先,后者必须指向实际准备发布的 JAR,避免后续文档提交误伤冻结物或误用 `target` 下的其他构建。`RELEASE_VERIFY_REMOTE_BACKEND=true` 会打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准;它不再隐式要求管理端/H5 静态资源匹配。同时启用前三项只能证明完整包匹配;8 月 1 日最终 Go 还必须增加 `RELEASE_VERIFY_AUG1_READINESS=true`。该门禁先严格校验 `docs/content-candidates/aug1-release-approval.json`:5/5 场景、30/30 问题证据及五类负责人均签认后,才继续只读检查生产固定码模式、五个直通角色各至少两名有效处理人,以及五个已发布场景的版本、内容哈希、审核人和 SOP 引用。它不请求验证码、不发送短信、不登录、不输出账号或处理人身份,也不写数据库。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。开启远端后端或 schema 核验时,预检还会只读检查 `wygj-aihr.service` 及其 `EnvironmentFile` 的有效配置,`AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 必须为 `false` 或未设置;无法读取引用的环境文件会失败关闭,而不会把本机环境变量当作线上证据。 四个远端核验开关可以按实际发布单元独立组合,但只要启用任一开关就必须提供 `RELEASE_REMOTE_URL`。定向模式只要求对应的本地产物保持新鲜:静态核验要求管理端/H5,后端核验要求后端 JAR,单独 schema 或 8 月 1 日业务就绪核验不要求无关构建产物。默认以当前 `HEAD` 判断产物时间;冻结 RC 应同时设置 `RELEASE_ARTIFACT_COMMIT` 和 `RELEASE_LOCAL_BACKEND_PATH`,前者必须是当前 `HEAD` 的祖先,后者必须指向实际准备发布的 JAR,避免后续文档提交误伤冻结物或误用 `target` 下的其他构建。`RELEASE_VERIFY_REMOTE_BACKEND=true` 会打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准;它不再隐式要求管理端/H5 静态资源匹配。同时启用前三项只能证明完整包匹配;8 月 1 日最终 Go 还必须增加 `RELEASE_VERIFY_AUG1_READINESS=true`。该门禁先严格校验 `docs/content-candidates/aug1-release-approval.json`:5/5 场景、30/30 问题证据、5/5 通道的绑定/正例/跨通道拒绝/一次回复证据、五类负责人签认及受控分发决定全部完成后,才继续只读检查生产固定码模式、五个直通角色各至少两名有效处理人,以及五个已发布场景的版本、内容哈希、审核人和 SOP 引用。受控分发决定必须明确法务批准或内部测试例外接受、企业签名或 DCloud 测试签名接受、实际签名摘要和分发证据哈希。门禁不请求验证码、不发送短信、不登录、不输出账号或处理人身份,也不写数据库。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。开启远端后端或 schema 核验时,预检还会只读检查 `wygj-aihr.service` 及其 `EnvironmentFile` 的有效配置,`AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 必须为 `false` 或未设置;无法读取引用的环境文件会失败关闭,而不会把本机环境变量当作线上证据。
Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-acceptance.ps1 -Serial <adb-serial> -Stage <stage>` 保存当前状态;脚本只读取包版本、前台 Activity、权限、UI、截图和退出记录,不执行安装、启动、清数据、授权或点击。证据仅写入已忽略的 `output/aug1-rc/android-acceptance/`,详细口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。 Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-acceptance.ps1 -Serial <adb-serial> -Stage <stage>` 保存当前状态;脚本只读取包版本、前台 Activity、权限、UI、截图和退出记录,不执行安装、启动、清数据、授权或点击。证据仅写入已忽略的 `output/aug1-rc/android-acceptance/`,详细口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。
@@ -4,11 +4,24 @@
"tenantId": "000000", "tenantId": "000000",
"candidateFile": "aug1-life-advisor-content-candidates-v0.1.json", "candidateFile": "aug1-life-advisor-content-candidates-v0.1.json",
"candidateSha256": "148654e241e0fd74bcb29d71cd43518cb961f0128855b66af98a56bc841aea1d", "candidateSha256": "148654e241e0fd74bcb29d71cd43518cb961f0128855b66af98a56bc841aea1d",
"expectedApkSignerSha256": "ab06ecc0de544de73c385f3f0690b22966ebc12887fb8bee1830cdbefdebef5d",
"releaseStatus": "PENDING", "releaseStatus": "PENDING",
"formalContentVersion": "", "formalContentVersion": "",
"sourceRegistry": [], "sourceRegistry": [],
"scenarioApprovals": {}, "scenarioApprovals": {},
"policyQuestionApprovals": {}, "policyQuestionApprovals": {},
"channelApprovals": {},
"distributionApproval": {
"status": "PENDING",
"scope": "CONTROLLED_INTERNAL_TEST",
"legalDecision": "PENDING",
"signerDecision": "PENDING",
"signerSha256": "",
"distributionEvidenceRef": "",
"distributionEvidenceSha256": "",
"reviewedBy": "",
"reviewedAt": ""
},
"signoffs": { "signoffs": {
"businessOwner": { "businessOwner": {
"status": "PENDING", "status": "PENDING",
@@ -21,7 +21,7 @@
| 构建 | `mobile-uni` H5、App-Plus、管理端生产构建通过;后端 `ruoyi-admin.jar` 重建通过;统一 RC 的前端、移动 H5 和后端 JAR 哈希均已写入冻结证据 | | 构建 | `mobile-uni` H5、App-Plus、管理端生产构建通过;后端 `ruoyi-admin.jar` 重建通过;统一 RC 的前端、移动 H5 和后端 JAR 哈希均已写入冻结证据 |
| P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 | | P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 |
| 内容候选门禁 | 已生成 5 个禁用场景候选和 30 道待审政策题,机器校验通过;正式可发布场景 `0`、正式有据标准答案 `0`,不得导入或启用 | | 内容候选门禁 | 已生成 5 个禁用场景候选和 30 道待审政策题,机器校验通过;正式可发布场景 `0`、正式有据标准答案 `0`,不得导入或启用 |
| 最终业务就绪门禁 | `verify-aug1-formal-content.mjs --strict` 和 `verify-aug1-release-readiness.sh --execute` 已落地并覆盖正反例;当前机器签认准确返回场景 `0/5`、问题证据 `0/30`、负责人签认 `0/5` 并失败关闭。签认完成后还会只读核对生产固定码模式、五角色各至少两名有效处理人和五个已发布场景的版本/哈希/审核链/SOP 引用;不请求验证码、不登录、不发短信、不写数据库 | | 最终业务就绪门禁 | `verify-aug1-formal-content.mjs --strict` 和 `verify-aug1-release-readiness.sh --execute` 已落地并覆盖正反例;当前机器签认准确返回场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING` 并失败关闭。签认完成后还会只读核对生产固定码模式、五角色各至少两名有效处理人和五个已发布场景的版本/哈希/审核链/SOP 引用;不请求验证码、不登录、不发短信、不写数据库 |
| App 资源门禁 | 正式法务 URL、登录页同意门禁、原生 `prompt=none`、API 35、图标和启动图检查通过 | | App 资源门禁 | 正式法务 URL、登录页同意门禁、原生 `prompt=none`、API 35、图标和启动图检查通过 |
| 独立 APK | `com.yincheng.wygj`,`0.1.13 (113)`,`targetSdkVersion=35`,v2 测试签名,明文 HTTP 关闭且网络安全资源已进入 APK;大小 `33,921,543` 字节,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0` | | 独立 APK | `com.yincheng.wygj`,`0.1.13 (113)`,`targetSdkVersion=35`,v2 测试签名,明文 HTTP 关闭且网络安全资源已进入 APK;大小 `33,921,543` 字节,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0` |
| 双提交交付包 | `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops9c53668c.zip`;运行时 `af8af2f6`、operations `9c53668c`,大小 `192,946,729` 字节,SHA-256 `B639C5933F8F3401EEEAB26E2BF2B98F5E5BA7E0BC65B7199469856B96906CEC`,20 个条目和内部哈希复核通过;新增机器签认清单、正式内容严格校验和生产业务就绪门禁,包内 Go/No-Go 已指向当前双提交包,旧 `ops63968f33` ZIP 只作历史追溯 | | 双提交交付包 | `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops9c53668c.zip`;运行时 `af8af2f6`、operations `9c53668c`,大小 `192,946,729` 字节,SHA-256 `B639C5933F8F3401EEEAB26E2BF2B98F5E5BA7E0BC65B7199469856B96906CEC`,20 个条目和内部哈希复核通过;新增机器签认清单、正式内容严格校验和生产业务就绪门禁,包内 Go/No-Go 已指向当前双提交包,旧 `ops63968f33` ZIP 只作历史追溯 |
@@ -23,6 +23,8 @@
| 正式有据标准答案 | 0/30 | 来源、版本、适用范围与审核人齐全 | | 正式有据标准答案 | 0/30 | 来源、版本、适用范围与审核人齐全 |
| 直通车通道 | 5/5 可见 | 每个通道至少绑定 1 名主处理人和 1 名替补 | | 直通车通道 | 5/5 可见 | 每个通道至少绑定 1 名主处理人和 1 名替补 |
| 生产有效处理人绑定 | 5 个角色均为 0 | 绑定后完成本通道正例、跨通道反例和一次正式回复 | | 生产有效处理人绑定 | 5 个角色均为 0 | 绑定后完成本通道正例、跨通道反例和一次正式回复 |
| 五通道验收证据 | 0/5 | 每通道的绑定、正例、跨通道拒绝和一次回复证据均有不可变引用与哈希 |
| 受控分发决定 | `PENDING` | 明确法务决定、签名决定、分发范围、证据哈希和负责人 |
候选事实源: 候选事实源:
@@ -107,6 +109,7 @@
- [ ] 五个处理角色分别绑定主处理人和替补; - [ ] 五个处理角色分别绑定主处理人和替补;
- [ ] 五通道正例、跨通道反例和一次正式回复全部通过; - [ ] 五通道正例、跨通道反例和一次正式回复全部通过;
- [ ] 内容运营确认候选版本、导入版本和员工可见版本一致; - [ ] 内容运营确认候选版本、导入版本和员工可见版本一致;
- [ ] 法务/合规与发布负责人明确接受受控内测范围、当前 APK 签名和分发名单;
- [ ] 发布负责人确认本单对应的后端、内容和权限变更已纳入回退点。 - [ ] 发布负责人确认本单对应的后端、内容和权限变更已纳入回退点。
| 签认角色 | 结论 | 签认人 | 日期 | 备注 | | 签认角色 | 结论 | 签认人 | 日期 | 备注 |
@@ -115,6 +118,8 @@
| 知识内容负责人 | `PENDING` | 待签认 | 待签认 | | | 知识内容负责人 | `PENDING` | 待签认 | 待签认 | |
| 训练内容负责人 | `PENDING` | 待签认 | 待签认 | | | 训练内容负责人 | `PENDING` | 待签认 | 待签认 | |
| 五通道业务负责人 | `PENDING` | 待签认 | 待签认 | | | 五通道业务负责人 | `PENDING` | 待签认 | 待签认 | |
| 法务/合规确认人 | `PENDING` | 待签认 | 待签认 | 受控内测可填写正式批准或明确例外接受 |
| 受控分发负责人 | `PENDING` | 待签认 | 待签认 | 确认签名摘要、分发范围与名单证据 |
| 发布负责人 | `PENDING` | 待签认 | 待签认 | | | 发布负责人 | `PENDING` | 待签认 | 待签认 | |
## 7. 机器门禁与回填方式 ## 7. 机器门禁与回填方式
@@ -126,8 +131,10 @@
1. 业务方提供正式文件,登记 `sourceRegistry`;不得把访谈或 AI 输出登记为 `formalPolicy=true`。 1. 业务方提供正式文件,登记 `sourceRegistry`;不得把访谈或 AI 输出登记为 `formalPolicy=true`。
2. 5 个场景在管理端完成内容录入和审核,高风险场景由不同人员二审;把最终生产 `scenarioCode`、`contentVersion`、`contentHash` 和证据引用回填到对应 `scenarioApprovals`。 2. 5 个场景在管理端完成内容录入和审核,高风险场景由不同人员二审;把最终生产 `scenarioCode`、`contentVersion`、`contentHash` 和证据引用回填到对应 `scenarioApprovals`。
3. 30 道问题逐项形成正式引用/拒答/越权回归证据,把证据文件引用和 SHA-256 回填到 `policyQuestionApprovals`;不把答案正文写入仓库。 3. 30 道问题逐项形成正式引用/拒答/越权回归证据,把证据文件引用和 SHA-256 回填到 `policyQuestionApprovals`;不把答案正文写入仓库。
4. 五类负责人签认后,将顶层 `releaseStatus` 改为 `APPROVED`。任何缺项、占位值、未知来源、证据哈希缺失或高风险同人二审都会失败关闭。 4. 五个通道分别完成绑定、正例访问、其他通道拒绝和一次正式回复验收,把四类证据引用与 SHA-256 回填到 `channelApprovals`;清单不记录处理人身份或反馈正文。
5. 系统管理员按本单绑定五通道主处理人和替补;机器门禁只输出每个角色的有效人数,不输出人员身份。 5. 受控分发负责人在 `distributionApproval` 中明确法务决定、DCloud 测试签名或企业签名决定、实际签名摘要和分发证据;只有受控内部测试范围允许填写明确的例外接受。
6. 五类业务负责人签认后,将顶层 `releaseStatus` 改为 `APPROVED`。任何缺项、占位值、未知来源、证据哈希缺失、高风险同人二审、通道验收不足或签名摘要不一致都会失败关闭。
7. 系统管理员按本单绑定五通道主处理人和替补;机器门禁只输出每个角色的有效人数,不输出人员身份。
先审计当前回填进度: 先审计当前回填进度:
@@ -147,4 +154,4 @@ node scripts/verify-aug1-formal-content.mjs --strict
./scripts/verify-aug1-release-readiness.sh --execute ./scripts/verify-aug1-release-readiness.sh --execute
``` ```
最终脚本不会请求或消费验证码,不会触发真实短信,不登录,不写数据库。它要求生产固定码模式已明确启用、五个角色各至少两名有效处理人,并核对 5 个已发布场景的版本、内容哈希、审核链和 SOP 引用与签认清单完全一致。 最终脚本不会请求或消费验证码,不会触发真实短信,不登录,不写数据库。它要求生产固定码模式已明确启用、五个角色各至少两名有效处理人、五通道验收证据和受控分发决定完整,并核对 5 个已发布场景的版本、内容哈希、审核链和 SOP 引用与签认清单完全一致。
+12 -2
View File
@@ -126,6 +126,9 @@ $releaseApprovalManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $release
if ([string]$releaseApprovalManifest.candidateSha256 -ne $contentSha256) { if ([string]$releaseApprovalManifest.candidateSha256 -ne $contentSha256) {
throw "Release approval candidate hash mismatch: $($releaseApprovalManifest.candidateSha256)" throw "Release approval candidate hash mismatch: $($releaseApprovalManifest.candidateSha256)"
} }
if ([string]$releaseApprovalManifest.expectedApkSignerSha256 -ne ([string]$evidence.app.signerSha256).ToLowerInvariant()) {
throw "Release approval APK signer mismatch: $($releaseApprovalManifest.expectedApkSignerSha256)"
}
& node (Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs') $releaseApprovalPath | Out-Null & node (Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs') $releaseApprovalPath | Out-Null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw 'Release approval structure audit failed.' throw 'Release approval structure audit failed.'
@@ -134,8 +137,11 @@ $approvedScenarioCount = @($releaseApprovalManifest.scenarioApprovals.PSObject.P
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedQuestionCount = @($releaseApprovalManifest.policyQuestionApprovals.PSObject.Properties | $approvedQuestionCount = @($releaseApprovalManifest.policyQuestionApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedChannelCount = @($releaseApprovalManifest.channelApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedSignoffCount = @($releaseApprovalManifest.signoffs.PSObject.Properties | $approvedSignoffCount = @($releaseApprovalManifest.signoffs.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$distributionApprovalStatus = [string]$releaseApprovalManifest.distributionApproval.status
$goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*Android*Go-No-Go-20260728.md') $goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*Android*Go-No-Go-20260728.md')
if ($goNoGoCandidates.Count -ne 1) { if ($goNoGoCandidates.Count -ne 1) {
@@ -261,7 +267,7 @@ try {
'', '',
'Recheck the bundled disabled content candidates with `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json`. Complete `operations/business-content-and-five-channel-signoff.md` before enabling content or binding handlers.', 'Recheck the bundled disabled content candidates with `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json`. Complete `operations/business-content-and-five-channel-signoff.md` before enabling content or binding handlers.',
'', '',
'Audit business evidence with `node operations/verify-aug1-formal-content.mjs aug1-release-approval.json`. The strict command and `operations/verify-aug1-release-readiness.sh --execute` must pass before the final Go decision; the latter is read-only and verifies fixed-code mode, two active handlers per channel, and immutable production scenario snapshots.', 'Audit business evidence with `node operations/verify-aug1-formal-content.mjs aug1-release-approval.json`. The strict command and `operations/verify-aug1-release-readiness.sh --execute` must pass before the final Go decision; the latter is read-only and verifies fixed-code mode, formal content, per-channel access/reply evidence, two active handlers per channel, immutable production scenario snapshots, and controlled-distribution approval.',
'', '',
'The fixed-code authenticated smoke is bundled as `operations/verify-aug1-authenticated-production.sh`. It requires the explicit `--execute` flag, selects an existing eligible APP identity without printing it, never enables or sends real SMS, and does not submit business records.', 'The fixed-code authenticated smoke is bundled as `operations/verify-aug1-authenticated-production.sh`. It requires the explicit `--execute` flag, selects an existing eligible APP identity without printing it, never enables or sends real SMS, and does not submit business records.',
'', '',
@@ -281,7 +287,7 @@ try {
'- Production schema: 64/64 and runtime schema bootstrap disabled/default.', '- Production schema: 64/64 and runtime schema bootstrap disabled/default.',
'- Production GET-only route probe: 20/20 (`1x200`, `6x401`, `13x405`).', '- Production GET-only route probe: 20/20 (`1x200`, `6x401`, `13x405`).',
'- Separate fixed-code authenticated smoke passed request/login, read-only business routes, logout, old-token rejection and re-login; application logs confirmed that no real SMS was sent.', '- Separate fixed-code authenticated smoke passed request/login, read-only business routes, logout, old-token rejection and re-login; application logs confirmed that no real SMS was sent.',
"- Formal approval audit: $approvedScenarioCount/5 scenarios, $approvedQuestionCount/30 policy questions, $approvedSignoffCount/5 owner sign-offs.", "- Formal approval audit: $approvedScenarioCount/5 scenarios, $approvedQuestionCount/30 policy questions, $approvedChannelCount/5 channel acceptances, $approvedSignoffCount/5 owner sign-offs, distribution $distributionApprovalStatus.",
'- Strict August 1 readiness is expected to fail closed until formal evidence and real channel handlers are complete.', '- Strict August 1 readiness is expected to fail closed until formal evidence and real channel handlers are complete.',
'- Backend normalized AIHR module remains mismatched until an authorized deploy.', '- Backend normalized AIHR module remains mismatched until an authorized deploy.',
'- Overall result: **No-Go** pending the manual and authorization gates in `operations/go-no-go.md`.' '- Overall result: **No-Go** pending the manual and authorization gates in `operations/go-no-go.md`.'
@@ -297,7 +303,9 @@ try {
"- Policy-question candidates: $($evidence.contentCandidates.policyQuestionCandidates)", "- Policy-question candidates: $($evidence.contentCandidates.policyQuestionCandidates)",
"- Formally approved scenarios: $approvedScenarioCount/5", "- Formally approved scenarios: $approvedScenarioCount/5",
"- Formally evidenced policy questions: $approvedQuestionCount/30", "- Formally evidenced policy questions: $approvedQuestionCount/30",
"- Approved direct-channel acceptances: $approvedChannelCount/5",
"- Owner sign-offs: $approvedSignoffCount/5", "- Owner sign-offs: $approvedSignoffCount/5",
"- Controlled-distribution approval: $distributionApprovalStatus",
'', '',
'Candidates are for business review only. Do not import, enable or count them toward August 1 acceptance until formal sources and item-level sign-off are complete.', 'Candidates are for business review only. Do not import, enable or count them toward August 1 acceptance until formal sources and item-level sign-off are complete.',
'', '',
@@ -363,7 +371,9 @@ try {
formalContentVersion = [string]$releaseApprovalManifest.formalContentVersion formalContentVersion = [string]$releaseApprovalManifest.formalContentVersion
approvedScenarios = $approvedScenarioCount approvedScenarios = $approvedScenarioCount
approvedPolicyQuestions = $approvedQuestionCount approvedPolicyQuestions = $approvedQuestionCount
approvedDirectChannels = $approvedChannelCount
approvedOwnerSignoffs = $approvedSignoffCount approvedOwnerSignoffs = $approvedSignoffCount
controlledDistributionStatus = $distributionApprovalStatus
} }
operations = @($operationSources.Keys) operations = @($operationSources.Keys)
manualGatesRemaining = @($evidence.manualGatesRemaining) manualGatesRemaining = @($evidence.manualGatesRemaining)
@@ -51,6 +51,7 @@ function validApproval() {
secondReviewedAt: '2026-07-30T10:00:00+08:00', secondReviewedAt: '2026-07-30T10:00:00+08:00',
} : {}), } : {}),
businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`, businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`,
businessEvidenceSha256: 'c'.repeat(64),
productionContentVersion: `aug1-v${index + 1}`, productionContentVersion: `aug1-v${index + 1}`,
productionContentHash: String(index + 1).repeat(64), productionContentHash: String(index + 1).repeat(64),
}] }]
@@ -84,17 +85,51 @@ function validApproval() {
reviewedBy: `${role}-reviewer`, reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T12:00:00+08:00', reviewedAt: '2026-07-30T12:00:00+08:00',
}])) }]))
const channelApprovals = Object.fromEntries([
'direct_president',
'direct_finance',
'direct_hr',
'direct_audit',
'direct_operations',
].map((role) => [role, {
status: 'APPROVED',
minimumActiveHandlers: 2,
bindingEvidenceRef: `evidence/${role}-binding.json`,
bindingEvidenceSha256: 'd'.repeat(64),
positiveAccessEvidenceRef: `evidence/${role}-positive.json`,
positiveAccessEvidenceSha256: 'e'.repeat(64),
crossChannelDenialEvidenceRef: `evidence/${role}-denial.json`,
crossChannelDenialEvidenceSha256: 'f'.repeat(64),
singleReplyEvidenceRef: `evidence/${role}-reply.json`,
singleReplyEvidenceSha256: '1'.repeat(64),
reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T11:30:00+08:00',
}]))
const expectedApkSignerSha256 = '2'.repeat(64)
return { return {
schemaVersion: '1.0', schemaVersion: '1.0',
releaseTarget: '2026-08-01', releaseTarget: '2026-08-01',
tenantId: '000000', tenantId: '000000',
candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json', candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json',
candidateSha256, candidateSha256,
expectedApkSignerSha256,
releaseStatus: 'APPROVED', releaseStatus: 'APPROVED',
formalContentVersion: 'aug1-formal-v1', formalContentVersion: 'aug1-formal-v1',
sourceRegistry: [source], sourceRegistry: [source],
scenarioApprovals, scenarioApprovals,
policyQuestionApprovals, policyQuestionApprovals,
channelApprovals,
distributionApproval: {
status: 'APPROVED',
scope: 'CONTROLLED_INTERNAL_TEST',
legalDecision: 'CONTROLLED_INTERNAL_TEST_EXCEPTION_ACCEPTED',
signerDecision: 'DCLOUD_TEST_SIGNER_ACCEPTED_FOR_CONTROLLED_INTERNAL_TEST',
signerSha256: expectedApkSignerSha256,
distributionEvidenceRef: 'evidence/controlled-distribution.json',
distributionEvidenceSha256: '3'.repeat(64),
reviewedBy: 'distribution-reviewer',
reviewedAt: '2026-07-30T12:30:00+08:00',
},
signoffs, signoffs,
} }
} }
@@ -123,6 +158,8 @@ test('audits the checked-in pending manifest without claiming release readiness'
assert.equal(audit.status, 0, audit.stderr) assert.equal(audit.status, 0, audit.stderr)
assert.match(audit.stdout, /approved scenarios: 0\/5/) assert.match(audit.stdout, /approved scenarios: 0\/5/)
assert.match(audit.stdout, /approved policy questions: 0\/30/) assert.match(audit.stdout, /approved policy questions: 0\/30/)
assert.match(audit.stdout, /approved direct channels: 0\/5/)
assert.match(audit.stdout, /controlled distribution: PENDING/)
const strict = spawnSync(process.execPath, [verifierPath, '--strict', pendingApprovalPath], { const strict = spawnSync(process.execPath, [verifierPath, '--strict', pendingApprovalPath], {
cwd: projectRoot, cwd: projectRoot,
@@ -168,6 +205,18 @@ test('rejects incomplete or contradictory formal approvals', async (t) => {
['missing owner sign-off', (approval) => { ['missing owner sign-off', (approval) => {
approval.signoffs.releaseOwner.status = 'PENDING' approval.signoffs.releaseOwner.status = 'PENDING'
}, /releaseOwner: sign-off must be APPROVED/], }, /releaseOwner: sign-off must be APPROVED/],
['missing channel acceptance', (approval) => {
delete approval.channelApprovals.direct_audit
}, /channel approvals must cover exactly 5\/5 roles/],
['channel without backup requirement', (approval) => {
approval.channelApprovals.direct_finance.minimumActiveHandlers = 1
}, /primary and backup requirement must remain 2/],
['unaccepted distribution', (approval) => {
approval.distributionApproval.status = 'PENDING'
}, /distribution approval must be APPROVED/],
['mismatched APK signer', (approval) => {
approval.distributionApproval.signerSha256 = '4'.repeat(64)
}, /distribution signer SHA-256 must match the expected APK signer/],
] ]
for (const [name, mutate, expected] of cases) { for (const [name, mutate, expected] of cases) {
@@ -59,6 +59,7 @@ candidate.scenarios.forEach((item, index) => {
secondReviewedAt: '2026-07-30T10:00:00+08:00', secondReviewedAt: '2026-07-30T10:00:00+08:00',
} : {}), } : {}),
businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`, businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`,
businessEvidenceSha256: 'c'.repeat(64),
productionContentVersion: version, productionContentVersion: version,
productionContentHash: hash, productionContentHash: hash,
} }
@@ -105,17 +106,51 @@ const signoffs = Object.fromEntries([
reviewedBy: `${role}-reviewer`, reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T12:00:00+08:00', reviewedAt: '2026-07-30T12:00:00+08:00',
}])) }]))
const channelApprovals = Object.fromEntries([
'direct_president',
'direct_finance',
'direct_hr',
'direct_audit',
'direct_operations',
].map((role) => [role, {
status: 'APPROVED',
minimumActiveHandlers: 2,
bindingEvidenceRef: `evidence/${role}-binding.json`,
bindingEvidenceSha256: 'd'.repeat(64),
positiveAccessEvidenceRef: `evidence/${role}-positive.json`,
positiveAccessEvidenceSha256: 'e'.repeat(64),
crossChannelDenialEvidenceRef: `evidence/${role}-denial.json`,
crossChannelDenialEvidenceSha256: 'f'.repeat(64),
singleReplyEvidenceRef: `evidence/${role}-reply.json`,
singleReplyEvidenceSha256: '1'.repeat(64),
reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T11:30:00+08:00',
}]))
const expectedApkSignerSha256 = '2'.repeat(64)
fs.writeFileSync(approvalPath, JSON.stringify({ fs.writeFileSync(approvalPath, JSON.stringify({
schemaVersion: '1.0', schemaVersion: '1.0',
releaseTarget: '2026-08-01', releaseTarget: '2026-08-01',
tenantId: '000000', tenantId: '000000',
candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json', candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json',
candidateSha256: crypto.createHash('sha256').update(bytes).digest('hex'), candidateSha256: crypto.createHash('sha256').update(bytes).digest('hex'),
expectedApkSignerSha256,
releaseStatus: 'APPROVED', releaseStatus: 'APPROVED',
formalContentVersion: 'aug1-formal-v1', formalContentVersion: 'aug1-formal-v1',
sourceRegistry: [source], sourceRegistry: [source],
scenarioApprovals, scenarioApprovals,
policyQuestionApprovals, policyQuestionApprovals,
channelApprovals,
distributionApproval: {
status: 'APPROVED',
scope: 'CONTROLLED_INTERNAL_TEST',
legalDecision: 'CONTROLLED_INTERNAL_TEST_EXCEPTION_ACCEPTED',
signerDecision: 'DCLOUD_TEST_SIGNER_ACCEPTED_FOR_CONTROLLED_INTERNAL_TEST',
signerSha256: expectedApkSignerSha256,
distributionEvidenceRef: 'evidence/controlled-distribution.json',
distributionEvidenceSha256: '3'.repeat(64),
reviewedBy: 'distribution-reviewer',
reviewedAt: '2026-07-30T12:30:00+08:00',
},
signoffs, signoffs,
})) }))
fs.writeFileSync(rowsPath, rows.join('\n') + '\n') fs.writeFileSync(rowsPath, rows.join('\n') + '\n')
@@ -177,6 +212,8 @@ run_mock_readiness() {
ready_output="$(run_mock_readiness)" ready_output="$(run_mock_readiness)"
grep -Fq 'AUG1_FIXED_CODE_NO_REAL_SMS_MODE=PASS' <<<"$ready_output" grep -Fq 'AUG1_FIXED_CODE_NO_REAL_SMS_MODE=PASS' <<<"$ready_output"
grep -Fq 'AUG1_FORMAL_SCENARIOS=5/5' <<<"$ready_output" grep -Fq 'AUG1_FORMAL_SCENARIOS=5/5' <<<"$ready_output"
grep -Fq 'AUG1_CHANNEL_ACCEPTANCE=5/5' <<<"$ready_output"
grep -Fq 'AUG1_DISTRIBUTION_APPROVAL=PASS' <<<"$ready_output"
grep -Fq 'AUG1_RELEASE_READINESS=PASS' <<<"$ready_output" grep -Fq 'AUG1_RELEASE_READINESS=PASS' <<<"$ready_output"
handler_failure="$( handler_failure="$(
@@ -201,6 +238,8 @@ grep -Fq 'second_reviewer_user_id" != "$reviewer_user_id"' "$SCRIPT"
grep -Fq 'production content hash does not match the approved snapshot' "$SCRIPT" grep -Fq 'production content hash does not match the approved snapshot' "$SCRIPT"
grep -Fq 'AUG1_FORMAL_SCENARIOS=5/5' "$SCRIPT" grep -Fq 'AUG1_FORMAL_SCENARIOS=5/5' "$SCRIPT"
grep -Fq 'AUG1_FORMAL_POLICY_QUESTIONS=30/30' "$SCRIPT" grep -Fq 'AUG1_FORMAL_POLICY_QUESTIONS=30/30' "$SCRIPT"
grep -Fq 'AUG1_CHANNEL_ACCEPTANCE=5/5' "$SCRIPT"
grep -Fq 'AUG1_DISTRIBUTION_APPROVAL=PASS' "$SCRIPT"
if grep -Eiq '(^|[[:space:]])(insert|update|delete|replace|alter|drop|truncate)[[:space:]]' "$SCRIPT"; then if grep -Eiq '(^|[[:space:]])(insert|update|delete|replace|alter|drop|truncate)[[:space:]]' "$SCRIPT"; then
echo 'FAIL: August 1 release readiness verifier contains a mutating SQL verb' >&2 echo 'FAIL: August 1 release readiness verifier contains a mutating SQL verb' >&2
+4 -1
View File
@@ -13,6 +13,7 @@ $requiredFragments = @(
'Frozen backend JAR hash mismatch', 'Frozen backend JAR hash mismatch',
'Content candidate hash mismatch', 'Content candidate hash mismatch',
'Release approval candidate hash mismatch', 'Release approval candidate hash mismatch',
'Release approval APK signer mismatch',
"'operations/release-backend.sh'", "'operations/release-backend.sh'",
"'operations/release-preflight.sh'", "'operations/release-preflight.sh'",
"'operations/verify-aug1-production-api-readonly.sh'", "'operations/verify-aug1-production-api-readonly.sh'",
@@ -34,7 +35,9 @@ $requiredFragments = @(
'fixedCodeNoRealSmsConfirmed = $true', 'fixedCodeNoRealSmsConfirmed = $true',
'deployPlanSmsOrLoginTriggered = $false', 'deployPlanSmsOrLoginTriggered = $false',
"artifact = 'aug1-release-approval.json'", "artifact = 'aug1-release-approval.json'",
'approvedPolicyQuestions = $approvedQuestionCount' 'approvedPolicyQuestions = $approvedQuestionCount',
'approvedDirectChannels = $approvedChannelCount',
'controlledDistributionStatus = $distributionApprovalStatus'
) )
foreach ($fragment in $requiredFragments) { foreach ($fragment in $requiredFragments) {
if (-not $source.Contains($fragment)) { if (-not $source.Contains($fragment)) {
+78
View File
@@ -54,6 +54,10 @@ const candidateFileValid = nonBlank(approval.candidateFile)
&& approval.candidateFile.endsWith('.json') && approval.candidateFile.endsWith('.json')
check(candidateFileValid, 'candidateFile must be a JSON basename beside the approval file') check(candidateFileValid, 'candidateFile must be a JSON basename beside the approval file')
check(sha256Pattern.test(approval.candidateSha256 ?? ''), 'candidateSha256 must be lowercase SHA-256') check(sha256Pattern.test(approval.candidateSha256 ?? ''), 'candidateSha256 must be lowercase SHA-256')
check(
sha256Pattern.test(approval.expectedApkSignerSha256 ?? ''),
'expectedApkSignerSha256 must be lowercase SHA-256',
)
if (!candidateFileValid) { if (!candidateFileValid) {
for (const failure of failures) console.error(`FAIL: ${failure}`) for (const failure of failures) console.error(`FAIL: ${failure}`)
@@ -110,6 +114,7 @@ for (const source of sourceRegistry) {
const scenarioApprovals = approval.scenarioApprovals const scenarioApprovals = approval.scenarioApprovals
const questionApprovals = approval.policyQuestionApprovals const questionApprovals = approval.policyQuestionApprovals
const channelApprovals = approval.channelApprovals
check( check(
scenarioApprovals && typeof scenarioApprovals === 'object' && !Array.isArray(scenarioApprovals), scenarioApprovals && typeof scenarioApprovals === 'object' && !Array.isArray(scenarioApprovals),
'scenarioApprovals must be an object keyed by candidate ID', 'scenarioApprovals must be an object keyed by candidate ID',
@@ -118,14 +123,29 @@ check(
questionApprovals && typeof questionApprovals === 'object' && !Array.isArray(questionApprovals), questionApprovals && typeof questionApprovals === 'object' && !Array.isArray(questionApprovals),
'policyQuestionApprovals must be an object keyed by question ID', 'policyQuestionApprovals must be an object keyed by question ID',
) )
check(
channelApprovals && typeof channelApprovals === 'object' && !Array.isArray(channelApprovals),
'channelApprovals must be an object keyed by direct-channel role',
)
const scenarioEntries = Object.entries(scenarioApprovals ?? {}) const scenarioEntries = Object.entries(scenarioApprovals ?? {})
const questionEntries = Object.entries(questionApprovals ?? {}) const questionEntries = Object.entries(questionApprovals ?? {})
const channelEntries = Object.entries(channelApprovals ?? {})
for (const [candidateId] of scenarioEntries) { for (const [candidateId] of scenarioEntries) {
check(expectedScenarios.has(candidateId), `${candidateId}: approval references an unknown scenario`) check(expectedScenarios.has(candidateId), `${candidateId}: approval references an unknown scenario`)
} }
for (const [questionId] of questionEntries) { for (const [questionId] of questionEntries) {
check(expectedQuestions.has(questionId), `${questionId}: approval references an unknown question`) check(expectedQuestions.has(questionId), `${questionId}: approval references an unknown question`)
} }
const requiredChannels = [
'direct_president',
'direct_finance',
'direct_hr',
'direct_audit',
'direct_operations',
]
for (const [roleKey] of channelEntries) {
check(requiredChannels.includes(roleKey), `${roleKey}: approval references an unknown direct channel`)
}
const resolveSourceRefs = (refs, label) => { const resolveSourceRefs = (refs, label) => {
check(Array.isArray(refs) && refs.length > 0, `${label}: at least one formal source reference is required`) check(Array.isArray(refs) && refs.length > 0, `${label}: at least one formal source reference is required`)
@@ -171,6 +191,7 @@ if (strict) {
check(entry.secondReviewedBy !== entry.reviewedBy, `${label}: high-risk reviewers must be different people`) check(entry.secondReviewedBy !== entry.reviewedBy, `${label}: high-risk reviewers must be different people`)
} }
check(nonBlank(entry.businessEvidenceRef), `${label}: business evidence reference is required`) check(nonBlank(entry.businessEvidenceRef), `${label}: business evidence reference is required`)
check(sha256Pattern.test(entry.businessEvidenceSha256 ?? ''), `${label}: business evidence SHA-256 is required`)
check(nonBlank(entry.productionContentVersion), `${label}: production content version is required`) check(nonBlank(entry.productionContentVersion), `${label}: production content version is required`)
check(sha256Pattern.test(entry.productionContentHash ?? ''), `${label}: production content hash is required`) check(sha256Pattern.test(entry.productionContentHash ?? ''), `${label}: production content hash is required`)
} }
@@ -208,6 +229,29 @@ if (strict) {
requireReview(entry, label) requireReview(entry, label)
} }
check(channelEntries.length === requiredChannels.length, 'channel approvals must cover exactly 5/5 roles')
for (const roleKey of requiredChannels) {
const entry = channelApprovals?.[roleKey]
const label = roleKey
check(Boolean(entry), `${label}: channel approval is missing`)
if (!entry) continue
check(entry.status === 'APPROVED', `${label}: channel status must be APPROVED`)
check(entry.minimumActiveHandlers === 2, `${label}: primary and backup requirement must remain 2`)
for (const evidenceName of [
'bindingEvidence',
'positiveAccessEvidence',
'crossChannelDenialEvidence',
'singleReplyEvidence',
]) {
check(nonBlank(entry[`${evidenceName}Ref`]), `${label}: ${evidenceName} reference is required`)
check(
sha256Pattern.test(entry[`${evidenceName}Sha256`] ?? ''),
`${label}: ${evidenceName} SHA-256 is required`,
)
}
requireReview(entry, label)
}
const requiredSignoffs = [ const requiredSignoffs = [
'businessOwner', 'businessOwner',
'knowledgeOwner', 'knowledgeOwner',
@@ -224,6 +268,37 @@ if (strict) {
check(signoff?.status === 'APPROVED', `${role}: sign-off must be APPROVED`) check(signoff?.status === 'APPROVED', `${role}: sign-off must be APPROVED`)
if (signoff) requireReview(signoff, role) if (signoff) requireReview(signoff, role)
} }
const distribution = approval.distributionApproval
check(
distribution && typeof distribution === 'object' && !Array.isArray(distribution),
'distributionApproval must be an object',
)
if (distribution) {
check(distribution.status === 'APPROVED', 'distribution approval must be APPROVED')
check(distribution.scope === 'CONTROLLED_INTERNAL_TEST', 'distribution scope must remain controlled internal test')
check(
['LEGAL_APPROVED', 'CONTROLLED_INTERNAL_TEST_EXCEPTION_ACCEPTED'].includes(distribution.legalDecision),
'distribution legalDecision is not approved',
)
check(
[
'ENTERPRISE_SIGNER_APPROVED',
'DCLOUD_TEST_SIGNER_ACCEPTED_FOR_CONTROLLED_INTERNAL_TEST',
].includes(distribution.signerDecision),
'distribution signerDecision is not approved',
)
check(
distribution.signerSha256 === approval.expectedApkSignerSha256,
'distribution signer SHA-256 must match the expected APK signer',
)
check(nonBlank(distribution.distributionEvidenceRef), 'controlled-distribution evidence reference is required')
check(
sha256Pattern.test(distribution.distributionEvidenceSha256 ?? ''),
'controlled-distribution evidence SHA-256 is required',
)
requireReview(distribution, 'distributionApproval')
}
} }
if (failures.length > 0) { if (failures.length > 0) {
@@ -243,12 +318,15 @@ if (scenarioSnapshots) {
} else { } else {
const approvedScenarios = scenarioEntries.filter(([, value]) => value?.status === 'APPROVED').length const approvedScenarios = scenarioEntries.filter(([, value]) => value?.status === 'APPROVED').length
const approvedQuestions = questionEntries.filter(([, value]) => value?.status === 'APPROVED').length const approvedQuestions = questionEntries.filter(([, value]) => value?.status === 'APPROVED').length
const approvedChannels = channelEntries.filter(([, value]) => value?.status === 'APPROVED').length
const approvedSignoffs = Object.values(approval.signoffs ?? {}).filter((value) => value?.status === 'APPROVED').length const approvedSignoffs = Object.values(approval.signoffs ?? {}).filter((value) => value?.status === 'APPROVED').length
console.log('August 1 formal content approval audit passed') console.log('August 1 formal content approval audit passed')
console.log(`- releaseStatus: ${approval.releaseStatus}`) console.log(`- releaseStatus: ${approval.releaseStatus}`)
console.log(`- formal sources: ${sourceRegistry.length}`) console.log(`- formal sources: ${sourceRegistry.length}`)
console.log(`- approved scenarios: ${approvedScenarios}/5`) console.log(`- approved scenarios: ${approvedScenarios}/5`)
console.log(`- approved policy questions: ${approvedQuestions}/30`) console.log(`- approved policy questions: ${approvedQuestions}/30`)
console.log(`- approved direct channels: ${approvedChannels}/5`)
console.log(`- approved owner sign-offs: ${approvedSignoffs}/5`) console.log(`- approved owner sign-offs: ${approvedSignoffs}/5`)
console.log(`- controlled distribution: ${approval.distributionApproval?.status ?? 'MISSING'}`)
console.log(`- strict release ready: ${strict ? 'true' : 'not requested'}`) console.log(`- strict release ready: ${strict ? 'true' : 'not requested'}`)
} }
+2
View File
@@ -213,6 +213,8 @@ for snapshot in "${snapshots[@]}"; do
done done
echo "AUG1_FORMAL_SCENARIOS=5/5" echo "AUG1_FORMAL_SCENARIOS=5/5"
echo "AUG1_FORMAL_POLICY_QUESTIONS=30/30" echo "AUG1_FORMAL_POLICY_QUESTIONS=30/30"
echo "AUG1_CHANNEL_ACCEPTANCE=5/5"
echo "AUG1_OWNER_SIGNOFFS=5/5" echo "AUG1_OWNER_SIGNOFFS=5/5"
echo "AUG1_DISTRIBUTION_APPROVAL=PASS"
echo "AUG1_RELEASE_READINESS=PASS" echo "AUG1_RELEASE_READINESS=PASS"
REMOTE REMOTE