Files
RemLink/scripts/validation/Test-ReleasePackage.ps1
qsc20001102 142e5dc7d6
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled
初版功能完成
2026-08-29 13:12:17 +08:00

178 lines
9.0 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[ValidateSet("Engineer", "Site", "Server")]
[string]$Role
)
$ErrorActionPreference = "Stop"
$resolvedPackage = [System.IO.Path]::GetFullPath($PackagePath)
$temporaryRoot = $null
$packageRoot = $resolvedPackage
try {
if (Test-Path -LiteralPath $resolvedPackage -PathType Leaf) {
if ([System.IO.Path]::GetExtension($resolvedPackage) -ne ".zip") {
throw "Release package file must be a ZIP: $resolvedPackage"
}
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-release-verify-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
Expand-Archive -LiteralPath $resolvedPackage -DestinationPath $temporaryRoot
$roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory)
$looseFiles = @(Get-ChildItem -LiteralPath $temporaryRoot -File)
if ($roots.Count -ne 1 -or $looseFiles.Count -ne 0) {
throw "Release ZIP must contain exactly one top-level package directory"
}
$packageRoot = $roots[0].FullName
} elseif (-not (Test-Path -LiteralPath $resolvedPackage -PathType Container)) {
throw "Release package not found: $resolvedPackage"
}
$roleMarkers = [ordered]@{
Engineer = "RemLinkEngineer.exe"
Site = "RemLinkSite.exe"
Server = "linux-amd64/remlink-server"
}
$detectedRoles = @($roleMarkers.GetEnumerator() | Where-Object {
Test-Path -LiteralPath (Join-Path $packageRoot $_.Value) -PathType Leaf
} | ForEach-Object Key)
if (-not $Role) {
if ($detectedRoles.Count -ne 1) {
throw "Unable to infer one package role; found markers for: $($detectedRoles -join ', ')"
}
$Role = $detectedRoles[0]
}
if ($detectedRoles.Count -ne 1 -or $detectedRoles[0] -ne $Role) {
throw "$Role package must contain only its own executable; found markers for: $($detectedRoles -join ', ')"
}
$forbiddenExecutableNames = switch ($Role) {
Engineer { @("RemLinkSite.exe", "remlink-server") }
Site { @("RemLinkEngineer.exe", "remlink-server") }
Server { @("RemLinkEngineer.exe", "RemLinkSite.exe") }
}
$foreignExecutables = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | Where-Object { $_.Name -in $forbiddenExecutableNames })
if ($foreignExecutables.Count -ne 0) {
throw "$Role package contains a foreign executable: $($foreignExecutables[0].FullName)"
}
$required = @(
"README.md", "BUILD-INFO.json", "SHA256SUMS.txt", "docs/deployment-and-usage.md",
"scripts/validation/Test-ReleasePackage.ps1"
)
switch ($Role) {
Engineer {
$required += @("RemLinkEngineer.exe", "engineer.yaml", "THIRD_PARTY_NOTICES.md")
}
Site {
$required += @("RemLinkSite.exe", "site.yaml", "THIRD_PARTY_NOTICES.md")
}
Server {
$required += @(
"linux-amd64/remlink-server", "linux-amd64/server.yaml", "linux-amd64/THIRD_PARTY_NOTICES.md",
"docker/compose.yaml", "docker/Dockerfile", "docker/compose.release.yaml", "docker/Dockerfile.release",
"docker/.env.example", "docker/.env.china.example", "docker/preflight.sh", "docker/server.yaml", "docker/README.md",
"docs/implementation-status.md",
"scripts/validation/New-AcceptanceRun.ps1", "scripts/validation/Set-AcceptanceResult.ps1",
"scripts/validation/Test-AcceptanceRun.ps1", "scripts/validation/Test-AcceptanceTools.ps1",
"scripts/validation/Test-ReleasePackage.ps1", "scripts/validation/Collect-WindowsEvidence.ps1",
"scripts/validation/Collect-ServerEvidence.sh", "scripts/validation/Test-RemoteTargets.ps1",
"docs/validation/T01-T18-runbook.md", "docs/validation/requirements-evidence.md"
)
}
}
foreach ($relative in $required) {
$target = Join-Path $packageRoot $relative
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) {
throw "Required $Role release entry is missing: $relative"
}
if ((Get-Item -LiteralPath $target).Length -eq 0) {
throw "Required $Role release entry is empty: $relative"
}
}
if ($Role -in @("Engineer", "Site")) {
$clientConfigName = if ($Role -eq "Engineer") { "engineer.yaml" } else { "site.yaml" }
$clientConfig = Get-Content -Raw -LiteralPath (Join-Path $packageRoot $clientConfigName)
if ($clientConfig -notmatch '(?m)^join_token:\s*') {
throw "$Role package YAML does not expose the join_token convenience field"
}
if ($clientConfig -match '(?im)^\s*(node_token|wg_private_key)\s*:') {
throw "$Role package YAML contains a durable Node secret field"
}
}
$checksumPath = Join-Path $packageRoot "SHA256SUMS.txt"
$checksums = @{}
foreach ($line in Get-Content -LiteralPath $checksumPath) {
if ($line -notmatch '^([0-9a-f]{64}) (.+)$') {
throw "Malformed checksum line: $line"
}
$relative = $Matches[2].Replace('\', '/')
if ([System.IO.Path]::IsPathRooted($relative) -or $relative -match '(^|/)\.\.(/|$)' -or $relative -eq "SHA256SUMS.txt") {
throw "Unsafe or self-referential checksum path: $relative"
}
if ($checksums.ContainsKey($relative)) {
throw "Duplicate checksum path: $relative"
}
$checksums[$relative] = $Matches[1]
}
$actualFiles = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | ForEach-Object {
[System.IO.Path]::GetRelativePath($packageRoot, $_.FullName).Replace('\', '/')
} | Where-Object { $_ -ne "SHA256SUMS.txt" })
if ($checksums.Count -ne $actualFiles.Count) {
throw "Checksum count $($checksums.Count) does not match package file count $($actualFiles.Count)"
}
foreach ($relative in $actualFiles) {
if (-not $checksums.ContainsKey($relative)) {
throw "Package file is not covered by SHA256SUMS.txt: $relative"
}
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $packageRoot $relative)).Hash.ToLowerInvariant()
if ($actualHash -ne $checksums[$relative]) {
throw "Checksum mismatch: $relative"
}
}
$buildInfo = Get-Content -Raw -LiteralPath (Join-Path $packageRoot "BUILD-INFO.json") | ConvertFrom-Json
if (-not $buildInfo.version -or $buildInfo.role -ne $Role -or @($buildInfo.target).Count -ne 1) {
throw "BUILD-INFO.json does not describe one $Role target"
}
if ($Role -in @("Engineer", "Site") -and $buildInfo.portable_data_root -ne "executable_directory") {
throw "$Role BUILD-INFO.json does not declare its portable executable data root"
}
if ($Role -eq "Engineer" -and (@($buildInfo.wails_build_tags) -join ',') -ne 'desktop,production') {
throw "Engineer BUILD-INFO.json does not declare the mandatory Wails desktop,production tags"
}
if ($Role -eq "Server") {
$acceptanceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-smoke-" + [guid]::NewGuid().ToString("N"))
try {
& (Join-Path $packageRoot "scripts/validation/New-AcceptanceRun.ps1") -OutputDirectory $acceptanceRoot
$manifest = Get-Content -Raw -LiteralPath (Join-Path $acceptanceRoot "acceptance-run.json") | ConvertFrom-Json
if ($manifest.schema_version -ne 2 -or @($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) {
throw "Packaged acceptance initializer produced an invalid manifest"
}
if (@($manifest.gates + $manifest.scenarios | Where-Object status -ne "NOT_RUN").Count -ne 0) {
throw "Packaged acceptance initializer fabricated a completed result"
}
if (-not (Test-Path -LiteralPath (Join-Path $acceptanceRoot "T01-T18-runbook.md") -PathType Leaf)) {
throw "Packaged acceptance initializer did not copy its runbook"
}
} finally {
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
if ($acceptanceRoot -and $acceptanceRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($acceptanceRoot).StartsWith("remlink-acceptance-smoke-")) {
Remove-Item -LiteralPath $acceptanceRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host "$Role release package verified: $packageRoot ($($checksums.Count) checksums)"
} finally {
if ($temporaryRoot) {
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
if ($temporaryRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($temporaryRoot).StartsWith("remlink-release-verify-")) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}