diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index cffe7100..44891b4e 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -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`。定向模式只要求对应的本地产物保持新鲜:静态核验要求管理端/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 -Stage ` 保存当前状态;脚本只读取包版本、前台 Activity、权限、UI、截图和退出记录,不执行安装、启动、清数据、授权或点击。证据仅写入已忽略的 `output/aug1-rc/android-acceptance/`,详细口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。 diff --git a/docs/content-candidates/aug1-release-approval.json b/docs/content-candidates/aug1-release-approval.json index 25093789..6cecb876 100644 --- a/docs/content-candidates/aug1-release-approval.json +++ b/docs/content-candidates/aug1-release-approval.json @@ -4,11 +4,24 @@ "tenantId": "000000", "candidateFile": "aug1-life-advisor-content-candidates-v0.1.json", "candidateSha256": "148654e241e0fd74bcb29d71cd43518cb961f0128855b66af98a56bc841aea1d", + "expectedApkSignerSha256": "ab06ecc0de544de73c385f3f0690b22966ebc12887fb8bee1830cdbefdebef5d", "releaseStatus": "PENDING", "formalContentVersion": "", "sourceRegistry": [], "scenarioApprovals": {}, "policyQuestionApprovals": {}, + "channelApprovals": {}, + "distributionApproval": { + "status": "PENDING", + "scope": "CONTROLLED_INTERNAL_TEST", + "legalDecision": "PENDING", + "signerDecision": "PENDING", + "signerSha256": "", + "distributionEvidenceRef": "", + "distributionEvidenceSha256": "", + "reviewedBy": "", + "reviewedAt": "" + }, "signoffs": { "businessOwner": { "status": "PENDING", diff --git a/docs/帮道8月1日Android内测Go-No-Go-20260728.md b/docs/帮道8月1日Android内测Go-No-Go-20260728.md index 001ca367..79703423 100644 --- a/docs/帮道8月1日Android内测Go-No-Go-20260728.md +++ b/docs/帮道8月1日Android内测Go-No-Go-20260728.md @@ -21,7 +21,7 @@ | 构建 | `mobile-uni` H5、App-Plus、管理端生产构建通过;后端 `ruoyi-admin.jar` 重建通过;统一 RC 的前端、移动 H5 和后端 JAR 哈希均已写入冻结证据 | | P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 | | 内容候选门禁 | 已生成 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、图标和启动图检查通过 | | 独立 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 只作历史追溯 | diff --git a/docs/帮道8月1日业务内容与五通道签认单-20260729.md b/docs/帮道8月1日业务内容与五通道签认单-20260729.md index 4c6fa631..206ce9b3 100644 --- a/docs/帮道8月1日业务内容与五通道签认单-20260729.md +++ b/docs/帮道8月1日业务内容与五通道签认单-20260729.md @@ -23,6 +23,8 @@ | 正式有据标准答案 | 0/30 | 来源、版本、适用范围与审核人齐全 | | 直通车通道 | 5/5 可见 | 每个通道至少绑定 1 名主处理人和 1 名替补 | | 生产有效处理人绑定 | 5 个角色均为 0 | 绑定后完成本通道正例、跨通道反例和一次正式回复 | +| 五通道验收证据 | 0/5 | 每通道的绑定、正例、跨通道拒绝和一次回复证据均有不可变引用与哈希 | +| 受控分发决定 | `PENDING` | 明确法务决定、签名决定、分发范围、证据哈希和负责人 | 候选事实源: @@ -107,6 +109,7 @@ - [ ] 五个处理角色分别绑定主处理人和替补; - [ ] 五通道正例、跨通道反例和一次正式回复全部通过; - [ ] 内容运营确认候选版本、导入版本和员工可见版本一致; +- [ ] 法务/合规与发布负责人明确接受受控内测范围、当前 APK 签名和分发名单; - [ ] 发布负责人确认本单对应的后端、内容和权限变更已纳入回退点。 | 签认角色 | 结论 | 签认人 | 日期 | 备注 | @@ -115,6 +118,8 @@ | 知识内容负责人 | `PENDING` | 待签认 | 待签认 | | | 训练内容负责人 | `PENDING` | 待签认 | 待签认 | | | 五通道业务负责人 | `PENDING` | 待签认 | 待签认 | | +| 法务/合规确认人 | `PENDING` | 待签认 | 待签认 | 受控内测可填写正式批准或明确例外接受 | +| 受控分发负责人 | `PENDING` | 待签认 | 待签认 | 确认签名摘要、分发范围与名单证据 | | 发布负责人 | `PENDING` | 待签认 | 待签认 | | ## 7. 机器门禁与回填方式 @@ -126,8 +131,10 @@ 1. 业务方提供正式文件,登记 `sourceRegistry`;不得把访谈或 AI 输出登记为 `formalPolicy=true`。 2. 5 个场景在管理端完成内容录入和审核,高风险场景由不同人员二审;把最终生产 `scenarioCode`、`contentVersion`、`contentHash` 和证据引用回填到对应 `scenarioApprovals`。 3. 30 道问题逐项形成正式引用/拒答/越权回归证据,把证据文件引用和 SHA-256 回填到 `policyQuestionApprovals`;不把答案正文写入仓库。 -4. 五类负责人签认后,将顶层 `releaseStatus` 改为 `APPROVED`。任何缺项、占位值、未知来源、证据哈希缺失或高风险同人二审都会失败关闭。 -5. 系统管理员按本单绑定五通道主处理人和替补;机器门禁只输出每个角色的有效人数,不输出人员身份。 +4. 五个通道分别完成绑定、正例访问、其他通道拒绝和一次正式回复验收,把四类证据引用与 SHA-256 回填到 `channelApprovals`;清单不记录处理人身份或反馈正文。 +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 ``` -最终脚本不会请求或消费验证码,不会触发真实短信,不登录,不写数据库。它要求生产固定码模式已明确启用、五个角色各至少两名有效处理人,并核对 5 个已发布场景的版本、内容哈希、审核链和 SOP 引用与签认清单完全一致。 +最终脚本不会请求或消费验证码,不会触发真实短信,不登录,不写数据库。它要求生产固定码模式已明确启用、五个角色各至少两名有效处理人、五通道验收证据和受控分发决定完整,并核对 5 个已发布场景的版本、内容哈希、审核链和 SOP 引用与签认清单完全一致。 diff --git a/scripts/package-aug1-release.ps1 b/scripts/package-aug1-release.ps1 index 561ef172..46fe8b95 100644 --- a/scripts/package-aug1-release.ps1 +++ b/scripts/package-aug1-release.ps1 @@ -126,6 +126,9 @@ $releaseApprovalManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $release if ([string]$releaseApprovalManifest.candidateSha256 -ne $contentSha256) { 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 if ($LASTEXITCODE -ne 0) { throw 'Release approval structure audit failed.' @@ -134,8 +137,11 @@ $approvedScenarioCount = @($releaseApprovalManifest.scenarioApprovals.PSObject.P Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count $approvedQuestionCount = @($releaseApprovalManifest.policyQuestionApprovals.PSObject.Properties | 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 | 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') 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.', '', - '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.', '', @@ -281,7 +287,7 @@ try { '- Production schema: 64/64 and runtime schema bootstrap disabled/default.', '- 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.', - "- 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.', '- 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`.' @@ -297,7 +303,9 @@ try { "- Policy-question candidates: $($evidence.contentCandidates.policyQuestionCandidates)", "- Formally approved scenarios: $approvedScenarioCount/5", "- Formally evidenced policy questions: $approvedQuestionCount/30", + "- Approved direct-channel acceptances: $approvedChannelCount/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.', '', @@ -363,7 +371,9 @@ try { formalContentVersion = [string]$releaseApprovalManifest.formalContentVersion approvedScenarios = $approvedScenarioCount approvedPolicyQuestions = $approvedQuestionCount + approvedDirectChannels = $approvedChannelCount approvedOwnerSignoffs = $approvedSignoffCount + controlledDistributionStatus = $distributionApprovalStatus } operations = @($operationSources.Keys) manualGatesRemaining = @($evidence.manualGatesRemaining) diff --git a/scripts/tests/aug1-formal-content.test.mjs b/scripts/tests/aug1-formal-content.test.mjs index 473d0543..9b682211 100644 --- a/scripts/tests/aug1-formal-content.test.mjs +++ b/scripts/tests/aug1-formal-content.test.mjs @@ -51,6 +51,7 @@ function validApproval() { secondReviewedAt: '2026-07-30T10:00:00+08:00', } : {}), businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`, + businessEvidenceSha256: 'c'.repeat(64), productionContentVersion: `aug1-v${index + 1}`, productionContentHash: String(index + 1).repeat(64), }] @@ -84,17 +85,51 @@ function validApproval() { reviewedBy: `${role}-reviewer`, 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 { schemaVersion: '1.0', releaseTarget: '2026-08-01', tenantId: '000000', candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json', candidateSha256, + expectedApkSignerSha256, releaseStatus: 'APPROVED', formalContentVersion: 'aug1-formal-v1', sourceRegistry: [source], scenarioApprovals, 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, } } @@ -123,6 +158,8 @@ test('audits the checked-in pending manifest without claiming release readiness' assert.equal(audit.status, 0, audit.stderr) assert.match(audit.stdout, /approved scenarios: 0\/5/) 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], { cwd: projectRoot, @@ -168,6 +205,18 @@ test('rejects incomplete or contradictory formal approvals', async (t) => { ['missing owner sign-off', (approval) => { approval.signoffs.releaseOwner.status = 'PENDING' }, /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) { diff --git a/scripts/tests/aug1-release-readiness.test.sh b/scripts/tests/aug1-release-readiness.test.sh index 21606130..9b18e0a0 100644 --- a/scripts/tests/aug1-release-readiness.test.sh +++ b/scripts/tests/aug1-release-readiness.test.sh @@ -59,6 +59,7 @@ candidate.scenarios.forEach((item, index) => { secondReviewedAt: '2026-07-30T10:00:00+08:00', } : {}), businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`, + businessEvidenceSha256: 'c'.repeat(64), productionContentVersion: version, productionContentHash: hash, } @@ -105,17 +106,51 @@ const signoffs = Object.fromEntries([ reviewedBy: `${role}-reviewer`, 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({ schemaVersion: '1.0', releaseTarget: '2026-08-01', tenantId: '000000', candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json', candidateSha256: crypto.createHash('sha256').update(bytes).digest('hex'), + expectedApkSignerSha256, releaseStatus: 'APPROVED', formalContentVersion: 'aug1-formal-v1', sourceRegistry: [source], scenarioApprovals, 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, })) fs.writeFileSync(rowsPath, rows.join('\n') + '\n') @@ -177,6 +212,8 @@ run_mock_readiness() { ready_output="$(run_mock_readiness)" grep -Fq 'AUG1_FIXED_CODE_NO_REAL_SMS_MODE=PASS' <<<"$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" 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 'AUG1_FORMAL_SCENARIOS=5/5' "$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 echo 'FAIL: August 1 release readiness verifier contains a mutating SQL verb' >&2 diff --git a/scripts/tests/package-aug1-release.test.ps1 b/scripts/tests/package-aug1-release.test.ps1 index 5ce2c568..31254804 100644 --- a/scripts/tests/package-aug1-release.test.ps1 +++ b/scripts/tests/package-aug1-release.test.ps1 @@ -13,6 +13,7 @@ $requiredFragments = @( 'Frozen backend JAR hash mismatch', 'Content candidate hash mismatch', 'Release approval candidate hash mismatch', + 'Release approval APK signer mismatch', "'operations/release-backend.sh'", "'operations/release-preflight.sh'", "'operations/verify-aug1-production-api-readonly.sh'", @@ -34,7 +35,9 @@ $requiredFragments = @( 'fixedCodeNoRealSmsConfirmed = $true', 'deployPlanSmsOrLoginTriggered = $false', "artifact = 'aug1-release-approval.json'", - 'approvedPolicyQuestions = $approvedQuestionCount' + 'approvedPolicyQuestions = $approvedQuestionCount', + 'approvedDirectChannels = $approvedChannelCount', + 'controlledDistributionStatus = $distributionApprovalStatus' ) foreach ($fragment in $requiredFragments) { if (-not $source.Contains($fragment)) { diff --git a/scripts/verify-aug1-formal-content.mjs b/scripts/verify-aug1-formal-content.mjs index fc5152a2..21596f8a 100644 --- a/scripts/verify-aug1-formal-content.mjs +++ b/scripts/verify-aug1-formal-content.mjs @@ -54,6 +54,10 @@ const candidateFileValid = nonBlank(approval.candidateFile) && approval.candidateFile.endsWith('.json') 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.expectedApkSignerSha256 ?? ''), + 'expectedApkSignerSha256 must be lowercase SHA-256', +) if (!candidateFileValid) { for (const failure of failures) console.error(`FAIL: ${failure}`) @@ -110,6 +114,7 @@ for (const source of sourceRegistry) { const scenarioApprovals = approval.scenarioApprovals const questionApprovals = approval.policyQuestionApprovals +const channelApprovals = approval.channelApprovals check( scenarioApprovals && typeof scenarioApprovals === 'object' && !Array.isArray(scenarioApprovals), 'scenarioApprovals must be an object keyed by candidate ID', @@ -118,14 +123,29 @@ check( questionApprovals && typeof questionApprovals === 'object' && !Array.isArray(questionApprovals), '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 questionEntries = Object.entries(questionApprovals ?? {}) +const channelEntries = Object.entries(channelApprovals ?? {}) for (const [candidateId] of scenarioEntries) { check(expectedScenarios.has(candidateId), `${candidateId}: approval references an unknown scenario`) } for (const [questionId] of questionEntries) { 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) => { 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(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(sha256Pattern.test(entry.productionContentHash ?? ''), `${label}: production content hash is required`) } @@ -208,6 +229,29 @@ if (strict) { 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 = [ 'businessOwner', 'knowledgeOwner', @@ -224,6 +268,37 @@ if (strict) { check(signoff?.status === 'APPROVED', `${role}: sign-off must be APPROVED`) 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) { @@ -243,12 +318,15 @@ if (scenarioSnapshots) { } else { const approvedScenarios = scenarioEntries.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 console.log('August 1 formal content approval audit passed') console.log(`- releaseStatus: ${approval.releaseStatus}`) console.log(`- formal sources: ${sourceRegistry.length}`) console.log(`- approved scenarios: ${approvedScenarios}/5`) console.log(`- approved policy questions: ${approvedQuestions}/30`) + console.log(`- approved direct channels: ${approvedChannels}/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'}`) } diff --git a/scripts/verify-aug1-release-readiness.sh b/scripts/verify-aug1-release-readiness.sh index e0ba8cc6..05399dc7 100644 --- a/scripts/verify-aug1-release-readiness.sh +++ b/scripts/verify-aug1-release-readiness.sh @@ -213,6 +213,8 @@ for snapshot in "${snapshots[@]}"; do done echo "AUG1_FORMAL_SCENARIOS=5/5" echo "AUG1_FORMAL_POLICY_QUESTIONS=30/30" +echo "AUG1_CHANNEL_ACCEPTANCE=5/5" echo "AUG1_OWNER_SIGNOFFS=5/5" +echo "AUG1_DISTRIBUTION_APPROVAL=PASS" echo "AUG1_RELEASE_READINESS=PASS" REMOTE