From fee80877de144e38c9c30c13defb0bc9e42d79ae Mon Sep 17 00:00:00 2001 From: key Date: Wed, 29 Jul 2026 12:25:20 +0800 Subject: [PATCH] fix(release): make August 1 handoff reproducible --- docs/DEV_SETUP.md | 2 +- scripts/package-aug1-release.ps1 | 47 ++++++++-- .../tests/aug1-content-candidates.test.mjs | 89 +++++++++++++++++++ scripts/tests/package-aug1-release.test.ps1 | 6 +- scripts/verify-aug1-content-candidates.mjs | 28 +++++- 5 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 scripts/tests/aug1-content-candidates.test.mjs diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index fba50576..d69d2275 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -373,7 +373,7 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep -BackendJar .\backend\ruoyi-admin\target\ruoyi-admin.jar ``` -打包器重新校验冻结证据、APK/JAR/内容哈希和提交祖先关系,按固定顺序与时间戳生成 14 个文件的 ZIP,并附带发布、预检、API 探针、Android 取证及最新 Go/No-Go 的审计快照。ZIP 内 operations 脚本用于交付审计;实际执行仍须从对应 operations commit 的干净仓库运行。 +打包器重新校验冻结证据、APK/JAR/内容哈希和提交祖先关系,按固定顺序与时间戳生成 17 个文件的 ZIP,并附带发布、预检、无登录 API 探针、固定码认证复验、内容候选校验、业务与五通道签认单、Android 取证及最新 Go/No-Go 的审计快照。ZIP 内 operations 脚本用于交付审计;实际执行仍须从对应 operations commit 的干净仓库运行。固定码认证脚本只有显式传入 `--execute` 才运行,沿用生产现有固定码配置,不启用或发送真实短信,也不提交业务记录。 ## MVP 页面验证 diff --git a/scripts/package-aug1-release.ps1 b/scripts/package-aug1-release.ps1 index 19da1857..89f0b456 100644 --- a/scripts/package-aug1-release.ps1 +++ b/scripts/package-aug1-release.ps1 @@ -122,12 +122,30 @@ $goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') if ($goNoGoCandidates.Count -ne 1) { throw "Expected one Android Go/No-Go document, found $($goNoGoCandidates.Count)." } +$businessSignoffCandidates = @( + Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*20260729.md' | + Where-Object { + $candidateText = Get-Content -Raw -Encoding UTF8 -LiteralPath $_.FullName + $candidateText.Contains('direct_president') -and + $candidateText.Contains('direct_finance') -and + $candidateText.Contains('direct_hr') -and + $candidateText.Contains('direct_audit') -and + $candidateText.Contains('direct_operations') + } +) +if ($businessSignoffCandidates.Count -ne 1) { + throw "Expected one August 1 business-content and five-channel sign-off document, found $($businessSignoffCandidates.Count)." +} +$businessSignoffPath = $businessSignoffCandidates[0].FullName $operationSources = [ordered]@{ 'operations/release-backend.sh' = Join-Path $projectRoot 'scripts\release-backend.sh' 'operations/release-preflight.sh' = Join-Path $projectRoot 'scripts\release-preflight.sh' '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/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 } foreach ($source in $operationSources.Values) { Require-File $source @@ -147,7 +165,7 @@ Write-Output "apk_sha256=$apkSha256" Write-Output "backend_sha256=$backendSha256" Write-Output "content_sha256=$contentSha256" Write-Output "package_path=$packagePath" -Write-Output 'package_entries=14' +Write-Output 'package_entries=17' if ($PlanOnly) { exit 0 } @@ -207,6 +225,10 @@ try { '', 'Run the GET-only API probe before and after release, and capture read-only Android evidence after each manual step. Neither replaces Android device acceptance or formal business sign-off.', '', + '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.', + '', + '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.' ) -join [Environment]::NewLine Write-Utf8 -Path (Join-Path $stagingDirectory 'README.md') -Content ($readme + [Environment]::NewLine) @@ -238,7 +260,9 @@ try { '- Formally publishable scenarios: 0', '- Formally sourced standard answers: 0', '', - 'Candidates are for business review only. Do not import, enable or count them toward August 1 acceptance until formal sources and item-level sign-off are complete.' + 'Candidates are for business review only. Do not import, enable or count them toward August 1 acceptance until formal sources and item-level sign-off are complete.', + '', + '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.' ) -join [Environment]::NewLine Write-Utf8 -Path (Join-Path $stagingDirectory 'content-review-readme.md') -Content ($contentReview + [Environment]::NewLine) @@ -307,8 +331,8 @@ try { $allFiles = Get-ChildItem -LiteralPath $stagingDirectory -Recurse -File | Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) } - if ($allFiles.Count -ne 14) { - throw "Release package expected 14 files, found $($allFiles.Count)." + if ($allFiles.Count -ne 17) { + throw "Release package expected 17 files, found $($allFiles.Count)." } foreach ($file in $allFiles) { $file.LastWriteTimeUtc = $entryTimestamp.UtcDateTime @@ -345,11 +369,20 @@ try { $verificationArchive = [IO.Compression.ZipFile]::OpenRead($partialPackagePath) try { - if ($verificationArchive.Entries.Count -ne 14) { - throw "ZIP verification expected 14 entries, found $($verificationArchive.Entries.Count)." + if ($verificationArchive.Entries.Count -ne 17) { + throw "ZIP verification expected 17 entries, found $($verificationArchive.Entries.Count)." } $entryNames = @($verificationArchive.Entries | ForEach-Object { $_.FullName }) - foreach ($requiredEntry in @($apkName, $backendName, 'release-manifest.json', 'SHA256SUMS.txt', 'operations/release-backend.sh')) { + foreach ($requiredEntry in @( + $apkName, + $backendName, + 'release-manifest.json', + 'SHA256SUMS.txt', + 'operations/release-backend.sh', + 'operations/verify-aug1-authenticated-production.sh', + 'operations/verify-aug1-content-candidates.mjs', + 'operations/business-content-and-five-channel-signoff.md' + )) { if ($entryNames -notcontains $requiredEntry) { throw "ZIP verification is missing $requiredEntry" } diff --git a/scripts/tests/aug1-content-candidates.test.mjs b/scripts/tests/aug1-content-candidates.test.mjs new file mode 100644 index 00000000..6b3ea442 --- /dev/null +++ b/scripts/tests/aug1-content-candidates.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +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-content-candidates.mjs') +const candidatePath = path.join( + projectRoot, + 'docs', + 'content-candidates', + 'aug1-life-advisor-content-candidates-v0.1.json', +) +const baseCandidate = JSON.parse(await readFile(candidatePath, 'utf8')) + +const cloneCandidate = () => structuredClone(baseCandidate) + +async function verifyCandidate(candidate) { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'aug1-content-')) + const fixturePath = path.join(tempDir, 'candidate.json') + try { + await writeFile(fixturePath, JSON.stringify(candidate), 'utf8') + return spawnSync(process.execPath, [verifierPath, fixturePath], { + cwd: projectRoot, + encoding: 'utf8', + }) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +} + +test('accepts the reviewed disabled candidate structure through an explicit package path', async () => { + const result = await verifyCandidate(cloneCandidate()) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /verification passed/) +}) + +test('rejects sensitive locators, credentials and mobile numbers', async (t) => { + const cases = [ + ['precise room', (candidate) => { + candidate.scenarios[0].scenarioDraft.openingText = '住户住在3栋2单元501室。' + }], + ['access credential', (candidate) => { + candidate.scenarios[0].scenarioDraft.openingText = '门禁口令 ABCD。' + }], + ['mobile phone', (candidate) => { + candidate.policyQuestionCandidates[0].question = ['联系号码', '13', '9000', '00000'].join('') + }], + ] + + for (const [name, mutate] of cases) { + await t.test(name, async () => { + const candidate = cloneCandidate() + mutate(candidate) + const result = await verifyCandidate(candidate) + assert.notEqual(result.status, 0) + }) + } +}) + +test('rejects publishable content, answers and duplicate IDs before formal sign-off', async (t) => { + const cases = [ + ['publishable root', (candidate) => { + candidate.publishable = true + }], + ['embedded answer', (candidate) => { + candidate.policyQuestionCandidates[0].answer = 'not allowed before formal sourcing' + }], + ['duplicate scenario ID', (candidate) => { + candidate.scenarios[1].candidateId = candidate.scenarios[0].candidateId + }], + ['duplicate question ID', (candidate) => { + candidate.policyQuestionCandidates[1].id = candidate.policyQuestionCandidates[0].id + }], + ] + + for (const [name, mutate] of cases) { + await t.test(name, async () => { + const candidate = cloneCandidate() + mutate(candidate) + const result = await verifyCandidate(candidate) + assert.notEqual(result.status, 0) + }) + } +}) diff --git a/scripts/tests/package-aug1-release.test.ps1 b/scripts/tests/package-aug1-release.test.ps1 index 2f31c574..349724ad 100644 --- a/scripts/tests/package-aug1-release.test.ps1 +++ b/scripts/tests/package-aug1-release.test.ps1 @@ -15,10 +15,14 @@ $requiredFragments = @( "'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/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 14 files', + 'Release package expected 17 files', + 'ZIP verification expected 17 entries', 'ZIP entry hash mismatch', 'package_verified=true', 'authenticatedFixedCodeSmokePassed = $true', diff --git a/scripts/verify-aug1-content-candidates.mjs b/scripts/verify-aug1-content-candidates.mjs index 8bfce8c1..0d5b2088 100644 --- a/scripts/verify-aug1-content-candidates.mjs +++ b/scripts/verify-aug1-content-candidates.mjs @@ -4,12 +4,19 @@ import path from 'node:path' const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(scriptDir, '..') -const candidatePath = path.join( +const defaultCandidatePath = path.join( rootDir, 'docs', 'content-candidates', 'aug1-life-advisor-content-candidates-v0.1.json', ) +if (process.argv.length > 3) { + console.error('Usage: node verify-aug1-content-candidates.mjs [candidate-json]') + process.exit(2) +} +const candidatePath = process.argv[2] + ? path.resolve(process.argv[2]) + : defaultCandidatePath const content = JSON.parse(await readFile(candidatePath, 'utf8')) const failures = [] @@ -38,12 +45,14 @@ const credentialPattern = /(?:门禁|家门|开门|访问)\s*(?:密码|口令| check(content.candidateStatus === 'PENDING_BUSINESS_REVIEW', 'root status must remain pending review') check(content.publishable === false, 'root publishable must be false') +check(Boolean(content.contentVersion?.trim()), 'content version is required') check(Array.isArray(content.scenarios) && content.scenarios.length === 5, 'exactly five scenario candidates are required') check( Array.isArray(content.policyQuestionCandidates) && content.policyQuestionCandidates.length === 30, 'exactly thirty policy question candidates are required', ) +const scenarioIds = new Set() for (const scenario of content.scenarios ?? []) { const label = scenario.candidateId ?? 'unknown scenario' const draft = scenario.scenarioDraft ?? {} @@ -61,13 +70,20 @@ for (const scenario of content.scenarios ?? []) { draft.goal, ] + check(Boolean(scenario.candidateId?.trim()), `${label}: candidate ID is required`) + check(!scenarioIds.has(scenario.candidateId), `${label}: candidate ID is duplicated`) + scenarioIds.add(scenario.candidateId) check(scenario.candidateStatus === 'PENDING_BUSINESS_REVIEW', `${label}: status must remain pending review`) check(scenario.publishable === false, `${label}: publishable must be false`) check(draft.enabled === false, `${label}: scenario must be disabled`) check(draft.riskLevel === '待评估', `${label}: risk must remain pending until business review`) check(rubric.enabled === false, `${label}: rubric must be disabled`) check(placeholderPattern.test(draft.sopRefs ?? ''), `${label}: missing-source marker must remain explicit`) - check((scenario.sourceRefs ?? []).length >= 2, `${label}: at least two source references are required`) + check( + (scenario.sourceRefs ?? []).length >= 2 + && scenario.sourceRefs.every((item) => typeof item === 'string' && item.trim()), + `${label}: at least two source references are required`, + ) check((scenario.requiredReviewers ?? []).length >= 2, `${label}: at least two reviewer roles are required`) check((scenario.reviewChecklist ?? []).length >= 4, `${label}: review checklist is incomplete`) check(requiredText.every((value) => typeof value === 'string' && value.trim()), `${label}: required scenario fields are incomplete`) @@ -93,14 +109,22 @@ for (const scenario of content.scenarios ?? []) { } const categoryCounts = new Map() +const questionIds = new Set() for (const question of content.policyQuestionCandidates ?? []) { const label = question.id ?? 'unknown question' categoryCounts.set(question.category, (categoryCounts.get(question.category) ?? 0) + 1) + check(Boolean(question.id?.trim()), `${label}: question ID is required`) + check(!questionIds.has(question.id), `${label}: question ID is duplicated`) + questionIds.add(question.id) check(question.status === 'PENDING_REVIEW', `${label}: status must remain pending review`) check(Boolean(question.question?.trim()), `${label}: question is missing`) check(Boolean(question.sourceNeed?.trim()), `${label}: source requirement is missing`) check(Boolean(question.expectedBehavior?.trim()), `${label}: expected boundary behavior is missing`) check(Boolean(question.reviewerRole?.trim()), `${label}: reviewer role is missing`) + check( + ['NEEDS_FORMAL_SOURCE', 'BOUNDARY_EXPECTATION_DEFINED'].includes(question.answerStatus), + `${label}: answer status is invalid`, + ) check(!Object.hasOwn(question, 'answer'), `${label}: answer must not exist before formal sourcing`) check(!Object.hasOwn(question, 'standardAnswer'), `${label}: standardAnswer must not exist before formal sourcing`) }