fix(release): enforce August 1 business readiness
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'}`)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user