fix(release): make August 1 handoff reproducible

This commit is contained in:
key
2026-07-29 12:25:20 +08:00
parent 6e2b6025c1
commit fee80877de
5 changed files with 161 additions and 11 deletions
+1 -1
View File
@@ -373,7 +373,7 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep
-BackendJar .\backend\ruoyi-admin\target\ruoyi-admin.jar -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 页面验证 ## MVP 页面验证
+40 -7
View File
@@ -122,12 +122,30 @@ $goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs')
if ($goNoGoCandidates.Count -ne 1) { if ($goNoGoCandidates.Count -ne 1) {
throw "Expected one Android Go/No-Go document, found $($goNoGoCandidates.Count)." 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]@{ $operationSources = [ordered]@{
'operations/release-backend.sh' = Join-Path $projectRoot 'scripts\release-backend.sh' 'operations/release-backend.sh' = Join-Path $projectRoot 'scripts\release-backend.sh'
'operations/release-preflight.sh' = Join-Path $projectRoot 'scripts\release-preflight.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-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/capture-android-acceptance.ps1' = Join-Path $projectRoot 'scripts\capture-android-acceptance.ps1'
'operations/go-no-go.md' = $goNoGoCandidates[0].FullName 'operations/go-no-go.md' = $goNoGoCandidates[0].FullName
'operations/business-content-and-five-channel-signoff.md' = $businessSignoffPath
} }
foreach ($source in $operationSources.Values) { foreach ($source in $operationSources.Values) {
Require-File $source Require-File $source
@@ -147,7 +165,7 @@ Write-Output "apk_sha256=$apkSha256"
Write-Output "backend_sha256=$backendSha256" Write-Output "backend_sha256=$backendSha256"
Write-Output "content_sha256=$contentSha256" Write-Output "content_sha256=$contentSha256"
Write-Output "package_path=$packagePath" Write-Output "package_path=$packagePath"
Write-Output 'package_entries=14' Write-Output 'package_entries=17'
if ($PlanOnly) { if ($PlanOnly) {
exit 0 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.', '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.' 'This package does not authorize production deployment, service restart, database change, Git push or public distribution.'
) -join [Environment]::NewLine ) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'README.md') -Content ($readme + [Environment]::NewLine) Write-Utf8 -Path (Join-Path $stagingDirectory 'README.md') -Content ($readme + [Environment]::NewLine)
@@ -238,7 +260,9 @@ try {
'- Formally publishable scenarios: 0', '- Formally publishable scenarios: 0',
'- Formally sourced standard answers: 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 ) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'content-review-readme.md') -Content ($contentReview + [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 | $allFiles = Get-ChildItem -LiteralPath $stagingDirectory -Recurse -File |
Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) } Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) }
if ($allFiles.Count -ne 14) { if ($allFiles.Count -ne 17) {
throw "Release package expected 14 files, found $($allFiles.Count)." throw "Release package expected 17 files, found $($allFiles.Count)."
} }
foreach ($file in $allFiles) { foreach ($file in $allFiles) {
$file.LastWriteTimeUtc = $entryTimestamp.UtcDateTime $file.LastWriteTimeUtc = $entryTimestamp.UtcDateTime
@@ -345,11 +369,20 @@ try {
$verificationArchive = [IO.Compression.ZipFile]::OpenRead($partialPackagePath) $verificationArchive = [IO.Compression.ZipFile]::OpenRead($partialPackagePath)
try { try {
if ($verificationArchive.Entries.Count -ne 14) { if ($verificationArchive.Entries.Count -ne 17) {
throw "ZIP verification expected 14 entries, found $($verificationArchive.Entries.Count)." throw "ZIP verification expected 17 entries, found $($verificationArchive.Entries.Count)."
} }
$entryNames = @($verificationArchive.Entries | ForEach-Object { $_.FullName }) $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) { if ($entryNames -notcontains $requiredEntry) {
throw "ZIP verification is missing $requiredEntry" throw "ZIP verification is missing $requiredEntry"
} }
@@ -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)
})
}
})
+5 -1
View File
@@ -15,10 +15,14 @@ $requiredFragments = @(
"'operations/release-backend.sh'", "'operations/release-backend.sh'",
"'operations/release-preflight.sh'", "'operations/release-preflight.sh'",
"'operations/verify-aug1-production-api-readonly.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/capture-android-acceptance.ps1'",
"'operations/go-no-go.md'", "'operations/go-no-go.md'",
"'operations/business-content-and-five-channel-signoff.md'",
'Release package already exists and will not be overwritten', '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', 'ZIP entry hash mismatch',
'package_verified=true', 'package_verified=true',
'authenticatedFixedCodeSmokePassed = $true', 'authenticatedFixedCodeSmokePassed = $true',
+26 -2
View File
@@ -4,12 +4,19 @@ import path from 'node:path'
const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(scriptDir, '..') const rootDir = path.resolve(scriptDir, '..')
const candidatePath = path.join( const defaultCandidatePath = path.join(
rootDir, rootDir,
'docs', 'docs',
'content-candidates', 'content-candidates',
'aug1-life-advisor-content-candidates-v0.1.json', '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 content = JSON.parse(await readFile(candidatePath, 'utf8'))
const failures = [] const failures = []
@@ -38,12 +45,14 @@ const credentialPattern = /(?:门禁|家门|开门|访问)\s*(?:密码|口令|
check(content.candidateStatus === 'PENDING_BUSINESS_REVIEW', 'root status must remain pending review') check(content.candidateStatus === 'PENDING_BUSINESS_REVIEW', 'root status must remain pending review')
check(content.publishable === false, 'root publishable must be false') 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.scenarios) && content.scenarios.length === 5, 'exactly five scenario candidates are required')
check( check(
Array.isArray(content.policyQuestionCandidates) && content.policyQuestionCandidates.length === 30, Array.isArray(content.policyQuestionCandidates) && content.policyQuestionCandidates.length === 30,
'exactly thirty policy question candidates are required', 'exactly thirty policy question candidates are required',
) )
const scenarioIds = new Set()
for (const scenario of content.scenarios ?? []) { for (const scenario of content.scenarios ?? []) {
const label = scenario.candidateId ?? 'unknown scenario' const label = scenario.candidateId ?? 'unknown scenario'
const draft = scenario.scenarioDraft ?? {} const draft = scenario.scenarioDraft ?? {}
@@ -61,13 +70,20 @@ for (const scenario of content.scenarios ?? []) {
draft.goal, 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.candidateStatus === 'PENDING_BUSINESS_REVIEW', `${label}: status must remain pending review`)
check(scenario.publishable === false, `${label}: publishable must be false`) check(scenario.publishable === false, `${label}: publishable must be false`)
check(draft.enabled === false, `${label}: scenario must be disabled`) check(draft.enabled === false, `${label}: scenario must be disabled`)
check(draft.riskLevel === '待评估', `${label}: risk must remain pending until business review`) check(draft.riskLevel === '待评估', `${label}: risk must remain pending until business review`)
check(rubric.enabled === false, `${label}: rubric must be disabled`) check(rubric.enabled === false, `${label}: rubric must be disabled`)
check(placeholderPattern.test(draft.sopRefs ?? ''), `${label}: missing-source marker must remain explicit`) 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.requiredReviewers ?? []).length >= 2, `${label}: at least two reviewer roles are required`)
check((scenario.reviewChecklist ?? []).length >= 4, `${label}: review checklist is incomplete`) 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`) 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 categoryCounts = new Map()
const questionIds = new Set()
for (const question of content.policyQuestionCandidates ?? []) { for (const question of content.policyQuestionCandidates ?? []) {
const label = question.id ?? 'unknown question' const label = question.id ?? 'unknown question'
categoryCounts.set(question.category, (categoryCounts.get(question.category) ?? 0) + 1) 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(question.status === 'PENDING_REVIEW', `${label}: status must remain pending review`)
check(Boolean(question.question?.trim()), `${label}: question is missing`) check(Boolean(question.question?.trim()), `${label}: question is missing`)
check(Boolean(question.sourceNeed?.trim()), `${label}: source requirement 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.expectedBehavior?.trim()), `${label}: expected boundary behavior is missing`)
check(Boolean(question.reviewerRole?.trim()), `${label}: reviewer role 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, 'answer'), `${label}: answer must not exist before formal sourcing`)
check(!Object.hasOwn(question, 'standardAnswer'), `${label}: standardAnswer must not exist before formal sourcing`) check(!Object.hasOwn(question, 'standardAnswer'), `${label}: standardAnswer must not exist before formal sourcing`)
} }