build: production Windows release pipeline (PyInstaller, SBOM, CI, signing hook)
Some checks failed
CI / Test Python 3.10 (push) Has been cancelled
CI / Test Python 3.11 (push) Has been cancelled
CI / Test Python 3.12 (push) Has been cancelled

This commit is contained in:
Dr Jones
2026-05-22 20:05:00 -07:00
parent b852fd264f
commit 08683ab328
10 changed files with 659 additions and 95 deletions

221
scripts/_build_common.ps1 Normal file
View File

@@ -0,0 +1,221 @@
#Requires -Version 5.1
<#
Shared build helpers for Proxy God / ProxyChainManager.
Dot-sourced by setup_and_build.ps1 and release_build.ps1 — do not run directly.
#>
Set-StrictMode -Version Latest
function Get-RepoRoot {
# This file lives in scripts/ — parent is the repository root.
return (Split-Path -Parent $PSScriptRoot)
}
function Resolve-PythonExe {
if (Get-Command py -ErrorAction SilentlyContinue) {
try {
$out = (& py -3 -c "import sys; print(sys.executable)" 2>$null).Trim()
if ($out -and (Test-Path -LiteralPath $out)) { return $out }
} catch { }
}
$p = Get-Command python.exe -ErrorAction SilentlyContinue
if ($p) { return $p.Source }
throw @(
"Python 3.10+ not found (tried 'py -3' and 'python'). Install, then re-run:",
" winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements",
"Or https://www.python.org/downloads/ (enable 'Add python.exe to PATH')."
) -join "`n"
}
function Get-GitCommit {
try {
$c = (git -C (Get-RepoRoot) rev-parse --short HEAD 2>$null).Trim()
if ($c) { return $c }
} catch { }
return "unknown"
}
function Get-ReleaseVersion {
param([string]$Override = "")
if ($Override) { return $Override.Trim() }
$root = Get-RepoRoot
try {
$tag = (git -C $root describe --tags --exact-match 2>$null).Trim()
if ($tag) { return $tag.TrimStart("v") }
} catch { }
try {
$desc = (git -C $root describe --tags --always --dirty 2>$null).Trim()
if ($desc) { return ($desc -replace '^v', '') }
} catch { }
$py = Resolve-PythonExe
$v = (& $py -c "from proxy_chain_manager import __version__; print(__version__)" 2>$null).Trim()
if ($v) { return $v }
return "0.0.0-dev"
}
function Install-BuildDependencies {
param([string]$PythonExe, [string]$Root)
Write-Host "`n== pip (upgrade) =="
& $PythonExe -m pip install --upgrade pip
Write-Host "`n== runtime dependencies =="
& $PythonExe -m pip install -r (Join-Path $Root "requirements.txt")
Write-Host "`n== build dependencies =="
& $PythonExe -m pip install -r (Join-Path $Root "dev-requirements.txt")
}
function Invoke-UnitTests {
param([string]$PythonExe, [string]$Root)
Write-Host "`n== compileall =="
& $PythonExe -m compileall -q (Join-Path $Root "proxy_chain_manager")
if ($LASTEXITCODE -ne 0) { throw "compileall failed (exit $LASTEXITCODE)" }
Write-Host "`n== unittest discover =="
Push-Location $Root
try {
& $PythonExe -m unittest discover -s tests -v
if ($LASTEXITCODE -ne 0) { throw "unittest failed (exit $LASTEXITCODE)" }
} finally {
Pop-Location
}
}
function Invoke-StageBundledGost {
param([string]$Root)
Write-Host "`n== Stage bundled GOST =="
$prep = Join-Path $Root "scripts\prepare_bundled_gost.ps1"
& powershell -NoProfile -ExecutionPolicy Bypass -File $prep
if ($LASTEXITCODE -ne 0) { throw "prepare_bundled_gost.ps1 failed (exit $LASTEXITCODE)" }
}
function Invoke-GenerateVersionInfo {
param(
[string]$PythonExe,
[string]$Root,
[string]$Version,
[string]$Commit = ""
)
Write-Host "`n== Version metadata ($Version) =="
$gen = Join-Path $Root "scripts\generate_version_info.py"
$out = Join-Path $Root "build\version_info.txt"
$args = @($gen, "--version", $Version, "--out", $out)
if ($Commit) { $args += @("--commit", $Commit) }
& $PythonExe @args
if ($LASTEXITCODE -ne 0) { throw "generate_version_info.py failed (exit $LASTEXITCODE)" }
}
function Invoke-PyInstallerBuild {
param([string]$PythonExe, [string]$Root)
Write-Host "`n== PyInstaller (ProxyChainManager.spec) =="
$spec = Join-Path $Root "ProxyChainManager.spec"
if (-not (Test-Path $spec)) { throw "Spec not found: $spec" }
& $PythonExe -m PyInstaller --noconfirm --clean $spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed (exit $LASTEXITCODE)" }
$exe = Join-Path $Root "dist\ProxyChainManager.exe"
if (-not (Test-Path $exe)) { throw "Build failed: missing $exe" }
return $exe
}
function Invoke-CodeSign {
param([string]$ExePath)
$cert = $env:SIGN_CERT_PATH
if (-not $cert) {
Write-Host " (signing skipped — set SIGN_CERT_PATH to a .pfx to Authenticode-sign)"
return
}
if (-not (Test-Path $cert)) { throw "SIGN_CERT_PATH not found: $cert" }
$signtool = "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe"
if (-not (Test-Path $signtool)) {
$signtool = (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source
}
if (-not $signtool) { throw "signtool.exe not found — install Windows SDK" }
Write-Host "`n== Authenticode sign =="
$args = @("sign", "/fd", "SHA256", "/f", $cert, "/tr", "http://timestamp.digicert.com", "/td", "SHA256")
if ($env:SIGN_CERT_PASSWORD) {
$args += @("/p", $env:SIGN_CERT_PASSWORD)
}
$args += $ExePath
& $signtool @args
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed (exit $LASTEXITCODE)" }
}
function Write-ExeHashSidecar {
param([string]$ExePath)
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ExePath).Hash.ToLower()
$sidecar = "$ExePath.sha256"
"$hash *$(Split-Path -Leaf $ExePath)" | Out-File -FilePath $sidecar -Encoding ascii -Force
Write-Host "SHA256: $hash -> $sidecar"
return @{ Hash = $hash; Sidecar = $sidecar }
}
function Write-Sbom {
param(
[string]$PythonExe,
[string]$OutDir,
[string]$Version
)
Write-Host "`n== SBOM =="
$freeze = Join-Path $OutDir "requirements-frozen.txt"
& $PythonExe -m pip freeze | Out-File -FilePath $freeze -Encoding utf8
$sbom = Join-Path $OutDir "SBOM.json"
$pkgs = @()
Get-Content $freeze | ForEach-Object {
if ($_ -match '^([^=]+)==(.+)$') {
$pkgs += @{ name = $Matches[1]; version = $Matches[2] }
}
}
$manifest = @{
product = "Proxy God"
version = $Version
generated = (Get-Date).ToUniversalTime().ToString("o")
python = (& $PythonExe -c "import sys; print(sys.version.split()[0])").Trim()
packages = $pkgs
}
($manifest | ConvertTo-Json -Depth 4) | Out-File -FilePath $sbom -Encoding utf8
Write-Host "Wrote $sbom and $freeze"
return @{ Sbom = $sbom; Freeze = $freeze }
}
function Write-ReleaseManifest {
param(
[string]$OutDir,
[string]$Version,
[string]$Commit,
[string]$ExePath,
[string]$Sha256,
[hashtable]$Extra = @{}
)
$manifest = @{
product = "Proxy God"
executable = "ProxyChainManager.exe"
version = $Version
git_commit = $Commit
built_at_utc = (Get-Date).ToUniversalTime().ToString("o")
platform = "windows-amd64"
sha256 = $Sha256
uac_admin = $true
bundled_gost = $true
pyinstaller = "6.10.0"
}
foreach ($k in $Extra.Keys) { $manifest[$k] = $Extra[$k] }
$path = Join-Path $OutDir "RELEASE_MANIFEST.json"
($manifest | ConvertTo-Json -Depth 4) | Out-File -FilePath $path -Encoding utf8
Write-Host "Wrote $path"
return $path
}
function New-ReleaseZip {
param(
[string]$ReleaseDir,
[string]$Version,
[string]$Root
)
$zipName = "ProxyGod-v$Version-windows-amd64.zip"
$zipPath = Join-Path $Root "releases\$zipName"
$releasesRoot = Split-Path $zipPath -Parent
if (-not (Test-Path $releasesRoot)) {
New-Item -ItemType Directory -Path $releasesRoot | Out-Null
}
if (Test-Path $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
Compress-Archive -Path (Join-Path $ReleaseDir "*") -DestinationPath $zipPath -Force
Write-Host "Release zip: $zipPath"
return $zipPath
}

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Generate a PyInstaller version-info file for ProxyChainManager.exe.
Usage:
python scripts/generate_version_info.py --version 1.2.3 --out build/version_info.txt
The output is consumed by ProxyChainManager.spec when the file exists.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def _parse_version(version: str) -> tuple[int, int, int, int]:
parts = re.findall(r"\d+", version)
nums = [int(x) for x in parts[:4]]
while len(nums) < 4:
nums.append(0)
return nums[0], nums[1], nums[2], nums[3]
def build_version_info(version: str, commit: str = "") -> str:
filevers = prodvers = _parse_version(version)
commit_suffix = f" ({commit})" if commit else ""
return f"""# UTF-8
# Generated by scripts/generate_version_info.py — do not edit by hand.
VSVersionInfo(
ffi=FixedFileInfo(
filevers={filevers},
prodvers={prodvers},
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'040904B0',
[StringStruct(u'CompanyName', u'Proxy God'),
StringStruct(u'FileDescription', u'Proxy God — multi-hop proxy chain manager'),
StringStruct(u'FileVersion', u'{version}{commit_suffix}'),
StringStruct(u'InternalName', u'ProxyChainManager'),
StringStruct(u'LegalCopyright', u'MIT License'),
StringStruct(u'OriginalFilename', u'ProxyChainManager.exe'),
StringStruct(u'ProductName', u'Proxy God'),
StringStruct(u'ProductVersion', u'{version}{commit_suffix}')])
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)
"""
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--version", required=True)
ap.add_argument("--commit", default="")
ap.add_argument("--out", required=True)
args = ap.parse_args()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(build_version_info(args.version, args.commit), encoding="utf-8")
print(f"Wrote {out}")
if __name__ == "__main__":
main()

108
scripts/release_build.ps1 Normal file
View File

@@ -0,0 +1,108 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Production release build for Proxy God (ProxyChainManager.exe).
.DESCRIPTION
Full pipeline:
1. Resolve Python 3.10+
2. Install pinned runtime + build deps
3. Run compileall + unittest (gate)
4. Stage SHA-verified bundled gost.exe
5. Generate Windows VERSIONINFO for the exe
6. PyInstaller one-file build (ProxyChainManager.spec)
7. Optional Authenticode sign (SIGN_CERT_PATH / SIGN_CERT_PASSWORD)
8. SHA256 sidecar + SBOM + RELEASE_MANIFEST.json
9. Zip release folder -> releases/ProxyGod-v{version}-windows-amd64.zip
.PARAMETER Version
Override semver (default: git tag > git describe > __version__).
.PARAMETER SkipTests
Skip compileall/unittest (not recommended for production).
.PARAMETER SkipSign
Skip Authenticode even if SIGN_CERT_PATH is set.
.PARAMETER NoZip
Skip creating the release zip archive.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\release_build.ps1
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\release_build.ps1 -Version 1.2.0
#>
[CmdletBinding()]
param(
[string]$Version = "",
[switch]$SkipTests,
[switch]$SkipSign,
[switch]$NoZip
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_build_common.ps1"
$root = Get-RepoRoot
Set-Location $root
$py = Resolve-PythonExe
Write-Host "Using: $py"
& $py -c "import sys; assert sys.version_info >= (3, 10); print(sys.version)"
$ver = Get-ReleaseVersion -Override $Version
$commit = Get-GitCommit
Write-Host "Release version: $ver (commit $commit)"
Install-BuildDependencies -PythonExe $py -Root $root
if (-not $SkipTests) {
Invoke-UnitTests -PythonExe $py -Root $root
} else {
Write-Warning "Skipping test gate (-SkipTests)"
}
Invoke-StageBundledGost -Root $root
Invoke-GenerateVersionInfo -PythonExe $py -Root $root -Version $ver -Commit $commit
$exe = Invoke-PyInstallerBuild -PythonExe $py -Root $root
if (-not $SkipSign) {
Invoke-CodeSign -ExePath $exe
}
$hashInfo = Write-ExeHashSidecar -ExePath $exe
# Assemble release directory (versioned folder under releases/)
$releaseDir = Join-Path $root "releases\v$ver"
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
New-Item -ItemType Directory -Path $releaseDir | Out-Null
Copy-Item -LiteralPath $exe -Destination (Join-Path $releaseDir "ProxyChainManager.exe") -Force
Copy-Item -LiteralPath $hashInfo.Sidecar -Destination (Join-Path $releaseDir "ProxyChainManager.exe.sha256") -Force
Copy-Item -LiteralPath (Join-Path $root "LICENSE") -Destination $releaseDir -ErrorAction SilentlyContinue
Copy-Item -LiteralPath (Join-Path $root "docs\OPERATOR_RUNBOOK.md") -Destination $releaseDir -ErrorAction SilentlyContinue
$sbom = Write-Sbom -PythonExe $py -OutDir $releaseDir -Version $ver
Write-ReleaseManifest -OutDir $releaseDir -Version $ver -Commit $commit `
-ExePath $exe -Sha256 $hashInfo.Hash -Extra @{
sbom_file = (Split-Path -Leaf $sbom.Sbom)
requirements_frozen = (Split-Path -Leaf $sbom.Freeze)
}
# Also refresh dist/ sidecars for developers
Copy-Item -LiteralPath $hashInfo.Sidecar -Destination (Join-Path $root "dist\ProxyChainManager.exe.sha256") -Force
if (-not $NoZip) {
$zip = New-ReleaseZip -ReleaseDir $releaseDir -Version $ver -Root $root
Write-Host "`n== Release complete =="
Write-Host " Folder: $releaseDir"
Write-Host " Zip: $zip"
} else {
Write-Host "`n== Release complete (no zip) =="
Write-Host " Folder: $releaseDir"
}
Write-Host "`nVerify:"
Write-Host " Get-FileHash -Algorithm SHA256 releases\v$ver\ProxyChainManager.exe"

View File

@@ -1,75 +1,48 @@
#Requires -Version 5.1
<#
.SYNOPSIS
After git pull: upgrade pip, install deps, build ProxyChainManager.exe, copy to Desktop, create "Proxy God.lnk".
Developer build: deps, tests (optional), PyInstaller, copy to Desktop, shortcut.
.DESCRIPTION
Requires Python 3.10+ on PATH (python.exe) or the Windows py launcher (py -3).
Does not auto-install Python; see README for winget one-liner if needed.
#>
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $root
Lighter than release_build.ps1 — skips SBOM/zip/manifest. For daily dev iteration.
Production releases: use scripts\release_build.ps1 or build_release.bat
function Resolve-PythonExe {
if (Get-Command py -ErrorAction SilentlyContinue) {
try {
$out = (& py -3 -c "import sys; print(sys.executable)" 2>$null).Trim()
if ($out -and (Test-Path -LiteralPath $out)) { return $out }
} catch { }
}
$p = Get-Command python.exe -ErrorAction SilentlyContinue
if ($p) { return $p.Source }
throw (
"Python 3.10+ not found (tried 'py -3' and 'python'). Install, then re-run:`n" +
" winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements`n" +
"Or https://www.python.org/downloads/ (enable 'Add python.exe to PATH')."
)
}
.PARAMETER SkipTests
Skip unittest gate (faster iteration).
#>
[CmdletBinding()]
param([switch]$SkipTests)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_build_common.ps1"
$root = Get-RepoRoot
Set-Location $root
$py = Resolve-PythonExe
Write-Host "Using: $py"
& $py -c "import sys; assert sys.version_info >= (3, 10), 'Need Python 3.10+'; print(sys.version)"
& $py -c "import sys; assert sys.version_info >= (3, 10); print(sys.version)"
Write-Host "`n== pip (upgrade) =="
& $py -m pip install --upgrade pip
$ver = Get-ReleaseVersion
Install-BuildDependencies -PythonExe $py -Root $root
Write-Host "`n== dependencies + PyInstaller =="
& $py -m pip install -r "$root\requirements.txt"
# Pinned dev dep (PyInstaller version) so builds are reproducible.
& $py -m pip install -r "$root\dev-requirements.txt"
Write-Host "`n== Stage bundled GOST binary =="
& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\prepare_bundled_gost.ps1"
if ($LASTEXITCODE -ne 0) { throw "prepare_bundled_gost.ps1 failed (exit $LASTEXITCODE)" }
Write-Host "`n== PyInstaller (using ProxyChainManager.spec) =="
# Always build from the spec — it bundles signup_extension/, world_map.png,
# customtkinter assets, and the right hidden imports. Do NOT override with
# CLI flags or the data files will be missing from the .exe.
$spec = Join-Path $root "ProxyChainManager.spec"
if (-not (Test-Path $spec)) { throw "Spec not found: $spec" }
& $py -m PyInstaller --noconfirm --clean $spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed (exit $LASTEXITCODE)" }
$distExe = Join-Path $root "dist\ProxyChainManager.exe"
if (-not (Test-Path $distExe)) {
throw "Build failed: missing $distExe"
if (-not $SkipTests) {
Invoke-UnitTests -PythonExe $py -Root $root
}
# SHA256 sidecar so the release artifact is self-verifiable.
$distHashFile = "$distExe.sha256"
$distHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $distExe).Hash.ToLower()
"$distHash *ProxyChainManager.exe" | Out-File -FilePath $distHashFile -Encoding ascii -Force
Write-Host "SHA256: $distHash$distHashFile"
Invoke-StageBundledGost -Root $root
Invoke-GenerateVersionInfo -PythonExe $py -Root $root -Version $ver -Commit (Get-GitCommit)
$exe = Invoke-PyInstallerBuild -PythonExe $py -Root $root
$hashInfo = Write-ExeHashSidecar -ExePath $exe
$desk = [Environment]::GetFolderPath("Desktop")
$deskExe = Join-Path $desk "ProxyChainManager.exe"
Copy-Item -LiteralPath $distExe -Destination $deskExe -Force
Copy-Item -LiteralPath $distHashFile -Destination "$deskExe.sha256" -Force
Copy-Item -LiteralPath $exe -Destination $deskExe -Force
Copy-Item -LiteralPath $hashInfo.Sidecar -Destination "$deskExe.sha256" -Force
Write-Host "Copied: $deskExe (+ .sha256)"
Write-Host "`n== Desktop shortcut =="
& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\create_desktop_shortcut.ps1"
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root "scripts\create_desktop_shortcut.ps1")
Write-Host "`nDone. Launch from Desktop: Proxy God.lnk (or ProxyChainManager.exe)"