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
+40 -7
View File
@@ -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"
}
@@ -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-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',
+26 -2
View File
@@ -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`)
}