fix(release): enforce August 1 business readiness

This commit is contained in:
key
2026-07-29 12:40:11 +08:00
parent c0b55efc29
commit 63968f3368
11 changed files with 1036 additions and 20 deletions
+6 -4
View File
@@ -310,13 +310,15 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top ./scripts/release-preflight.sh
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true ./scripts/release-preflight.sh
# 定向后端发布后只核对后端与 schema,不要求本轮未发布的管理端/H5 与本地一致
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh
# 8 月 1 日最终业务门禁:正式内容签认、固定码模式、五渠道主处理人/替补和生产场景快照,只读且失败关闭
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh
# 验证冻结提交生成的指定 JAR;提交必须是当前 HEAD 的祖先,且产物时间不得早于该提交
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_ARTIFACT_COMMIT=<release-commit> RELEASE_LOCAL_BACKEND_PATH=<release-jar> ./scripts/release-preflight.sh
# 发布后同时核对线上静态资源、后端包和必需表(schema 检查为只读)
RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh
# 8 月 1 日最终发布后同时核对线上静态资源、后端包、必需表和业务就绪状态
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 核验不要求无关构建产物。默认以当前 `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 静态资源匹配。只有同时启用 `RELEASE_VERIFY_REMOTE_MATCH=true`、`RELEASE_VERIFY_REMOTE_BACKEND=true` 和 `RELEASE_VERIFY_REMOTE_SCHEMA=true`,才能称为完整包匹配。远端后端默认核对 `/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 问题证据及五类负责人均签认后,才继续只读检查生产固定码模式、五个直通角色各至少两名有效处理人,以及五个已发布场景的版本、内容哈希、审核人和 SOP 引用。它不请求验证码、不发送短信、不登录、不输出账号或处理人身份,也不写数据库。远端后端默认核对 `/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`。
@@ -373,7 +375,7 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep
-BackendJar .\backend\ruoyi-admin\target\ruoyi-admin.jar
```
打包器重新校验冻结证据、APK/JAR/内容哈希和提交祖先关系,按固定顺序与时间戳生成 17 个文件的 ZIP,并附带发布、预检、无登录 API 探针、固定码认证复验、内容候选校验、业务与五通道签认单、Android 取证及最新 Go/No-Go 的审计快照。ZIP 内 operations 脚本用于交付审计;实际执行仍须从对应 operations commit 的干净仓库运行。固定码认证脚本只有显式传入 `--execute` 才运行,沿用生产现有固定码配置,不启用或发送真实短信,也不提交业务记录。
打包器重新校验冻结证据、APK/JAR/内容哈希和提交祖先关系,按固定顺序与时间戳生成 20 个文件的 ZIP,并附带发布、预检、无登录 API 探针、固定码认证复验、内容候选与正式签认校验、生产业务就绪门禁、机器签认清单、业务与五通道签认单、Android 取证及最新 Go/No-Go 的审计快照。ZIP 内 operations 脚本用于交付审计;实际执行仍须从对应 operations commit 的干净仓库运行。固定码认证脚本只有显式传入 `--execute` 才运行,沿用生产现有固定码配置,不启用或发送真实短信,也不提交业务记录;最终业务就绪脚本同样只读,签认不足时在连接生产前就失败关闭。
## MVP 页面验证
@@ -0,0 +1,39 @@
{
"schemaVersion": "1.0",
"releaseTarget": "2026-08-01",
"tenantId": "000000",
"candidateFile": "aug1-life-advisor-content-candidates-v0.1.json",
"candidateSha256": "148654e241e0fd74bcb29d71cd43518cb961f0128855b66af98a56bc841aea1d",
"releaseStatus": "PENDING",
"formalContentVersion": "",
"sourceRegistry": [],
"scenarioApprovals": {},
"policyQuestionApprovals": {},
"signoffs": {
"businessOwner": {
"status": "PENDING",
"reviewedBy": "",
"reviewedAt": ""
},
"knowledgeOwner": {
"status": "PENDING",
"reviewedBy": "",
"reviewedAt": ""
},
"trainingOwner": {
"status": "PENDING",
"reviewedBy": "",
"reviewedAt": ""
},
"channelOwner": {
"status": "PENDING",
"reviewedBy": "",
"reviewedAt": ""
},
"releaseOwner": {
"status": "PENDING",
"reviewedBy": "",
"reviewedAt": ""
}
}
}
@@ -116,3 +116,35 @@
| 训练内容负责人 | `PENDING` | 待签认 | 待签认 | |
| 五通道业务负责人 | `PENDING` | 待签认 | 待签认 | |
| 发布负责人 | `PENDING` | 待签认 | 待签认 | |
## 7. 机器门禁与回填方式
本单用于业务阅读;机器事实源是 [`aug1-release-approval.json`](content-candidates/aug1-release-approval.json)。它只保存正式来源的编号、版本、适用范围与哈希,逐项审核和回归证据引用,以及生产场景版本/哈希,不复制制度正文、标准答案、手机号或处理人身份。
回填顺序固定为:
1. 业务方提供正式文件,登记 `sourceRegistry`;不得把访谈或 AI 输出登记为 `formalPolicy=true`。
2. 5 个场景在管理端完成内容录入和审核,高风险场景由不同人员二审;把最终生产 `scenarioCode`、`contentVersion`、`contentHash` 和证据引用回填到对应 `scenarioApprovals`。
3. 30 道问题逐项形成正式引用/拒答/越权回归证据,把证据文件引用和 SHA-256 回填到 `policyQuestionApprovals`;不把答案正文写入仓库。
4. 五类负责人签认后,将顶层 `releaseStatus` 改为 `APPROVED`。任何缺项、占位值、未知来源、证据哈希缺失或高风险同人二审都会失败关闭。
5. 系统管理员按本单绑定五通道主处理人和替补;机器门禁只输出每个角色的有效人数,不输出人员身份。
先审计当前回填进度:
```bash
node scripts/verify-aug1-formal-content.mjs
```
正式签认完成后,严格校验本地证据:
```bash
node scripts/verify-aug1-formal-content.mjs --strict
```
最后执行生产只读门禁:
```bash
./scripts/verify-aug1-release-readiness.sh --execute
```
最终脚本不会请求或消费验证码,不会触发真实短信,不登录,不写数据库。它要求生产固定码模式已明确启用、五个角色各至少两名有效处理人,并核对 5 个已发布场景的版本、内容哈希、审核链和 SOP 引用与签认清单完全一致。
+53 -10
View File
@@ -7,6 +7,8 @@ param(
[string]$ContentCandidate = 'docs\content-candidates\aug1-life-advisor-content-candidates-v0.1.json',
[string]$ReleaseApproval = 'docs\content-candidates\aug1-release-approval.json',
[string]$RuntimeCommit = 'af8af2f6dd169e4616a4ca0ca724e8b809502ab3',
[string]$OperationsCommit = 'HEAD',
@@ -71,12 +73,14 @@ function Ensure-UnderOutput {
$evidenceDirectoryPath = Resolve-ProjectPath $EvidenceDirectory
$backendJarPath = Resolve-ProjectPath $BackendJar
$contentCandidatePath = Resolve-ProjectPath $ContentCandidate
$releaseApprovalPath = Resolve-ProjectPath $ReleaseApproval
$outputDirectoryPath = Ensure-UnderOutput (Resolve-ProjectPath $OutputDirectory)
$evidencePath = Join-Path $evidenceDirectoryPath 'release-evidence.json'
Require-File $evidencePath
Require-File $backendJarPath
Require-File $contentCandidatePath
Require-File $releaseApprovalPath
$runtimeCommitSha = Read-Git @('rev-parse', '--verify', "$RuntimeCommit^{commit}")
$operationsCommitSha = Read-Git @('rev-parse', '--verify', "$OperationsCommit^{commit}")
@@ -108,6 +112,7 @@ Require-File $apkPath
$apkSha256 = Get-Sha256 $apkPath
$backendSha256 = Get-Sha256 $backendJarPath
$contentSha256 = Get-Sha256 $contentCandidatePath
$releaseApprovalSha256 = Get-Sha256 $releaseApprovalPath
if ($apkSha256 -ne ([string]$evidence.artifacts.apk.sha256).ToLowerInvariant()) {
throw "Frozen APK hash mismatch: $apkSha256"
}
@@ -117,6 +122,20 @@ if ($backendSha256 -ne ([string]$evidence.artifacts.backendJarSha256).ToLowerInv
if ($contentSha256 -ne ([string]$evidence.contentCandidates.sha256).ToLowerInvariant()) {
throw "Content candidate hash mismatch: $contentSha256"
}
$releaseApprovalManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $releaseApprovalPath | ConvertFrom-Json
if ([string]$releaseApprovalManifest.candidateSha256 -ne $contentSha256) {
throw "Release approval candidate hash mismatch: $($releaseApprovalManifest.candidateSha256)"
}
& node (Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs') $releaseApprovalPath | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Release approval structure audit failed.'
}
$approvedScenarioCount = @($releaseApprovalManifest.scenarioApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedQuestionCount = @($releaseApprovalManifest.policyQuestionApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedSignoffCount = @($releaseApprovalManifest.signoffs.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*Android*Go-No-Go-20260728.md')
if ($goNoGoCandidates.Count -ne 1) {
@@ -143,6 +162,8 @@ $operationSources = [ordered]@{
'operations/verify-aug1-production-api-readonly.sh' = Join-Path $projectRoot 'scripts\verify-aug1-production-api-readonly.sh'
'operations/verify-aug1-authenticated-production.sh' = Join-Path $projectRoot 'scripts\verify-aug1-authenticated-production.sh'
'operations/verify-aug1-content-candidates.mjs' = Join-Path $projectRoot 'scripts\verify-aug1-content-candidates.mjs'
'operations/verify-aug1-formal-content.mjs' = Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs'
'operations/verify-aug1-release-readiness.sh' = Join-Path $projectRoot 'scripts\verify-aug1-release-readiness.sh'
'operations/capture-android-acceptance.ps1' = Join-Path $projectRoot 'scripts\capture-android-acceptance.ps1'
'operations/go-no-go.md' = $goNoGoCandidates[0].FullName
'operations/business-content-and-five-channel-signoff.md' = $businessSignoffPath
@@ -164,8 +185,9 @@ Write-Output "operations_commit=$operationsCommitSha"
Write-Output "apk_sha256=$apkSha256"
Write-Output "backend_sha256=$backendSha256"
Write-Output "content_sha256=$contentSha256"
Write-Output "release_approval_sha256=$releaseApprovalSha256"
Write-Output "package_path=$packagePath"
Write-Output 'package_entries=17'
Write-Output 'package_entries=20'
if ($PlanOnly) {
exit 0
}
@@ -191,6 +213,7 @@ try {
Copy-Item -LiteralPath $apkPath -Destination (Join-Path $stagingDirectory $apkName)
Copy-Item -LiteralPath $backendJarPath -Destination (Join-Path $stagingDirectory $backendName)
Copy-Item -LiteralPath $contentCandidatePath -Destination (Join-Path $stagingDirectory 'aug1-life-advisor-content-candidates-v0.1.json')
Copy-Item -LiteralPath $releaseApprovalPath -Destination (Join-Path $stagingDirectory 'aug1-release-approval.json')
Copy-Item -LiteralPath $evidencePath -Destination (Join-Path $stagingDirectory 'release-evidence.json')
foreach ($entry in $operationSources.GetEnumerator()) {
@@ -216,6 +239,7 @@ try {
"| $apkName | $apkSha256 |",
"| $backendName | $backendSha256 |",
"| aug1-life-advisor-content-candidates-v0.1.json | $contentSha256 |",
"| aug1-release-approval.json | $releaseApprovalSha256 |",
'',
'The APK uses a DCloud test signer and is limited to a small controlled internal test. Admin/H5 are deferred, schema is unchanged, and content candidates must not be imported or enabled.',
'',
@@ -227,6 +251,8 @@ 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.',
'',
'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.',
'',
'This package does not authorize production deployment, service restart, database change, Git push or public distribution.'
@@ -245,6 +271,8 @@ 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.",
'- 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`.'
) -join [Environment]::NewLine
@@ -257,12 +285,15 @@ try {
"- Status: $($evidence.contentCandidates.candidateStatus)",
"- Scenario candidates: $($evidence.contentCandidates.scenarioCandidates)",
"- Policy-question candidates: $($evidence.contentCandidates.policyQuestionCandidates)",
'- Formally publishable scenarios: 0',
'- Formally sourced standard answers: 0',
"- Formally approved scenarios: $approvedScenarioCount/5",
"- Formally evidenced policy questions: $approvedQuestionCount/30",
"- Owner sign-offs: $approvedSignoffCount/5",
'',
'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.',
'',
'Run `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json` after extraction, then use `operations/business-content-and-five-channel-signoff.md` for item-level review and handler assignment.'
'Run `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json` after extraction, then use `operations/business-content-and-five-channel-signoff.md` for item-level review and handler assignment.',
'',
'Record only source/evidence references and hashes in `aug1-release-approval.json`; do not copy formal answer text or identities into it. Run `node operations/verify-aug1-formal-content.mjs aug1-release-approval.json` for progress and add `--strict` only after every item is signed.'
) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'content-review-readme.md') -Content ($contentReview + [Environment]::NewLine)
@@ -312,8 +343,17 @@ try {
publishable = $false
scenarioCandidates = [int]$evidence.contentCandidates.scenarioCandidates
policyQuestionCandidates = [int]$evidence.contentCandidates.policyQuestionCandidates
formalPublishableScenarios = 0
formallySourcedStandardAnswers = 0
formalPublishableScenarios = $approvedScenarioCount
formallySourcedStandardAnswers = $approvedQuestionCount
}
formalApproval = [ordered]@{
artifact = 'aug1-release-approval.json'
sha256 = $releaseApprovalSha256
status = [string]$releaseApprovalManifest.releaseStatus
formalContentVersion = [string]$releaseApprovalManifest.formalContentVersion
approvedScenarios = $approvedScenarioCount
approvedPolicyQuestions = $approvedQuestionCount
approvedOwnerSignoffs = $approvedSignoffCount
}
operations = @($operationSources.Keys)
manualGatesRemaining = @($evidence.manualGatesRemaining)
@@ -331,8 +371,8 @@ try {
$allFiles = Get-ChildItem -LiteralPath $stagingDirectory -Recurse -File |
Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) }
if ($allFiles.Count -ne 17) {
throw "Release package expected 17 files, found $($allFiles.Count)."
if ($allFiles.Count -ne 20) {
throw "Release package expected 20 files, found $($allFiles.Count)."
}
foreach ($file in $allFiles) {
$file.LastWriteTimeUtc = $entryTimestamp.UtcDateTime
@@ -369,8 +409,8 @@ try {
$verificationArchive = [IO.Compression.ZipFile]::OpenRead($partialPackagePath)
try {
if ($verificationArchive.Entries.Count -ne 17) {
throw "ZIP verification expected 17 entries, found $($verificationArchive.Entries.Count)."
if ($verificationArchive.Entries.Count -ne 20) {
throw "ZIP verification expected 20 entries, found $($verificationArchive.Entries.Count)."
}
$entryNames = @($verificationArchive.Entries | ForEach-Object { $_.FullName })
foreach ($requiredEntry in @(
@@ -381,6 +421,9 @@ try {
'operations/release-backend.sh',
'operations/verify-aug1-authenticated-production.sh',
'operations/verify-aug1-content-candidates.mjs',
'operations/verify-aug1-formal-content.mjs',
'operations/verify-aug1-release-readiness.sh',
'aug1-release-approval.json',
'operations/business-content-and-five-channel-signoff.md'
)) {
if ($entryNames -notcontains $requiredEntry) {
+12 -1
View File
@@ -46,8 +46,10 @@ normalized_jar_content_sha256() {
verify_remote_match="${RELEASE_VERIFY_REMOTE_MATCH:-false}"
verify_remote_backend="${RELEASE_VERIFY_REMOTE_BACKEND:-false}"
verify_remote_schema="${RELEASE_VERIFY_REMOTE_SCHEMA:-false}"
verify_aug1_readiness="${RELEASE_VERIFY_AUG1_READINESS:-false}"
remote_verification_requested="false"
if [[ "$verify_remote_match" == "true" || "$verify_remote_backend" == "true" || "$verify_remote_schema" == "true" ]]; then
if [[ "$verify_remote_match" == "true" || "$verify_remote_backend" == "true" \
|| "$verify_remote_schema" == "true" || "$verify_aug1_readiness" == "true" ]]; then
remote_verification_requested="true"
fi
if [[ "$remote_verification_requested" == "true" && -z "${RELEASE_REMOTE_URL:-}" ]]; then
@@ -866,6 +868,15 @@ if [[ -n "${RELEASE_REMOTE_URL:-}" ]]; then
require_remote_business_success "remote_tenant_list" "$remote/prod-api/auth/tenant/list"
require_remote_business_success "remote_mobile_home" "$remote/prod-api/api/aihr/mobile/home/user"
if [[ "$verify_aug1_readiness" == "true" ]]; then
AIHR_AUG1_REMOTE_SSH="${RELEASE_REMOTE_SSH:-YCWY}" \
AIHR_AUG1_REMOTE_SERVICE="${RELEASE_REMOTE_SERVICE:-wygj-aihr.service}" \
AIHR_AUG1_REMOTE_DB="${RELEASE_REMOTE_DB_NAME:-ry-vue}" \
AIHR_AUG1_TENANT_ID="${RELEASE_AUG1_TENANT_ID:-000000}" \
AIHR_AUG1_APPROVAL_PATH="${RELEASE_AUG1_APPROVAL_PATH:-$ROOT_DIR/docs/content-candidates/aug1-release-approval.json}" \
"$ROOT_DIR/scripts/verify-aug1-release-readiness.sh" --execute
fi
if [[ "$verify_remote_schema" == "true" ]]; then
require_remote_schema
fi
+182
View File
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
const testDir = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(testDir, '..', '..')
const verifierPath = path.join(projectRoot, 'scripts', 'verify-aug1-formal-content.mjs')
const pendingApprovalPath = path.join(
projectRoot,
'docs',
'content-candidates',
'aug1-release-approval.json',
)
const candidatePath = path.join(
projectRoot,
'docs',
'content-candidates',
'aug1-life-advisor-content-candidates-v0.1.json',
)
const candidateBytes = await readFile(candidatePath)
const candidate = JSON.parse(candidateBytes.toString('utf8'))
const candidateSha256 = createHash('sha256').update(candidateBytes).digest('hex')
const source = {
sourceId: 'FORMAL-OPS-001',
title: '现行生活顾问服务作业标准',
version: '2026.07',
effectiveDate: '2026-07-01',
scope: '受控内测项目',
sha256: 'a'.repeat(64),
formalPolicy: true,
}
function validApproval() {
const scenarioApprovals = Object.fromEntries(candidate.scenarios.map((item, index) => {
const highRisk = item.proposedRiskLevel === '高风险'
return [item.candidateId, {
status: 'APPROVED',
scenarioCode: item.scenarioDraft.id,
riskLevel: item.proposedRiskLevel,
sourceRefs: ['FORMAL-OPS-001:section-1'],
reviewedBy: `reviewer-${index + 1}`,
reviewedAt: '2026-07-30T09:00:00+08:00',
...(highRisk ? {
secondReviewedBy: `second-reviewer-${index + 1}`,
secondReviewedAt: '2026-07-30T10:00:00+08:00',
} : {}),
businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`,
productionContentVersion: `aug1-v${index + 1}`,
productionContentHash: String(index + 1).repeat(64),
}]
}))
const policyQuestionApprovals = Object.fromEntries(candidate.policyQuestionCandidates.map((item, index) => {
const answerDisposition = item.category === 'NO_EVIDENCE'
? 'FORMAL_NO_EVIDENCE_BOUNDARY'
: item.category === 'UNAUTHORIZED'
? 'FORMAL_UNAUTHORIZED_BOUNDARY'
: 'FORMALLY_SOURCED'
return [item.id, {
status: 'APPROVED',
answerDisposition,
sourceRefs: ['FORMAL-OPS-001:section-2'],
expectedBehavior: item.expectedBehavior,
observedBehavior: '验收结果与预期边界一致',
evidenceRef: `evidence/question-${index + 1}.json`,
evidenceSha256: 'b'.repeat(64),
reviewedBy: `question-reviewer-${index + 1}`,
reviewedAt: '2026-07-30T11:00:00+08:00',
}]
}))
const signoffs = Object.fromEntries([
'businessOwner',
'knowledgeOwner',
'trainingOwner',
'channelOwner',
'releaseOwner',
].map((role) => [role, {
status: 'APPROVED',
reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T12:00:00+08:00',
}]))
return {
schemaVersion: '1.0',
releaseTarget: '2026-08-01',
tenantId: '000000',
candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json',
candidateSha256,
releaseStatus: 'APPROVED',
formalContentVersion: 'aug1-formal-v1',
sourceRegistry: [source],
scenarioApprovals,
policyQuestionApprovals,
signoffs,
}
}
async function verifyApproval(approval, ...options) {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'aug1-formal-'))
const tempCandidatePath = path.join(tempDir, 'aug1-life-advisor-content-candidates-v0.1.json')
const tempApprovalPath = path.join(tempDir, 'approval.json')
try {
await writeFile(tempCandidatePath, candidateBytes)
await writeFile(tempApprovalPath, JSON.stringify(approval), 'utf8')
return spawnSync(process.execPath, [verifierPath, ...options, tempApprovalPath], {
cwd: projectRoot,
encoding: 'utf8',
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
test('audits the checked-in pending manifest without claiming release readiness', () => {
const audit = spawnSync(process.execPath, [verifierPath, pendingApprovalPath], {
cwd: projectRoot,
encoding: 'utf8',
})
assert.equal(audit.status, 0, audit.stderr)
assert.match(audit.stdout, /approved scenarios: 0\/5/)
assert.match(audit.stdout, /approved policy questions: 0\/30/)
const strict = spawnSync(process.execPath, [verifierPath, '--strict', pendingApprovalPath], {
cwd: projectRoot,
encoding: 'utf8',
})
assert.notEqual(strict.status, 0)
assert.match(strict.stderr, /releaseStatus must be APPROVED/)
})
test('accepts complete formal evidence and emits five immutable production snapshots', async () => {
const strict = await verifyApproval(validApproval(), '--strict')
assert.equal(strict.status, 0, strict.stderr)
assert.match(strict.stdout, /strict release ready: true/)
const snapshots = await verifyApproval(validApproval(), '--scenario-snapshots')
assert.equal(snapshots.status, 0, snapshots.stderr)
const lines = snapshots.stdout.trim().split(/\r?\n/)
assert.equal(lines.length, 5)
assert.match(lines[0], /^[a-z0-9-]+\|aug1-v1\|1{64}$/)
})
test('rejects incomplete or contradictory formal approvals', async (t) => {
const cases = [
['missing question', (approval) => {
delete approval.policyQuestionApprovals[candidate.policyQuestionCandidates[0].id]
}, /question approvals must cover exactly 30\/30/],
['wrong no-evidence behavior', (approval) => {
const item = candidate.policyQuestionCandidates.find((question) => question.category === 'NO_EVIDENCE')
approval.policyQuestionApprovals[item.id].answerDisposition = 'FORMALLY_SOURCED'
}, /must preserve the formal refusal boundary/],
['same high-risk reviewer', (approval) => {
const item = candidate.scenarios.find((scenario) => scenario.proposedRiskLevel === '高风险')
approval.scenarioApprovals[item.candidateId].secondReviewedBy =
approval.scenarioApprovals[item.candidateId].reviewedBy
}, /high-risk reviewers must be different people/],
['unknown source', (approval) => {
const item = candidate.scenarios[0]
approval.scenarioApprovals[item.candidateId].sourceRefs = ['MISSING-SOURCE:section-1']
}, /is not in sourceRegistry/],
['placeholder source version', (approval) => {
approval.sourceRegistry[0].version = '待填写'
}, /formal source version is required/],
['missing owner sign-off', (approval) => {
approval.signoffs.releaseOwner.status = 'PENDING'
}, /releaseOwner: sign-off must be APPROVED/],
]
for (const [name, mutate, expected] of cases) {
await t.test(name, async () => {
const approval = validApproval()
mutate(approval)
const result = await verifyApproval(approval, '--strict')
assert.notEqual(result.status, 0)
assert.match(result.stderr, expected)
})
}
})
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT_DIR/scripts/verify-aug1-release-readiness.sh"
PREFLIGHT="$ROOT_DIR/scripts/release-preflight.sh"
DEV_SETUP="$ROOT_DIR/docs/DEV_SETUP.md"
bash -n "$SCRIPT"
bash -n "$PREFLIGHT"
pending_output="$("$SCRIPT" --execute 2>&1 || true)"
grep -Fq 'releaseStatus must be APPROVED' <<<"$pending_output"
grep -Fq 'strict approval did not return exactly five scenario snapshots' <<<"$pending_output"
test_tmp_base="${TMPDIR:-/tmp}"
test_tmp="$(mktemp -d "$test_tmp_base/aug1-readiness-test.XXXXXX")"
cleanup() {
case "$test_tmp" in
"$test_tmp_base"/aug1-readiness-test.*) rm -rf -- "$test_tmp" ;;
*) echo "FAIL: unsafe test cleanup path: $test_tmp" >&2; exit 1 ;;
esac
}
trap cleanup EXIT
cp "$ROOT_DIR/docs/content-candidates/aug1-life-advisor-content-candidates-v0.1.json" \
"$test_tmp/aug1-life-advisor-content-candidates-v0.1.json"
node - "$test_tmp/aug1-life-advisor-content-candidates-v0.1.json" \
"$test_tmp/approval.json" "$test_tmp/scenario-rows.tsv" <<'NODE'
const fs = require('node:fs')
const crypto = require('node:crypto')
const [candidatePath, approvalPath, rowsPath] = process.argv.slice(2)
const bytes = fs.readFileSync(candidatePath)
const candidate = JSON.parse(bytes)
const source = {
sourceId: 'FORMAL-OPS-001',
title: 'test-only formal operations standard',
version: 'test-v1',
effectiveDate: '2026-07-01',
scope: 'test-only',
sha256: 'a'.repeat(64),
formalPolicy: true,
}
const scenarioApprovals = {}
const rows = []
candidate.scenarios.forEach((item, index) => {
const highRisk = item.proposedRiskLevel === '高风险'
const version = `aug1-v${index + 1}`
const hash = String(index + 1).repeat(64)
scenarioApprovals[item.candidateId] = {
status: 'APPROVED',
scenarioCode: item.scenarioDraft.id,
riskLevel: item.proposedRiskLevel,
sourceRefs: ['FORMAL-OPS-001:section-1'],
reviewedBy: `reviewer-${index + 1}`,
reviewedAt: '2026-07-30T09:00:00+08:00',
...(highRisk ? {
secondReviewedBy: `second-reviewer-${index + 1}`,
secondReviewedAt: '2026-07-30T10:00:00+08:00',
} : {}),
businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`,
productionContentVersion: version,
productionContentHash: hash,
}
rows.push([
item.scenarioDraft.id,
version,
hash,
'1',
'已发布',
item.proposedRiskLevel,
String(100 + index),
'2026-07-30T09:00:00',
highRisk ? String(200 + index) : '0',
highRisk ? '2026-07-30T10:00:00' : '-',
'FORMAL-OPS-001:section-1',
].join('\t'))
})
const policyQuestionApprovals = {}
candidate.policyQuestionCandidates.forEach((item, index) => {
policyQuestionApprovals[item.id] = {
status: 'APPROVED',
answerDisposition: item.category === 'NO_EVIDENCE'
? 'FORMAL_NO_EVIDENCE_BOUNDARY'
: item.category === 'UNAUTHORIZED'
? 'FORMAL_UNAUTHORIZED_BOUNDARY'
: 'FORMALLY_SOURCED',
sourceRefs: ['FORMAL-OPS-001:section-2'],
expectedBehavior: item.expectedBehavior,
observedBehavior: 'test-only observed result',
evidenceRef: `evidence/question-${index + 1}.json`,
evidenceSha256: 'b'.repeat(64),
reviewedBy: `question-reviewer-${index + 1}`,
reviewedAt: '2026-07-30T11:00:00+08:00',
}
})
const signoffs = Object.fromEntries([
'businessOwner',
'knowledgeOwner',
'trainingOwner',
'channelOwner',
'releaseOwner',
].map((role) => [role, {
status: 'APPROVED',
reviewedBy: `${role}-reviewer`,
reviewedAt: '2026-07-30T12:00:00+08:00',
}]))
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'),
releaseStatus: 'APPROVED',
formalContentVersion: 'aug1-formal-v1',
sourceRegistry: [source],
scenarioApprovals,
policyQuestionApprovals,
signoffs,
}))
fs.writeFileSync(rowsPath, rows.join('\n') + '\n')
NODE
mock_bin="$test_tmp/mock-bin"
mkdir -p "$mock_bin"
cat > "$mock_bin/systemctl" <<'MOCK_SYSTEMCTL'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$MOCK_SERVICE_PID"
MOCK_SYSTEMCTL
cat > "$mock_bin/mysql" <<'MOCK_MYSQL'
#!/usr/bin/env bash
set -euo pipefail
query="$(cat)"
if [[ "$query" == *'COUNT(DISTINCT u.user_id)'* ]]; then
handler_count="${MOCK_HANDLER_COUNT:-2}"
printf 'direct_audit\t%s\n' "$handler_count"
printf 'direct_finance\t%s\n' "$handler_count"
printf 'direct_hr\t%s\n' "$handler_count"
printf 'direct_operations\t%s\n' "$handler_count"
printf 'direct_president\t%s\n' "$handler_count"
elif [[ "$query" == *'FROM aihr_practice_scenario'* ]]; then
cat "$MOCK_SCENARIO_ROWS"
else
echo 'FAIL: unexpected mock MySQL query' >&2
exit 90
fi
MOCK_MYSQL
cat > "$mock_bin/ssh" <<'MOCK_SSH'
#!/usr/bin/env bash
set -euo pipefail
while (($#)); do
if [[ "$1" == '--' ]]; then
shift
break
fi
shift
done
remote_script="$MOCK_REMOTE_SCRIPT"
cat > "$remote_script"
export MOCK_SERVICE_PID="$$"
PATH="$MOCK_REMOTE_BIN:$PATH" bash "$remote_script" "$@"
MOCK_SSH
chmod +x "$mock_bin/systemctl" "$mock_bin/mysql" "$mock_bin/ssh"
run_mock_readiness() {
PATH="$mock_bin:$PATH" \
MOCK_REMOTE_BIN="$mock_bin" \
MOCK_REMOTE_SCRIPT="$test_tmp/remote.sh" \
MOCK_SCENARIO_ROWS="$test_tmp/scenario-rows.tsv" \
AIHR_SMS_DEV_FIXED_CODE='test-only-fixed-code' \
AIHR_SMS_PROD_FIXED_CODE_ENABLED="${AIHR_SMS_PROD_FIXED_CODE_ENABLED:-true}" \
AIHR_AUG1_APPROVAL_PATH="$test_tmp/approval.json" \
"$SCRIPT" --execute
}
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_RELEASE_READINESS=PASS' <<<"$ready_output"
handler_failure="$(
MOCK_HANDLER_COUNT=1 run_mock_readiness 2>&1 || true
)"
grep -Fq 'requires an active primary handler and backup; got 1/2' <<<"$handler_failure"
fixed_mode_failure="$(
AIHR_SMS_PROD_FIXED_CODE_ENABLED=false run_mock_readiness 2>&1 || true
)"
grep -Fq 'production fixed-code mode is not explicitly enabled' <<<"$fixed_mode_failure"
grep -Fq 'AIHR_SMS_DEV_FIXED_CODE' "$SCRIPT"
grep -Fq 'AIHR_SMS_PROD_FIXED_CODE_ENABLED' "$SCRIPT"
grep -Fq 'AUG1_FIXED_CODE_NO_REAL_SMS_MODE=PASS' "$SCRIPT"
grep -Fq 'COUNT(DISTINCT u.user_id)' "$SCRIPT"
grep -Fq 'handler_count >= 2' "$SCRIPT"
grep -Fq 'u.status = '\''0'\''' "$SCRIPT"
grep -Fq 'u.del_flag = '\''0'\''' "$SCRIPT"
grep -Fq 'review_status" == "已发布"' "$SCRIPT"
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"
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
exit 1
fi
if grep -Fq '/resource/sms/code' "$SCRIPT" || grep -Fq '/auth/mobile/sms-login' "$SCRIPT"; then
echo 'FAIL: final readiness verifier must inspect fixed-code state without requesting or consuming a code' >&2
exit 1
fi
if grep -Eq 'echo[[:space:]].*(fixed_code|reviewedBy|user_id)' "$SCRIPT"; then
echo 'FAIL: final readiness verifier may expose a code or identity' >&2
exit 1
fi
grep -Fq 'verify_aug1_readiness="${RELEASE_VERIFY_AUG1_READINESS:-false}"' "$PREFLIGHT"
grep -Fq 'verify-aug1-release-readiness.sh" --execute' "$PREFLIGHT"
grep -Fq 'RELEASE_VERIFY_AUG1_READINESS=true' "$DEV_SETUP"
echo 'PASS: August 1 final readiness gate is fixed-code-only, read-only, content-bound and handler-bound'
+8 -3
View File
@@ -12,22 +12,27 @@ $requiredFragments = @(
'Frozen APK hash mismatch',
'Frozen backend JAR hash mismatch',
'Content candidate hash mismatch',
'Release approval candidate hash mismatch',
"'operations/release-backend.sh'",
"'operations/release-preflight.sh'",
"'operations/verify-aug1-production-api-readonly.sh'",
"'operations/verify-aug1-authenticated-production.sh'",
"'operations/verify-aug1-content-candidates.mjs'",
"'operations/verify-aug1-formal-content.mjs'",
"'operations/verify-aug1-release-readiness.sh'",
"'operations/capture-android-acceptance.ps1'",
"'operations/go-no-go.md'",
"'operations/business-content-and-five-channel-signoff.md'",
'Release package already exists and will not be overwritten',
'Release package expected 17 files',
'ZIP verification expected 17 entries',
'Release package expected 20 files',
'ZIP verification expected 20 entries',
'ZIP entry hash mismatch',
'package_verified=true',
'authenticatedFixedCodeSmokePassed = $true',
'fixedCodeNoRealSmsConfirmed = $true',
'deployPlanSmsOrLoginTriggered = $false'
'deployPlanSmsOrLoginTriggered = $false',
"artifact = 'aug1-release-approval.json'",
'approvedPolicyQuestions = $approvedQuestionCount'
)
foreach ($fragment in $requiredFragments) {
if (-not $source.Contains($fragment)) {
+10 -2
View File
@@ -17,6 +17,11 @@ schema_without_url="$(
)"
grep -Fq 'remote verification flags require RELEASE_REMOTE_URL' <<<"$schema_without_url"
aug1_readiness_without_url="$(
RELEASE_VERIFY_AUG1_READINESS=true bash "$SCRIPT" 2>&1 || true
)"
grep -Fq 'remote verification flags require RELEASE_REMOTE_URL' <<<"$aug1_readiness_without_url"
if grep -Fq 'RELEASE_VERIFY_REMOTE_BACKEND=true requires RELEASE_VERIFY_REMOTE_MATCH=true' "$SCRIPT"; then
echo 'FAIL: backend-only verification is still coupled to static resource matching' >&2
exit 1
@@ -32,10 +37,13 @@ static_scope_line="$(grep -n '^[[:space:]]*if \[\[ "\$verify_remote_match" == "t
grep -Fq 'require_static_artifacts="$verify_remote_match"' "$SCRIPT"
grep -Fq 'require_backend_artifact="$verify_remote_backend"' "$SCRIPT"
grep -Fq 'verify_aug1_readiness="${RELEASE_VERIFY_AUG1_READINESS:-false}"' "$SCRIPT"
grep -Fq 'verify-aug1-release-readiness.sh" --execute' "$SCRIPT"
grep -Fq 'backend_jar="${RELEASE_LOCAL_BACKEND_PATH:-backend/ruoyi-admin/target/ruoyi-admin.jar}"' "$SCRIPT"
grep -Fq 'artifact_commit="${RELEASE_ARTIFACT_COMMIT:-HEAD}"' "$SCRIPT"
grep -Fq 'git merge-base --is-ancestor "$artifact_commit_sha" HEAD' "$SCRIPT"
grep -Fq 'RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh' "$DEV_SETUP"
grep -Fq 'RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true ./scripts/release-preflight.sh' "$DEV_SETUP"
grep -Fq 'RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
grep -Fq 'RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_AUG1_READINESS=true ./scripts/release-preflight.sh' "$DEV_SETUP"
echo 'PASS: release preflight supports scoped backend/schema verification and preserves the full-package mode'
echo 'PASS: release preflight supports scoped artifact checks and a strict August 1 business-readiness mode'
+254
View File
@@ -0,0 +1,254 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(scriptDir, '..')
const defaultApprovalPath = path.join(
rootDir,
'docs',
'content-candidates',
'aug1-release-approval.json',
)
let strict = false
let scenarioSnapshots = false
const positional = []
for (const argument of process.argv.slice(2)) {
if (argument === '--strict') {
strict = true
} else if (argument === '--scenario-snapshots') {
strict = true
scenarioSnapshots = true
} else if (argument.startsWith('-')) {
console.error(`Unknown option: ${argument}`)
process.exit(2)
} else {
positional.push(argument)
}
}
if (positional.length > 1) {
console.error('Usage: node verify-aug1-formal-content.mjs [--strict] [--scenario-snapshots] [approval-json]')
process.exit(2)
}
const approvalPath = positional[0] ? path.resolve(positional[0]) : defaultApprovalPath
const approval = JSON.parse(await readFile(approvalPath, 'utf8'))
const failures = []
const check = (condition, message) => {
if (!condition) failures.push(message)
}
const nonBlank = (value) => typeof value === 'string' && value.trim().length > 0
const sha256Pattern = /^[0-9a-f]{64}$/
const reviewedAtPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/
const sourceIdPattern = /^[A-Z0-9][A-Z0-9._-]{1,79}$/
const scenarioCodePattern = /^[a-z0-9][a-z0-9-]{1,79}$/
const placeholderPattern = /(?:待补|待定|待填写|待签|TBD|TODO)/i
check(approval.schemaVersion === '1.0', 'approval schemaVersion must be 1.0')
check(approval.releaseTarget === '2026-08-01', 'release target must be 2026-08-01')
check(/^[0-9A-Za-z_-]{1,20}$/.test(approval.tenantId ?? ''), 'tenantId is invalid')
const candidateFileValid = nonBlank(approval.candidateFile)
&& path.basename(approval.candidateFile) === 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')
if (!candidateFileValid) {
for (const failure of failures) console.error(`FAIL: ${failure}`)
process.exit(1)
}
const candidatePath = path.resolve(path.dirname(approvalPath), approval.candidateFile ?? '')
let candidateBytes
try {
candidateBytes = await readFile(candidatePath)
} catch {
console.error('FAIL: candidate file cannot be read beside the approval manifest')
process.exit(1)
}
const candidateHash = createHash('sha256').update(candidateBytes).digest('hex')
check(candidateHash === approval.candidateSha256, 'candidate file SHA-256 does not match approval manifest')
const candidate = JSON.parse(candidateBytes.toString('utf8'))
check(candidate.candidateStatus === 'PENDING_BUSINESS_REVIEW', 'candidate source must remain pending review')
check(candidate.publishable === false, 'candidate source must remain non-publishable')
check(Array.isArray(candidate.scenarios) && candidate.scenarios.length === 5, 'candidate source must contain five scenarios')
check(
Array.isArray(candidate.policyQuestionCandidates) && candidate.policyQuestionCandidates.length === 30,
'candidate source must contain thirty policy questions',
)
const expectedScenarios = new Map()
for (const item of candidate.scenarios ?? []) {
check(nonBlank(item.candidateId), 'candidate scenario ID is required')
check(nonBlank(item.scenarioDraft?.id), `${item.candidateId ?? 'unknown scenario'}: scenario code is required`)
check(!expectedScenarios.has(item.candidateId), `${item.candidateId}: duplicate candidate scenario ID`)
expectedScenarios.set(item.candidateId, item)
}
const expectedQuestions = new Map()
for (const item of candidate.policyQuestionCandidates ?? []) {
check(nonBlank(item.id), 'candidate question ID is required')
check(!expectedQuestions.has(item.id), `${item.id}: duplicate candidate question ID`)
expectedQuestions.set(item.id, item)
}
const sourceRegistry = Array.isArray(approval.sourceRegistry) ? approval.sourceRegistry : []
check(Array.isArray(approval.sourceRegistry), 'sourceRegistry must be an array')
const sourceIds = new Set()
for (const source of sourceRegistry) {
const label = source.sourceId ?? 'unknown source'
check(sourceIdPattern.test(source.sourceId ?? ''), `${label}: sourceId is invalid`)
check(!sourceIds.has(source.sourceId), `${label}: duplicate sourceId`)
sourceIds.add(source.sourceId)
check(nonBlank(source.title) && !placeholderPattern.test(source.title), `${label}: formal source title is required`)
check(nonBlank(source.version) && !placeholderPattern.test(source.version), `${label}: formal source version is required`)
check(/^\d{4}-\d{2}-\d{2}$/.test(source.effectiveDate ?? ''), `${label}: effectiveDate must be YYYY-MM-DD`)
check(nonBlank(source.scope) && !placeholderPattern.test(source.scope), `${label}: source scope is required`)
check(sha256Pattern.test(source.sha256 ?? ''), `${label}: source SHA-256 is required`)
check(source.formalPolicy === true, `${label}: source must be marked formalPolicy=true`)
}
const scenarioApprovals = approval.scenarioApprovals
const questionApprovals = approval.policyQuestionApprovals
check(
scenarioApprovals && typeof scenarioApprovals === 'object' && !Array.isArray(scenarioApprovals),
'scenarioApprovals must be an object keyed by candidate ID',
)
check(
questionApprovals && typeof questionApprovals === 'object' && !Array.isArray(questionApprovals),
'policyQuestionApprovals must be an object keyed by question ID',
)
const scenarioEntries = Object.entries(scenarioApprovals ?? {})
const questionEntries = Object.entries(questionApprovals ?? {})
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 resolveSourceRefs = (refs, label) => {
check(Array.isArray(refs) && refs.length > 0, `${label}: at least one formal source reference is required`)
for (const reference of refs ?? []) {
check(nonBlank(reference), `${label}: source reference must be non-empty`)
const sourceId = typeof reference === 'string' ? reference.split(':', 1)[0] : ''
check(sourceIds.has(sourceId), `${label}: source reference ${reference} is not in sourceRegistry`)
}
}
const requireReview = (entry, label, prefix = '') => {
const reviewedBy = prefix ? entry[`${prefix}ReviewedBy`] : entry.reviewedBy
const reviewedAt = prefix ? entry[`${prefix}ReviewedAt`] : entry.reviewedAt
check(nonBlank(reviewedBy) && !placeholderPattern.test(reviewedBy), `${label}: ${prefix || 'first'} reviewer is required`)
check(reviewedAtPattern.test(reviewedAt ?? ''), `${label}: ${prefix || 'first'} review time must include timezone`)
}
if (strict) {
check(approval.releaseStatus === 'APPROVED', 'releaseStatus must be APPROVED')
check(
nonBlank(approval.formalContentVersion) && !placeholderPattern.test(approval.formalContentVersion),
'formalContentVersion is required',
)
check(sourceRegistry.length > 0, 'at least one formal source is required')
check(scenarioEntries.length === expectedScenarios.size, 'scenario approvals must cover exactly 5/5 candidates')
check(questionEntries.length === expectedQuestions.size, 'question approvals must cover exactly 30/30 candidates')
for (const [candidateId, candidateItem] of expectedScenarios) {
const entry = scenarioApprovals?.[candidateId]
const label = candidateId
check(Boolean(entry), `${label}: scenario approval is missing`)
if (!entry) continue
check(entry.status === 'APPROVED', `${label}: scenario status must be APPROVED`)
check(entry.scenarioCode === candidateItem.scenarioDraft.id, `${label}: production scenario code does not match candidate`)
check(scenarioCodePattern.test(entry.scenarioCode ?? ''), `${label}: production scenario code is invalid`)
check(
['常规', '高风险'].includes(entry.riskLevel) && entry.riskLevel === candidateItem.proposedRiskLevel,
`${label}: risk level must match the reviewed candidate proposal`,
)
resolveSourceRefs(entry.sourceRefs, label)
requireReview(entry, label)
if (entry.riskLevel === '高风险') {
requireReview(entry, label, 'second')
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.productionContentVersion), `${label}: production content version is required`)
check(sha256Pattern.test(entry.productionContentHash ?? ''), `${label}: production content hash is required`)
}
const dispositions = new Set([
'FORMALLY_SOURCED',
'FORMAL_NO_EVIDENCE_BOUNDARY',
'FORMAL_UNAUTHORIZED_BOUNDARY',
])
for (const [questionId, candidateItem] of expectedQuestions) {
const entry = questionApprovals?.[questionId]
const label = questionId
check(Boolean(entry), `${label}: question approval is missing`)
if (!entry) continue
check(entry.status === 'APPROVED', `${label}: question status must be APPROVED`)
check(dispositions.has(entry.answerDisposition), `${label}: answerDisposition is invalid`)
if (candidateItem.category === 'NO_EVIDENCE') {
check(
entry.answerDisposition === 'FORMAL_NO_EVIDENCE_BOUNDARY',
`${label}: no-evidence question must preserve the formal refusal boundary`,
)
} else if (candidateItem.category === 'UNAUTHORIZED') {
check(
entry.answerDisposition === 'FORMAL_UNAUTHORIZED_BOUNDARY',
`${label}: unauthorized question must preserve the permission boundary`,
)
} else {
check(entry.answerDisposition === 'FORMALLY_SOURCED', `${label}: answer must be formally sourced`)
}
resolveSourceRefs(entry.sourceRefs, label)
check(nonBlank(entry.expectedBehavior), `${label}: expected behavior is required`)
check(nonBlank(entry.observedBehavior), `${label}: observed behavior is required`)
check(nonBlank(entry.evidenceRef), `${label}: acceptance evidence reference is required`)
check(sha256Pattern.test(entry.evidenceSha256 ?? ''), `${label}: acceptance evidence SHA-256 is required`)
requireReview(entry, label)
}
const requiredSignoffs = [
'businessOwner',
'knowledgeOwner',
'trainingOwner',
'channelOwner',
'releaseOwner',
]
check(
approval.signoffs && typeof approval.signoffs === 'object' && !Array.isArray(approval.signoffs),
'signoffs must be an object',
)
for (const role of requiredSignoffs) {
const signoff = approval.signoffs?.[role]
check(signoff?.status === 'APPROVED', `${role}: sign-off must be APPROVED`)
if (signoff) requireReview(signoff, role)
}
}
if (failures.length > 0) {
for (const failure of failures) console.error(`FAIL: ${failure}`)
process.exit(1)
}
if (scenarioSnapshots) {
for (const [candidateId, candidateItem] of expectedScenarios) {
const entry = scenarioApprovals[candidateId]
console.log([
candidateItem.scenarioDraft.id,
entry.productionContentVersion,
entry.productionContentHash,
].join('|'))
}
} else {
const approvedScenarios = scenarioEntries.filter(([, value]) => value?.status === 'APPROVED').length
const approvedQuestions = questionEntries.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 owner sign-offs: ${approvedSignoffs}/5`)
console.log(`- strict release ready: ${strict ? 'true' : 'not requested'}`)
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FORMAL_VERIFIER="$ROOT_DIR/scripts/verify-aug1-formal-content.mjs"
APPROVAL_PATH="${AIHR_AUG1_APPROVAL_PATH:-$ROOT_DIR/docs/content-candidates/aug1-release-approval.json}"
REMOTE_SSH="${AIHR_AUG1_REMOTE_SSH:-YCWY}"
REMOTE_SERVICE="${AIHR_AUG1_REMOTE_SERVICE:-wygj-aihr.service}"
REMOTE_DB="${AIHR_AUG1_REMOTE_DB:-ry-vue}"
TENANT_ID="${AIHR_AUG1_TENANT_ID:-000000}"
if [[ "${1:-}" != "--execute" || -n "${2:-}" ]]; then
cat <<'USAGE'
Usage: ./scripts/verify-aug1-release-readiness.sh --execute
Runs the final read-only August 1 business release gate. It first requires a
strictly approved formal-content manifest, then verifies that production uses
fixed-code login without real SMS, each direct channel has at least two active
handlers, and the five approved scenario snapshots are published unchanged.
It does not print identities, the fixed code, source content, or access tokens.
USAGE
exit 2
fi
[[ "$REMOTE_DB" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "FAIL: invalid remote database name" >&2
exit 3
}
[[ "$TENANT_ID" =~ ^[A-Za-z0-9_-]{1,20}$ ]] || {
echo "FAIL: invalid tenant ID" >&2
exit 3
}
[[ "$REMOTE_SERVICE" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] || {
echo "FAIL: invalid remote service name" >&2
exit 3
}
mapfile -t scenario_snapshots < <(
node "$FORMAL_VERIFIER" --scenario-snapshots "$APPROVAL_PATH"
)
[[ "${#scenario_snapshots[@]}" -eq 5 ]] || {
echo "FAIL: strict approval did not return exactly five scenario snapshots" >&2
exit 4
}
for snapshot in "${scenario_snapshots[@]}"; do
[[ "$snapshot" =~ ^[a-z0-9][a-z0-9-]{1,79}\|[A-Za-z0-9._-]{1,30}\|[0-9a-f]{64}$ ]] || {
echo "FAIL: strict approval returned an invalid scenario snapshot" >&2
exit 4
}
done
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_SSH" bash -s -- \
"$REMOTE_SERVICE" "$REMOTE_DB" "$TENANT_ID" "${scenario_snapshots[@]}" <<'REMOTE'
set -euo pipefail
service="$1"
db="$2"
tenant="$3"
shift 3
snapshots=("$@")
pid="$(systemctl show -p MainPID --value "$service")"
[[ -n "$pid" && "$pid" != "0" ]] || {
echo "FAIL: production service is not running" >&2
exit 10
}
fixed_code_present="false"
fixed_mode_enabled="false"
while IFS='=' read -r key value; do
case "$key" in
AIHR_SMS_DEV_FIXED_CODE)
[[ -n "$value" ]] && fixed_code_present="true"
;;
AIHR_SMS_PROD_FIXED_CODE_ENABLED)
[[ "${value,,}" == "true" ]] && fixed_mode_enabled="true"
;;
esac
done < <(tr '\0' '\n' < "/proc/$pid/environ")
[[ "$fixed_code_present" == "true" && "$fixed_mode_enabled" == "true" ]] || {
echo "FAIL: production fixed-code mode is not explicitly enabled; real SMS must not be used for this release" >&2
exit 11
}
echo "AUG1_FIXED_CODE_NO_REAL_SMS_MODE=PASS"
required_roles=(
direct_president
direct_finance
direct_hr
direct_audit
direct_operations
)
declare -A handler_counts=()
while IFS=$'\t' read -r role_key handler_count; do
[[ "$role_key" =~ ^direct_(president|finance|hr|audit|operations)$ ]] || {
echo "FAIL: production returned an unexpected direct-channel role" >&2
exit 12
}
[[ "$handler_count" =~ ^[0-9]+$ ]] || {
echo "FAIL: production returned an invalid direct-channel handler count" >&2
exit 12
}
handler_counts["$role_key"]="$handler_count"
done < <(
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<SQL
SELECT required.role_key, COUNT(DISTINCT u.user_id) AS handler_count
FROM (
SELECT 'direct_president' AS role_key
UNION ALL SELECT 'direct_finance'
UNION ALL SELECT 'direct_hr'
UNION ALL SELECT 'direct_audit'
UNION ALL SELECT 'direct_operations'
) required
LEFT JOIN sys_role r
ON r.tenant_id = '$tenant'
AND r.role_key = required.role_key
AND r.status = '0'
AND r.del_flag = '0'
LEFT JOIN sys_user_role ur
ON ur.role_id = r.role_id
LEFT JOIN sys_user u
ON u.tenant_id = r.tenant_id
AND u.user_id = ur.user_id
AND u.status = '0'
AND u.del_flag = '0'
GROUP BY required.role_key
ORDER BY required.role_key;
SQL
)
for role_key in "${required_roles[@]}"; do
handler_count="${handler_counts[$role_key]:-0}"
(( handler_count >= 2 )) || {
echo "FAIL: $role_key requires an active primary handler and backup; got $handler_count/2" >&2
exit 13
}
echo "AUG1_DIRECT_HANDLER_COUNT_${role_key^^}=$handler_count"
done
declare -A expected_versions=()
declare -A expected_hashes=()
scenario_sql_values=""
for snapshot in "${snapshots[@]}"; do
IFS='|' read -r scenario_code content_version content_hash <<<"$snapshot"
[[ "$scenario_code" =~ ^[a-z0-9][a-z0-9-]{1,79}$ ]]
[[ "$content_version" =~ ^[A-Za-z0-9._-]{1,30}$ ]]
[[ "$content_hash" =~ ^[0-9a-f]{64}$ ]]
expected_versions["$scenario_code"]="$content_version"
expected_hashes["$scenario_code"]="$content_hash"
if [[ -n "$scenario_sql_values" ]]; then
scenario_sql_values+=","
fi
scenario_sql_values+="'$scenario_code'"
done
declare -A observed_scenarios=()
while IFS=$'\t' read -r scenario_code content_version content_hash enabled review_status risk_level \
reviewer_user_id reviewed_time second_reviewer_user_id second_reviewed_time sop_refs; do
[[ -n "${expected_versions[$scenario_code]:-}" ]] || {
echo "FAIL: production returned an unexpected August 1 scenario" >&2
exit 14
}
[[ "$content_version" == "${expected_versions[$scenario_code]}" ]] || {
echo "FAIL: $scenario_code production content version does not match the approved snapshot" >&2
exit 15
}
[[ "${content_hash,,}" == "${expected_hashes[$scenario_code]}" ]] || {
echo "FAIL: $scenario_code production content hash does not match the approved snapshot" >&2
exit 15
}
[[ "$enabled" == "1" && "$review_status" == "已发布" ]] || {
echo "FAIL: $scenario_code is not enabled and published" >&2
exit 16
}
[[ "$risk_level" == "常规" || "$risk_level" == "高风险" ]] || {
echo "FAIL: $scenario_code has not completed risk classification" >&2
exit 16
}
[[ "$reviewer_user_id" =~ ^[1-9][0-9]*$ && -n "$reviewed_time" ]] || {
echo "FAIL: $scenario_code is missing first-review evidence" >&2
exit 16
}
if [[ "$risk_level" == "高风险" ]]; then
[[ "$second_reviewer_user_id" =~ ^[1-9][0-9]*$ \
&& "$second_reviewer_user_id" != "$reviewer_user_id" \
&& -n "$second_reviewed_time" ]] || {
echo "FAIL: $scenario_code is high risk but lacks independent second-review evidence" >&2
exit 16
}
fi
[[ -n "$sop_refs" && "$sop_refs" != *"待补"* && "$sop_refs" != *"待定"* ]] || {
echo "FAIL: $scenario_code has no finalized SOP references" >&2
exit 16
}
observed_scenarios["$scenario_code"]="true"
done < <(
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<SQL
SELECT scenario_code, content_version, content_hash, enabled, review_status, risk_level,
COALESCE(reviewer_user_id, 0), COALESCE(DATE_FORMAT(reviewed_time, '%Y-%m-%dT%H:%i:%s'), '-'),
COALESCE(second_reviewer_user_id, 0), COALESCE(DATE_FORMAT(second_reviewed_time, '%Y-%m-%dT%H:%i:%s'), '-'),
COALESCE(NULLIF(sop_refs, ''), '-')
FROM aihr_practice_scenario
WHERE tenant_id = '$tenant'
AND scenario_code IN ($scenario_sql_values)
ORDER BY scenario_code;
SQL
)
for snapshot in "${snapshots[@]}"; do
scenario_code="${snapshot%%|*}"
[[ "${observed_scenarios[$scenario_code]:-false}" == "true" ]] || {
echo "FAIL: approved scenario $scenario_code is missing from production" >&2
exit 17
}
done
echo "AUG1_FORMAL_SCENARIOS=5/5"
echo "AUG1_FORMAL_POLICY_QUESTIONS=30/30"
echo "AUG1_OWNER_SIGNOFFS=5/5"
echo "AUG1_RELEASE_READINESS=PASS"
REMOTE