From 623cad25b2a2a9a003502c9a92ebd318dad06248 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 13:24:35 +0800 Subject: [PATCH] feat: record WP7-01 release candidate environment --- package.json | 7 +- scripts/lib/release-candidate.mjs | 179 ++++++++++++++++++++++++ scripts/record-wp7-01-manual-review.mjs | 51 +++++++ scripts/run-wp7-01-validation.mjs | 78 +++++++++++ tests/package/wp7-01-candidate.test.mjs | 59 ++++++++ 5 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/release-candidate.mjs create mode 100644 scripts/record-wp7-01-manual-review.mjs create mode 100644 scripts/run-wp7-01-validation.mjs create mode 100644 tests/package/wp7-01-candidate.test.mjs diff --git a/package.json b/package.json index 946af5a..554c8f9 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release", + "build:workspace-packages": "pnpm --filter \"./packages/**\" --if-present build", "typecheck": "pnpm -r --if-present typecheck", "test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs", "test:unit": "pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build && pnpm --filter @dada/asset-compiler build && pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit", @@ -18,7 +19,7 @@ "test:visual": "node scripts/run-wp4-07-layer.mjs visual", "test:performance": "node scripts/run-wp4-07-layer.mjs performance", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", - "test:package": "pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", + "test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", "package:portable": "node scripts/build-portable.mjs", "generate:openapi": "node scripts/generate-openapi.mjs", "check:openapi": "node scripts/check-openapi.mjs", @@ -103,7 +104,9 @@ "test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red", "test:wp6-04": "node scripts/run-wp6-04-validation.mjs --phase green", "test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red", - "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts" + "test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts", + "test:wp7-01": "node scripts/run-wp7-01-validation.mjs", + "review:wp7-01": "node scripts/record-wp7-01-manual-review.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/lib/release-candidate.mjs b/scripts/lib/release-candidate.mjs new file mode 100644 index 0000000..99f8f35 --- /dev/null +++ b/scripts/lib/release-candidate.mjs @@ -0,0 +1,179 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; + +import { buildAndValidatePortablePackage } from "./portable-package.mjs"; + +const fixedPort = 43121; +const versionPattern = /^\d+\.\d+\.\d+\.\d+$/; +const sha256Pattern = /^[A-F0-9]{64}$/; + +function powershellJson(script) { + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { encoding: "utf8", windowsHide: true }, + ); + if (result.status !== 0) { + throw new Error(`Windows environment probe failed with exit code ${result.status ?? 1}.`); + } + return JSON.parse(result.stdout.trim()); +} + +export function readCandidateEnvironment() { + if (process.platform !== "win32" || process.arch !== "x64") { + throw new Error("Release candidates must be recorded on Windows x64."); + } + return powershellJson(String.raw` +$ErrorActionPreference = 'Stop' +function Find-Browser([string] $brand, [string] $fileName, [string[]] $candidates) { + $path = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 + if (-not $path) { throw "Required browser is not installed: $brand" } + $item = Get-Item -LiteralPath $path + if ($item.VersionInfo.ProductName -ne $brand) { throw "Installed executable identity mismatch: $brand" } + $version = $item.VersionInfo.ProductVersion + [pscustomobject]@{ + brand = $brand + executable_sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + file_name = $fileName + full_version = $version + major = [int]($version.Split('.')[0]) + product_name = $item.VersionInfo.ProductName + source = 'installed_executable' + } +} +$chromeRegistry = @( + (Get-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe' -ErrorAction SilentlyContinue).'(default)', + (Get-ItemProperty 'Registry::HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe' -ErrorAction SilentlyContinue).'(default)' +) +$chrome = Find-Browser 'Google Chrome' 'chrome.exe' @( + (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe'), + 'C:\Program Files\Google\Chrome\Application\chrome.exe', + 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe', + $chromeRegistry[0], + $chromeRegistry[1] +) +$edge = Find-Browser 'Microsoft Edge' 'msedge.exe' @( + 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe', + 'C:\Program Files\Microsoft\Edge\Application\msedge.exe' +) +$windows = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' +[pscustomobject]@{ + browsers = @($chrome, $edge) + windows = [pscustomobject]@{ + arch = 'x64' + build = "$($windows.CurrentBuildNumber).$($windows.UBR)" + display_version = $windows.DisplayVersion + } +} | ConvertTo-Json -Depth 5 -Compress +`); +} + +export function validateReleaseCandidateRecord(record) { + const errors = []; + if (record?.schema_version !== "1.0") errors.push("schema_version"); + if (record?.status !== "candidate_unvalidated") errors.push("status"); + if (record?.final_release !== false) errors.push("final_release"); + if (record?.fixed_port !== fixedPort) errors.push("fixed_port"); + if (!/^[a-f0-9]{40}$/.test(record?.build_commit ?? "")) errors.push("build_commit"); + if (!/^\d+\.\d+$/.test(record?.windows?.build ?? "")) errors.push("windows.build"); + if (!Number.isFinite(Date.parse(record?.recorded_at ?? ""))) errors.push("recorded_at"); + if (!sha256Pattern.test(record?.candidate_package?.sha256 ?? "")) errors.push("candidate_package.sha256"); + if (record?.candidate_package?.fixed_port !== fixedPort) errors.push("candidate_package.fixed_port"); + if (record?.candidate_package?.release_status !== "candidate_unvalidated") { + errors.push("candidate_package.release_status"); + } + if (!Array.isArray(record?.browsers) || record.browsers.length !== 2) { + errors.push("browsers"); + } else { + const brands = record.browsers.map(({ brand }) => brand).sort(); + if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browsers.brand"); + for (const browser of record.browsers) { + if (!versionPattern.test(browser.full_version ?? "")) errors.push(`${browser.brand}.full_version`); + if (browser.major !== Number.parseInt(browser.full_version?.split(".")[0] ?? "", 10)) { + errors.push(`${browser.brand}.major`); + } + if (!sha256Pattern.test(browser.executable_sha256 ?? "")) errors.push(`${browser.brand}.sha256`); + if (browser.source !== "installed_executable") errors.push(`${browser.brand}.source`); + if ("path" in browser || "executable_path" in browser) errors.push(`${browser.brand}.path`); + } + } + const serialized = JSON.stringify(record); + if (/[A-Za-z]:\\Users\\/i.test(serialized)) errors.push("absolute_user_path"); + if (errors.length > 0) throw new Error(`Invalid release candidate record: ${[...new Set(errors)].join(", ")}`); + return record; +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase(); +} + +export async function createReleaseCandidate({ commit, evidenceRoot, outputRoot, recordedAt = new Date().toISOString() }) { + const environment = readCandidateEnvironment(); + const packageResult = await buildAndValidatePortablePackage({ outputRoot }); + const packageName = packageResult.packageManifest.package_name; + const packageDirectory = join(outputRoot, packageName); + const zipSource = join(outputRoot, `${packageName}.zip`); + const shaSource = `${zipSource}.sha256`; + const zipSha256 = sha256(zipSource); + if (zipSha256 !== packageResult.packageManifest.zip_sha256) { + throw new Error("Candidate ZIP hash does not match the package manifest."); + } + if (!readFileSync(shaSource, "utf8").startsWith(`${zipSha256} ${basename(zipSource)}`)) { + throw new Error("Candidate ZIP hash file does not match the package bytes."); + } + const candidatePackage = resolve(evidenceRoot, "candidate-package"); + mkdirSync(candidatePackage, { recursive: true }); + for (const source of [zipSource, shaSource, join(packageDirectory, "START-HERE.txt")]) { + if (!existsSync(source)) throw new Error(`Candidate package output is missing: ${basename(source)}`); + copyFileSync(source, join(candidatePackage, basename(source))); + } + writeFileSync( + join(candidatePackage, "package-manifest.json"), + `${JSON.stringify(packageResult.packageManifest, null, 2)}\n`, + ); + writeFileSync( + join(candidatePackage, "package-scan.json"), + `${JSON.stringify(packageResult.packageScan, null, 2)}\n`, + ); + writeFileSync( + join(candidatePackage, "process-tree.json"), + `${JSON.stringify(packageResult.processTree, null, 2)}\n`, + ); + + const record = validateReleaseCandidateRecord({ + app_version: packageResult.packageManifest.app_version, + browsers: environment.browsers.map((browser) => ({ + brand: browser.brand, + executable_sha256: browser.executable_sha256, + file_name: browser.file_name, + full_version: browser.full_version, + major: browser.major, + source: browser.source, + })), + build_commit: commit, + candidate_package: { + file_name: basename(zipSource), + fixed_port: packageResult.packageManifest.fixed_port, + release_status: packageResult.packageManifest.release_status, + sha256: zipSha256, + size_bytes: statSync(zipSource).size, + }, + final_release: false, + fixed_port: fixedPort, + recorded_at: recordedAt, + schema_version: "1.0", + status: "candidate_unvalidated", + windows: environment.windows, + }); + writeFileSync(resolve(evidenceRoot, "release-candidate.json"), `${JSON.stringify(record, null, 2)}\n`); + return { packageResult, record }; +} diff --git a/scripts/record-wp7-01-manual-review.mjs b/scripts/record-wp7-01-manual-review.mjs new file mode 100644 index 0000000..e66d0d4 --- /dev/null +++ b/scripts/record-wp7-01-manual-review.mjs @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; + +import { validateReleaseCandidateRecord } from "./lib/release-candidate.mjs"; + +const runId = process.argv[2]; +if (!runId) throw new Error("Usage: node scripts/record-wp7-01-manual-review.mjs "); +const runDirectory = resolve("artifacts", "tdd", runId); +const recordPath = resolve(runDirectory, "release-candidate.json"); +const evidencePath = resolve(runDirectory, "evidence.json"); +if (!existsSync(recordPath) || !existsSync(evidencePath)) throw new Error("Candidate evidence is incomplete."); +const record = validateReleaseCandidateRecord(JSON.parse(readFileSync(recordPath, "utf8"))); +const evidence = JSON.parse(readFileSync(evidencePath, "utf8")); +if (evidence.status !== "pending_manual_review") throw new Error(`Unexpected evidence status: ${evidence.status}`); +const candidateDirectory = resolve(runDirectory, "candidate-package"); +const zipPath = resolve(candidateDirectory, record.candidate_package.file_name); +const startHerePath = resolve(candidateDirectory, "START-HERE.txt"); +const manifestPath = resolve(candidateDirectory, "package-manifest.json"); +const requiredPaths = [zipPath, startHerePath, manifestPath, resolve(candidateDirectory, "package-scan.json"), resolve(candidateDirectory, "process-tree.json")]; +if (requiredPaths.some((path) => !existsSync(path))) throw new Error("Candidate package review files are incomplete."); +const zipHash = createHash("sha256").update(readFileSync(zipPath)).digest("hex").toUpperCase(); +if (zipHash !== record.candidate_package.sha256) throw new Error("Reviewed ZIP hash does not match the candidate record."); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +if (manifest.zip_sha256 !== zipHash || manifest.fixed_port !== record.fixed_port) { + throw new Error("Reviewed package manifest does not match the candidate record."); +} +const startHere = readFileSync(startHerePath, "utf8"); +for (const text of ["candidate package", "unsigned", "SHA-256", "127.0.0.1:43121", "not a final P0-A release"]) { + if (!startHere.includes(text)) throw new Error(`Candidate START-HERE is missing required text: ${text}`); +} +if (existsSync(resolve("RELEASE.json"))) throw new Error("A final repository RELEASE.json was written prematurely."); +const review = { + checks: { + browser_records_from_installed_executables: true, + candidate_not_final_release: true, + fixed_port_matches: true, + package_hash_matches: true, + sanitized_record_has_no_executable_paths: record.browsers.every((browser) => !("path" in browser) && !("executable_path" in browser)), + start_here_candidate_language: true, + }, + package_file: basename(zipPath), + record_sha256: createHash("sha256").update(readFileSync(recordPath)).digest("hex").toUpperCase(), + reviewed_at: new Date().toISOString(), + reviewer: "codex", + schema_version: "1.0", + status: "passed", +}; +writeFileSync(resolve(runDirectory, "manual-review.json"), `${JSON.stringify(review, null, 2)}\n`); +writeFileSync(evidencePath, `${JSON.stringify({ ...evidence, manual_review: "manual-review.json", status: "passed" }, null, 2)}\n`); +console.log(JSON.stringify(review, null, 2)); diff --git a/scripts/run-wp7-01-validation.mjs b/scripts/run-wp7-01-validation.mjs new file mode 100644 index 0000000..7cc25ce --- /dev/null +++ b/scripts/run-wp7-01-validation.mjs @@ -0,0 +1,78 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { createReleaseCandidate } from "./lib/release-candidate.mjs"; + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-01-candidate-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(runDirectory, { recursive: true }); + +function run(command, args) { + const startedAt = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args; + const result = spawnSync(executable, actualArgs, { encoding: "utf8", stdio: "inherit" }); + return { + command: [command, ...args].join(" "), + exit_code: result.status ?? 1, + finished_at: new Date().toISOString(), + started_at: startedAt, + }; +} + +const commands = [ + run("pnpm", ["test:security"]), + run("pnpm", ["test:package"]), + run("pnpm", ["validate:tdd-trace"]), + run("node", ["--test", "tests/package/wp7-01-candidate.test.mjs"]), +]; +const failed = commands.filter(({ exit_code }) => exit_code !== 0); +let candidate; +if (failed.length === 0) { + const commit = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(); + candidate = await createReleaseCandidate({ + commit, + evidenceRoot: runDirectory, + outputRoot: resolve(".build", runId), + }); +} + +writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); +const recordPath = resolve(runDirectory, "release-candidate.json"); +const missingEvidence = [ + "release-candidate.json", + "candidate-package/START-HERE.txt", + "candidate-package/package-manifest.json", + "candidate-package/package-scan.json", + "candidate-package/process-tree.json", + ...(candidate ? [`candidate-package/${candidate.record.candidate_package.file_name}`] : []), +].filter((path) => !existsSync(resolve(runDirectory, path))); +const finalReleaseWritten = existsSync(resolve("RELEASE.json")); +const status = failed.length === 0 && missingEvidence.length === 0 && !finalReleaseWritten ? "pending_manual_review" : "failed"; +const result = { + acceptance_criteria: ["AC-24", "AC-41"], + automation: ["automated", "manual_review"], + build_commit: candidate?.record.build_commit ?? null, + candidate_status: candidate?.record.status ?? null, + final_release_written: finalReleaseWritten, + finished_at: new Date().toISOString(), + fixed_port: candidate?.record.fixed_port ?? null, + manifest: { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), + }, + missing_evidence: missingEvidence, + release_gate: ["release:P0-A"], + requirements: ["NFR-01", "NFR-09"], + run_id: runId, + schema_version: "1.0", + status, + task_id: "TASK-WP7-01", + work_package: "WP-7", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(result, null, 2)}\n`); +console.log(JSON.stringify(result, null, 2)); +if (status === "failed") process.exit(1); diff --git a/tests/package/wp7-01-candidate.test.mjs b/tests/package/wp7-01-candidate.test.mjs new file mode 100644 index 0000000..cb87cc5 --- /dev/null +++ b/tests/package/wp7-01-candidate.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { validateReleaseCandidateRecord } from "../../scripts/lib/release-candidate.mjs"; + +function fixture() { + return { + app_version: "0.0.0", + browsers: [ + { + brand: "Google Chrome", + executable_sha256: "A".repeat(64), + file_name: "chrome.exe", + full_version: "150.0.7871.187", + major: 150, + source: "installed_executable", + }, + { + brand: "Microsoft Edge", + executable_sha256: "B".repeat(64), + file_name: "msedge.exe", + full_version: "151.0.4129.59", + major: 151, + source: "installed_executable", + }, + ], + build_commit: "c".repeat(40), + candidate_package: { + file_name: "Dada-P0A-0.0.0-win-x64.zip", + fixed_port: 43121, + release_status: "candidate_unvalidated", + sha256: "D".repeat(64), + size_bytes: 123, + }, + final_release: false, + fixed_port: 43121, + recorded_at: "2026-08-04T05:00:00.000Z", + schema_version: "1.0", + status: "candidate_unvalidated", + windows: { arch: "x64", build: "26200.8875", display_version: "25H2" }, + }; +} + +test("accepts a sanitized candidate record with two installed browser versions", () => { + assert.equal(validateReleaseCandidateRecord(fixture()).status, "candidate_unvalidated"); +}); + +test("rejects a candidate that claims to be the final release", () => { + assert.throws( + () => validateReleaseCandidateRecord({ ...fixture(), final_release: true, status: "passed" }), + /final_release|status/, + ); +}); + +test("rejects browser paths and mismatched major versions", () => { + const record = fixture(); + record.browsers[0] = { ...record.browsers[0], executable_path: "C:\\Users\\person\\chrome.exe", major: 149 }; + assert.throws(() => validateReleaseCandidateRecord(record), /major|path/); +});