Files
theta-agent/scripts/setup-build-env.ps1
wmantly 1b332cacab fix(installer): derive installer version from the git tag, not a hardcoded default
installer.iss had #define MyAppVersion "2.1.0", so every release's setup.exe
(and its AppVersion/VersionInfoVersion) self-identified as 2.1.0 regardless of
the actual tag. setup-build-env.ps1 now passes /DMyAppVersion derived from
GITHUB_REF_NAME (CI tag run) or git describe, falling back to a 0.0.0-dev
placeholder in the .iss only for bare manual compiles.
2026-08-10 17:41:37 -07:00

308 lines
12 KiB
PowerShell

<#
.SYNOPSIS
Idempotent setup of the Theta Agent Windows dev/build environment.
.DESCRIPTION
Ensures everything needed to build and package the Windows agent, tray,
session helper, and the fully-offline Inno installer is present:
* Go toolchain (pinned version, user-space, no admin required)
* Inno Setup compiler (pinned version, per-user install, no admin required)
* vendor assets (WireGuard MSI, VC++ redist, OpenCredential CP),
fetched into installer/windows/vendor/ and verified against the pinned
sha256 in installer/windows/vendor-manifest.json
Safe to run repeatedly: each component is skipped when already installed
and valid, so `powershell -File scripts/setup-build-env.ps1` is a no-op on a
ready machine. Pass -Build to also compile the binaries and the installer.
.PARAMETER ToolDir
Where to install toolchains. Default: %LOCALAPPDATA%\Theta42\buildtools
.PARAMETER RepoRoot
Repository root. Default: the parent of this script's directory.
.PARAMETER SkipGo
Do not install/verify the Go toolchain.
.PARAMETER SkipInno
Do not install/verify Inno Setup.
.PARAMETER SkipVendor
Do not fetch/verify vendor assets.
.PARAMETER Build
After setup, build the agent/tray/helper for windows (amd64+arm64), run
`go test ./...`, and compile the installer with ISCC.
.PARAMETER CI
Non-interactive/CI mode: exit non-zero on any failure. (Used by the
GitHub Actions workflow so the runner fails loudly on a broken env.)
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\setup-build-env.ps1 -Build
#>
[CmdletBinding()]
param(
[string]$ToolDir = (Join-Path $env:LOCALAPPDATA 'Theta42\buildtools'),
[string]$RepoRoot = '',
[switch]$SkipGo,
[switch]$SkipInno,
[switch]$SkipVendor,
[switch]$Build,
[switch]$CI
)
$ErrorActionPreference = 'Stop'
$script:anyFailed = $false
# $PSScriptRoot is not populated inside the param() defaults on PowerShell 5.1.
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
}
function Write-Step($msg) { Write-Host "== $msg" -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Skip($msg) { Write-Host " [skip] $msg" -ForegroundColor DarkGray }
function Write-Fail($msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:anyFailed = $true }
function Write-Info($msg) { Write-Host " [info] $msg" -ForegroundColor Gray }
# ---------------------------------------------------------------- manifest ----
$manifestPath = Join-Path $RepoRoot 'installer\windows\vendor-manifest.json'
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
$vendorDir = Join-Path $RepoRoot 'installer\windows\vendor'
# -------------------------------------------------------------- Go toolchain --
function Get-GoVersion {
$v = (go version 2>$null)
if ($v -match 'go([0-9]+\.[0-9]+)') { return $matches[1] }
return ''
}
function Install-Go {
$needGo = $manifest.toolchain.go.version
$minGo = ($needGo -split '\.')[0] + '.' + ($needGo -split '\.')[1] # e.g. 1.22
$have = Get-GoVersion
if ($have -ne '' -and ([version]$have -ge [version]$minGo)) {
Write-OK "Go $have already available ($minGo+ required)"
return
}
$url = $manifest.toolchain.go.url
$goRoot = Join-Path $ToolDir "go$needGo"
# The official zip contains a top-level go/ folder -> goRoot\go\bin\go.exe.
$goBin = Join-Path $goRoot 'go\bin'
if (Test-Path (Join-Path $goBin 'go.exe')) {
Write-OK "Go $needGo found at $goRoot"
} else {
Write-Step "Installing Go $needGo (no admin required, zip extract)"
$zip = Join-Path $env:TEMP "go$needGo.windows-amd64.zip"
if (-not (Test-Path $zip)) {
Write-Info "Downloading $url"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
}
$staging = Join-Path $goRoot 'staging'
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Expand-Archive -Path $zip -DestinationPath $staging -Force
# staging\go -> goRoot\go
Move-Item -Force (Join-Path $staging 'go') $goRoot
Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue
if (-not (Test-Path (Join-Path $goBin 'go.exe'))) {
Write-Fail "Go extract produced no bin\go.exe at $goBin"
return
}
Write-OK "Go $needGo installed at $goRoot"
}
Add-ToUserPath $goBin
$env:Path = "$goBin;$env:Path"
}
# --------------------------------------------------------------- Inno Setup ---
function Find-Iscc {
$candidates = @(
(Join-Path $ToolDir 'InnoSetup7\ISCC.exe'),
"$env:ProgramFiles\Inno Setup 7\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
)
foreach ($c in $candidates) {
if ($c -and (Test-Path $c)) { return $c }
}
return ''
}
function Install-Inno {
$iscc = Find-Iscc
if ($iscc) {
Write-OK "Inno Setup ISCC found at $iscc"
return $iscc
}
$inno = $manifest.toolchain.inno
$exe = Join-Path $env:TEMP "innosetup-$($inno.version)-x64.exe"
if (-not (Test-Path $exe)) {
Write-Step "Downloading Inno Setup $($inno.version)"
Invoke-WebRequest -Uri $inno.url -OutFile $exe -UseBasicParsing
$h = (Get-FileHash $exe -Algorithm SHA256).Hash.ToUpper()
if ($h -ne $inno.sha256) {
Remove-Item $exe -Force
Write-Fail "Inno Setup installer sha256 mismatch: $h"
return ''
}
Write-OK "Downloaded and verified Inno Setup installer"
}
$dest = Join-Path $ToolDir 'InnoSetup7'
Write-Step "Installing Inno Setup per-user to $dest"
# /CURRENTUSER installs without admin; /DIR is honored in that mode.
$p = Start-Process -FilePath $exe -ArgumentList @(
'/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/CURRENTUSER',"/DIR=$dest"
) -Wait -PassThru
if ($p.ExitCode -ne 0 -or -not (Test-Path (Join-Path $dest 'ISCC.exe'))) {
Write-Fail "Inno Setup install failed (exit $($p.ExitCode)); ISCC not found at $dest"
return ''
}
Write-OK "Inno Setup installed; ISCC at $(Join-Path $dest 'ISCC.exe')"
Add-ToUserPath $dest
return (Join-Path $dest 'ISCC.exe')
}
# ------------------------------------------------- PATH (idempotent) ----------
function Add-ToUserPath($dir) {
if (-not $dir -or -not (Test-Path $dir)) { return }
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
if ($userPath -and ($userPath -split ';' -contains $dir)) {
Write-Skip "$dir already on user PATH"
return
}
$newPath = if ($userPath) { "$userPath;$dir" } else { $dir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-OK "Added $dir to user PATH"
}
# ------------------------------------------------ Vendor assets (idempotent) --
function Fetch-Asset($asset) {
$dest = Join-Path $vendorDir $asset.name
$ok = $false
if (Test-Path $dest) {
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -eq $asset.sha256) {
$ok = $true
Write-Skip "$($asset.name) present and checksum verified"
} else {
Write-Info "$($asset.name): stale or corrupt (got $h), re-downloading"
}
}
if (-not $ok) {
Write-Info "Downloading $($asset.name)"
Invoke-WebRequest -Uri $asset.url -OutFile $dest -UseBasicParsing
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -ne $asset.sha256) {
Remove-Item $dest -Force
Write-Fail "$($asset.name): sha256 mismatch ($h) - expected $($asset.sha256)"
return
}
Write-OK "$($asset.name) downloaded and checksum verified"
}
Set-Content -Path "$dest.sha256" -Value $asset.sha256 -NoNewline
}
function Ensure-Vendor {
Write-Step "Vendor assets (pinned in vendor-manifest.json)"
New-Item -ItemType Directory -Force -Path $vendorDir | Out-Null
foreach ($a in $manifest.assets) {
Fetch-Asset $a
}
}
# ----------------------------------------------------------------- Verify -----
function Assert-BuildReady {
Write-Step "Verification"
$go = Get-GoVersion
if ($go) { Write-OK "go $go" } elseif (-not $SkipGo) { Write-Fail 'go not found' }
$iscc = Find-Iscc
if ($iscc) { Write-OK "ISCC $iscc" } elseif (-not $SkipInno) { Write-Fail 'ISCC not found' }
foreach ($a in $manifest.assets) {
$dest = Join-Path $vendorDir $a.name
if (Test-Path $dest) {
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -eq $a.sha256) { Write-OK "$($a.name) verified" }
else { Write-Fail "$($a.name) checksum mismatch" }
} elseif (-not $SkipVendor) {
Write-Fail "$($a.name) missing"
}
}
if ($script:anyFailed) {
if ($CI) { throw 'Build environment setup failed' }
exit 1
}
Write-Host "Build environment ready." -ForegroundColor Green
}
# ------------------------------------------------------------------- Build ----
function Invoke-Build {
Write-Step "Building agent, tray, helper (windows amd64+arm64)"
$dist = Join-Path $RepoRoot 'dist'
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$flags = '-s -w'
# The tray and helper are GUI-subsystem binaries: no console window pops up
# when the installer starts the tray or the service spawns the helper.
$guiFlags = '-s -w -H=windowsgui'
foreach ($arch in @('amd64','arm64')) {
$env:GOOS='windows'; $env:GOARCH=$arch; $env:CGO_ENABLED='0'
go build "-ldflags=$flags" -o (Join-Path $dist "theta-agent-windows-$arch.exe") $RepoRoot
if ($LASTEXITCODE -ne 0) { Write-Fail "build agent windows/$arch failed"; return }
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-tray-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-tray')
if ($LASTEXITCODE -ne 0) { Write-Fail "build tray windows/$arch failed"; return }
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-helper-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-helper')
if ($LASTEXITCODE -ne 0) { Write-Fail "build helper windows/$arch failed"; return }
}
Remove-Item Env:GOOS,Env:GOARCH,Env:CGO_ENABLED -ErrorAction SilentlyContinue
Write-Step "Running go test ./..."
Push-Location $RepoRoot
go test ./...
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail 'go test failed'; return }
Pop-Location
$iscc = Find-Iscc
if (-not $iscc) { Write-Fail 'ISCC not found; cannot build installer'; return }
# The installer must not carry a stale hardcoded version: derive it from
# the tag on CI (GITHUB_REF_NAME, e.g. "v2.2.0"), else the nearest local
# tag, else a plain dev marker. Passed as -DMyAppVersion so
# installer.iss's #ifndef default is always overridden by the build.
$appVer = '0.0.0-dev'
if ($env:GITHUB_REF_NAME -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') {
$appVer = $matches[1]
} else {
$desc = git describe --tags --abbrev=0 2>$null
if ($desc -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') { $appVer = $matches[1] }
}
Write-Step "Compiling installer with ISCC (version $appVer)"
& $iscc "/DMyAppVersion=$appVer" (Join-Path $RepoRoot 'installer\windows\installer.iss')
if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' }
}
# ------------------------------------------------------------------- Main -----
New-Item -ItemType Directory -Force -Path $ToolDir | Out-Null
if (-not $SkipGo) { Install-Go }
if (-not $SkipInno) { $null = Install-Inno }
if (-not $SkipVendor) { Ensure-Vendor }
Assert-BuildReady
if ($Build) { Invoke-Build }
if ($script:anyFailed) {
if ($CI) { throw 'Setup failed' }
exit 1
}