180 lines
7.7 KiB
JavaScript
180 lines
7.7 KiB
JavaScript
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 };
|
|
}
|