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
+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