zapret-kvn/scripts/release_windows_gate.ps1
loop-uh d5a4715771
Some checks failed
Windows project source guards / test (push) Has been cancelled
feat: use official Amnezia transport and organize runtime modules
2026-09-06 00:14:08 +03:00

361 lines
15 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("dev", "stable")]
[string]$Mode,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-f]{40}$")]
[string]$Commit,
[Parameter(Mandatory = $true)]
[ValidatePattern("^\d+\.\d+\.\d+$")]
[string]$Version,
[string]$RepoRoot = "C:\Users\privacy\ZapretKVN-local-release",
[string]$ManifestPath = ""
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
function Invoke-Native([string]$Executable, [string[]]$Arguments) {
& $Executable @Arguments | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "$Executable failed with exit code $LASTEXITCODE"
}
}
function Get-Sha256([string]$Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Assert-StableCoreLock([string]$LockFile) {
$lock = Get-Content -LiteralPath $LockFile -Raw | ConvertFrom-Json
if ([int]$lock.schema -ne 1 -or [string]$lock.platform -ne "windows-x64") {
throw "Unsupported core lock format: $LockFile"
}
$required = @{
"xray-core" = "XTLS/Xray-core"
"sing-box-extended" = "shtorm-7/sing-box-extended"
"hysteria" = "HyNetworks/hysteria"
}
foreach ($entry in $required.GetEnumerator()) {
$matches = @($lock.sources | Where-Object { [string]$_.id -eq $entry.Key })
if ($matches.Count -ne 1) {
throw "Core lock must contain exactly one $($entry.Key) source"
}
$source = $matches[0]
if ([string]$source.repository -ne $entry.Value -or [string]$source.channel -ne "stable") {
throw "Core lock source $($entry.Key) is not from the approved stable channel"
}
if ($source.release_prerelease -ne $false) {
throw "Stable release cannot use prerelease core source $($entry.Key)"
}
if ([string]$source.release_tag -ne [string]$source.version) {
throw "Core release tag/version mismatch for $($entry.Key)"
}
if ([string]$source.asset_name -ne [string]$source.archive) {
throw "Core asset/archive mismatch for $($entry.Key)"
}
if ([int64]$source.asset_size -le 0) {
throw "Core source $($entry.Key) has no verified asset size"
}
}
if ([string]$lock.amnezia.repository -ne "amnezia-vpn/amneziawg-go" -or
[string]$lock.amnezia.channel -ne "official-tags" -or
[string]$lock.amnezia.commit -notmatch '^[0-9a-f]{40}$' -or
[string]$lock.amnezia.sha256 -notmatch '^[0-9a-f]{64}$') {
throw "Core lock must pin the official Amnezia module and source digest"
}
return $lock
}
function Stop-ReleaseProcesses([string]$Root) {
$ownedRoots = @(
[IO.Path]::GetFullPath((Join-Path $Root "dist\ZapretKVN")),
[IO.Path]::GetFullPath((Join-Path $Root "core"))
)
$names = @("ZapretKVN", "sing-box", "xray", "hysteria", "zapret-amnezia", "tun2socks")
foreach ($process in Get-Process -Name $names -ErrorAction SilentlyContinue) {
$path = $null
try { $path = $process.Path } catch { $path = $null }
if (-not $path) { continue }
$resolved = [IO.Path]::GetFullPath($path)
$owned = $ownedRoots | Where-Object {
$resolved.StartsWith($_, [StringComparison]::OrdinalIgnoreCase)
}
if ($owned) {
Write-Host "[release] stopping $($process.ProcessName) from $resolved"
Stop-Process -Id $process.Id -Force
Wait-Process -Id $process.Id -Timeout 15 -ErrorAction SilentlyContinue
}
}
}
function Assert-Workspace([string]$Root, [string]$ExpectedCommit, [string]$ExpectedVersion) {
if (-not (Test-Path -LiteralPath (Join-Path $Root ".git"))) {
throw "Release workspace is missing: $Root"
}
Set-Location -LiteralPath $Root
Invoke-Native "git" @("fetch", "origin", "main", "--tags")
Invoke-Native "git" @("switch", "--detach", $ExpectedCommit)
$actualCommit = (& git rev-parse HEAD).Trim()
if ($actualCommit -ne $ExpectedCommit) {
throw "Windows workspace SHA mismatch: $actualCommit"
}
$source = Get-Content -LiteralPath (Join-Path $Root "xray_fluent\constants.py") -Raw
if (-not $source.Contains("APP_VERSION = `"$ExpectedVersion`"")) {
throw "APP_VERSION does not match $ExpectedVersion"
}
$unexpected = @(
git status --short --untracked-files=all |
Where-Object { $_ -notmatch "^\?\? package-zapret-kvn\.ps1$" }
)
if ($unexpected.Count -ne 0) {
$unexpected | Write-Host
throw "Windows release workspace contains unexpected changes"
}
}
function Install-VerifiedCore([string]$Root) {
$lockFile = Join-Path $Root "scripts\core-lock.windows-x64.json"
$null = Assert-StableCoreLock $lockFile
$archive = Join-Path $Root ".cache\core-bundle\core-windows-x64.7z"
$stamp = "$archive.inputs.sha256"
$lockHash = (& py -3 (Join-Path $Root "scripts\core_bundle_fingerprint.py") --root $Root --lock $lockFile | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $lockHash -notmatch '^[0-9a-f]{64}$') { throw "Cannot fingerprint current core sources" }
$cachedHash = if (Test-Path -LiteralPath $stamp) {
(Get-Content -LiteralPath $stamp -Raw).Trim().ToLowerInvariant()
} else { "" }
if (-not (Test-Path -LiteralPath $archive) -or $cachedHash -ne $lockHash) {
Write-Host "[release] rebuilding pinned core bundle"
& (Join-Path $Root "scripts\build_core_bundle.ps1")
} else {
Write-Host "[release] reusing core bundle for verified inputs $lockHash"
}
& (Join-Path $Root "scripts\install_core_bundle.ps1")
}
function Get-CoreProof([string]$Root) {
$lockFile = Join-Path $Root "scripts\core-lock.windows-x64.json"
$lock = Assert-StableCoreLock $lockFile
$manifestPath = Join-Path $Root "core\core-manifest.windows-x64.json"
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "Installed core manifest is missing: $manifestPath"
}
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$expectedInputs = (& py -3 (Join-Path $Root "scripts\core_bundle_fingerprint.py") --root $Root --lock $lockFile | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]$manifest.inputs_sha256 -ne $expectedInputs -or
[string]$manifest.lock_sha256 -ne (Get-Sha256 $lockFile)) {
throw "Installed cores were built from different source/patch inputs"
}
$sources = @((@($lock.sources) + @($lock.amnezia)) | Where-Object {
[string]$_.id -in @("xray-core", "sing-box-extended", "hysteria", "amnezia")
} | ForEach-Object {
[ordered]@{
id = [string]$_.id
version = [string]$_.version
repository = [string]$_.repository
channel = [string]$_.channel
release_prerelease = [bool]$_.release_prerelease
archive_sha256 = [string]$_.sha256
asset_size = [int64]$_.asset_size
}
})
return [ordered]@{
lock_sha256 = Get-Sha256 $lockFile
manifest_sha256 = Get-Sha256 $manifestPath
sources = $sources
singbox_build = $manifest.singbox_build
inputs_sha256 = $manifest.inputs_sha256
}
}
function Ensure-DependenciesAndTests([string]$Root) {
$python = Join-Path $Root ".venv\Scripts\python.exe"
if (-not (Test-Path -LiteralPath $python)) {
Invoke-Native "py" @("-3", "-m", "venv", (Join-Path $Root ".venv"))
}
Invoke-Native $python @("-m", "pip", "install", "-r", (Join-Path $Root "requirements.txt"))
Invoke-Native $python @("-m", "pip", "check")
Invoke-Native $python @("-m", "unittest", "discover", "-s", "tests", "-v")
}
function Build-Application([string]$Root) {
Stop-ReleaseProcesses $Root
$python = Join-Path $Root ".venv\Scripts\python.exe"
Invoke-Native $python @((Join-Path $Root "build.py"), "--no-zip")
$exe = Join-Path $Root "dist\ZapretKVN\ZapretKVN.exe"
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
throw "Built ZapretKVN.exe is missing"
}
return $exe
}
function Assert-CleanPayload([string]$Root) {
$portable = Join-Path $Root "dist\ZapretKVN"
if (-not (Test-Path -LiteralPath $portable -PathType Container)) {
throw "Clean release payload is missing: $portable"
}
$allowedTopLevel = @("ZapretKVN.exe", "_internal", "assets", "core", "data", "zapret")
$unexpectedTopLevel = @(
Get-ChildItem -LiteralPath $portable -Force |
Where-Object { $allowedTopLevel -notcontains $_.Name }
)
if ($unexpectedTopLevel.Count -ne 0) {
$unexpectedTopLevel | ForEach-Object { Write-Host $_.FullName }
throw "Release payload contains unexpected top-level files"
}
$data = Join-Path $portable "data"
if (-not (Test-Path -LiteralPath $data -PathType Container)) {
throw "Release payload is missing data/templates"
}
$unexpectedData = @(
Get-ChildItem -LiteralPath $data -Force |
Where-Object { $_.Name -ne "templates" }
)
if ($unexpectedData.Count -ne 0) {
$unexpectedData | ForEach-Object { Write-Host $_.FullName }
throw "Release payload contains runtime data; only data/templates is allowed"
}
if (-not (Test-Path -LiteralPath (Join-Path $data "templates") -PathType Container)) {
throw "Release payload is missing data/templates"
}
$ruleSetRoot = Join-Path $portable "core\rule-set"
$requiredRuleSets = @(
"geosite-ru-blocked.srs",
"geoip-ru-blocked.srs",
"geosite-category-ru.srs",
"geoip-ru.srs"
)
foreach ($name in $requiredRuleSets) {
$path = Join-Path $ruleSetRoot $name
if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-Item -LiteralPath $path).Length -le 0) {
throw "Release payload is missing bundled sing-box rule-set: $path"
}
}
}
function Test-ShippedTemplates([string]$Root) {
$sourceRoot = (Resolve-Path (Join-Path $Root "data\templates")).Path
$templates = @(Get-ChildItem -LiteralPath $sourceRoot -Recurse -File)
foreach ($template in $templates) {
$relative = $template.FullName.Substring($sourceRoot.Length).TrimStart("\")
foreach ($destinationRoot in @(
(Join-Path $Root "dist\ZapretKVN\data\templates"),
(Join-Path $Root "dist\ZapretKVN\assets\template-update")
)) {
$destination = Join-Path $destinationRoot $relative
if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) {
throw "Missing shipped template: $destination"
}
if ((Get-Sha256 $template.FullName) -ne (Get-Sha256 $destination)) {
throw "Shipped template mismatch: $destination"
}
}
}
$portable = Join-Path $Root "dist\ZapretKVN"
$singbox = Join-Path $portable "core\sing-box.exe"
$workingDirectory = Join-Path $portable "core"
$singboxTemplates = @(Get-ChildItem -LiteralPath (Join-Path $portable "data\templates\sing-box") -Filter "*.json" -File)
foreach ($template in $singboxTemplates) {
Invoke-Native $singbox @("check", "-D", $workingDirectory, "-c", $template.FullName)
}
return $templates.Count
}
function Find-SevenZip {
$command = Get-Command 7z.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$candidate = Join-Path $env:ProgramFiles "7-Zip\7z.exe"
if (Test-Path -LiteralPath $candidate) { return $candidate }
throw "7-Zip is not installed"
}
function New-StableAssets([string]$Root, [string]$ReleaseVersion) {
$sevenZip = Find-SevenZip
$tag = "v$ReleaseVersion"
$dist = Join-Path $Root "dist"
$portable = Join-Path $dist "ZapretKVN"
$coreBundle = Join-Path $Root ".cache\core-bundle\core-windows-x64.7z"
$sfx = Join-Path $dist "ZapretKVN-$tag-windows-x64.exe"
$zip = Join-Path $dist "ZapretKVN-$tag-windows-x64.zip"
$checksum = "$zip.sha256"
$archive = Join-Path $dist "ZapretKVN-$tag-windows-x64.7z"
$releaseCore = Join-Path $dist "ZapretKVN-cores-$tag-windows-x64.7z"
$outputs = @($sfx, $zip, $checksum, $archive, $releaseCore)
foreach ($output in $outputs) {
if (Test-Path -LiteralPath $output) {
Remove-Item -LiteralPath $output -Force
}
}
Push-Location -LiteralPath $portable
try {
Invoke-Native $sevenZip @("a", "-bd", "-t7z", "-mx=5", "-sfx", $sfx, "*")
Invoke-Native $sevenZip @("a", "-bd", "-tzip", "-mx=5", $zip, "*")
Invoke-Native $sevenZip @("a", "-bd", "-t7z", "-mx=5", $archive, "*")
}
finally {
Pop-Location
}
Copy-Item -LiteralPath $coreBundle -Destination $releaseCore
$zipHash = Get-Sha256 $zip
[IO.File]::WriteAllText($checksum, "$zipHash`n", [Text.Encoding]::ASCII)
foreach ($testFile in @($sfx, $zip, $archive, $releaseCore)) {
Invoke-Native $sevenZip @("t", "-bd", $testFile)
}
return @($outputs | ForEach-Object {
$item = Get-Item -LiteralPath $_
if ($item.Length -le 0) { throw "Empty release asset: $($_)" }
[ordered]@{
name = $item.Name
path = $item.FullName
size = $item.Length
sha256 = Get-Sha256 $item.FullName
}
})
}
Assert-Workspace $RepoRoot $Commit $Version
Stop-ReleaseProcesses $RepoRoot
Install-VerifiedCore $RepoRoot
$coreProof = Get-CoreProof $RepoRoot
Ensure-DependenciesAndTests $RepoRoot
$exePath = Build-Application $RepoRoot
Assert-CleanPayload $RepoRoot
$templateCount = Test-ShippedTemplates $RepoRoot
$geoipJson = & (Join-Path $RepoRoot ".venv\Scripts\python.exe") (Join-Path $RepoRoot "scripts\prepare_geoip.py") --verify (Join-Path $RepoRoot "dist\ZapretKVN\assets")
if ($LASTEXITCODE -ne 0) { throw "Packaged GeoIP verification failed" }
$geoipProof = $geoipJson | ConvertFrom-Json
$assets = if ($Mode -eq "stable") { @(New-StableAssets $RepoRoot $Version) } else { @() }
if (-not $ManifestPath) {
$ManifestPath = Join-Path $RepoRoot ".cache\release\v$Version\$Mode-manifest.json"
}
$manifestDirectory = Split-Path -Parent $ManifestPath
New-Item -ItemType Directory -Force -Path $manifestDirectory | Out-Null
$manifest = [ordered]@{
schema = 1
mode = $Mode
version = $Version
commit = $Commit
generated_at_utc = [DateTime]::UtcNow.ToString("o")
executable = [ordered]@{
path = $exePath
size = (Get-Item -LiteralPath $exePath).Length
sha256 = Get-Sha256 $exePath
}
templates_verified = $templateCount
core = $coreProof
geoip = $geoipProof
assets = $assets
}
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $ManifestPath -Encoding utf8
Write-Host "[release] $Mode gate complete: $ManifestPath"