build(win): idempotent setup script + verified vendor manifest; real installer build

The Windows CI/CD story is now one idempotent script that both local dev and
GitHub Actions run, so they cannot drift.

scripts/setup-build-env.ps1:
- idempotent: skips anything already present/valid; safe to re-run (verified
  no-op on second run)
- Go toolchain: pinned 1.22.2, user-space zip extract, no admin; accepts >= 1.22
- Inno Setup: pinned 7.0.2 from jrsoftware GitHub release, per-user install
  (/CURRENTUSER, no admin), sha256-verified installer
- vendor assets: fetches WireGuard MSI, VC++ redist, OpenCredential CP into
  installer/windows/vendor/ and verifies each against the pinned manifest;
  writes .sha256 sidecars
- -Build: builds agent/tray/helper for windows amd64+arm64, runs go test, and
  compiles the installer; -CI: fail loudly for workflows

installer/windows/vendor-manifest.json: pinned urls + sha256 for all three
third-party assets and the toolchain; nothing large is committed to git
(installer/windows/vendor/ is gitignored).

installer/windows/installer.iss: bundle + silently install the real
OpenCredential installer (Inno-built -> /VERYSILENT) and read /SERVER_URL and
/JOIN_KEY via {param:...}; validated by compiling with Inno Setup 7.0.2.

.github/workflows/build-windows.yml now delegates the entire build to the script
(setup-go + setup-build-env.ps1 -SkipGo -Build -CI), then hashes, optionally
signs with Azure Trusted Signing, attaches to the release, and publishes to the
SSO resource tree.

Built locally: dist/theta-agent-2.1.0-windows-amd64-setup.exe (63MB fully
offline bundle) plus windows amd64/arm64 agent, tray, and helper, all verified.
This commit is contained in:
2026-08-09 18:26:03 -07:00
parent e613874de5
commit ba2a619db5
7 changed files with 366 additions and 56 deletions
+12 -44
View File
@@ -4,6 +4,10 @@ name: build-windows
# installer; signs them with Azure Trusted Signing; generates SHA256SUMS; and
# publishes the artifacts to the SSO resource tree (DESIGN-WINDOWS.md §9).
#
# The build itself is driven by scripts/setup-build-env.ps1 (idempotent dev/build
# environment setup: pinned Go version, per-user Inno Setup, checksum-verified
# vendor assets). The workflow only adds signing and publishing on top.
#
# Signing requires GitHub OIDC federation to Azure. If the Azure secrets are
# not configured the workflow still builds and attaches unsigned artifacts.
@@ -29,56 +33,20 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Run unit tests
shell: bash
run: go test ./...
- name: Build agent, tray, helper
shell: bash
run: |
mkdir -p dist
LDFLAGS="-s -w"
for arch in amd64 arm64; do
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-windows-$arch.exe .
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-tray-windows-$arch.exe ./cmd/theta-agent-tray/
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-helper-windows-$arch.exe ./cmd/theta-agent-helper/
done
- name: Fetch vendored, vendor-signed dependencies
# One idempotent step sets up Inno Setup + fetches/verifies the vendor
# assets and builds binaries + installer + runs the test suite.
- name: Setup build environment and build
shell: pwsh
run: |
$vendor = "installer/windows/vendor"
New-Item -ItemType Directory -Force -Path $vendor | Out-Null
# Pinned by sha256; WireGuard MSI and VC++ redist are Microsoft/vendor signed.
$assets = @(
@{ url = "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi"; out = "wireguard-amd64-0.5.3.msi"; sha = "PINNED_WIREGUARD_SHA256" },
@{ url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; out = "vc_redist.x64.exe"; sha = "PINNED_VC_REDIST_SHA256" }
)
foreach ($a in $assets) {
$dest = Join-Path $vendor $a.out
Invoke-WebRequest -Uri $a.url -OutFile $dest
$h = (Get-FileHash -Algorithm SHA256 $dest).Hash.ToLower()
if ($h -ne $a.sha) { throw "sha256 mismatch for $($a.out): $h" }
Set-Content -Path "$dest.sha256" -Value $h
}
# OpenCredential (pGina fork) release — swap PINNED below for a real release URL.
$oc = "https://github.com/pedropablobm/OpenCredential/releases/latest/download/OpenCredential.zip"
Invoke-WebRequest -Uri $oc -OutFile (Join-Path $vendor "opencredential.zip")
Expand-Archive -Path (Join-Path $vendor "opencredential.zip") -DestinationPath (Join-Path $vendor "OpenCredential")
- name: Compile Inno installer
shell: pwsh
run: |
choco install innosetup -y --no-progress
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) { $iscc = "C:\Program Files\Inno Setup 6\ISCC.exe" }
& $iscc "/DMyAppVersion=${{ env.VERSION }}" installer/windows/installer.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/setup-build-env.ps1 -SkipGo -Build -CI
if ($LASTEXITCODE -ne 0) { throw "setup-build-env failed" }
- name: Generate SHA256SUMS
shell: pwsh
run: |
Get-ChildItem dist -File | Where-Object { $_.Extension -in ".exe",".msi" -or $_.Name -like "*setup*" } | ForEach-Object {
Get-ChildItem dist -File | Where-Object {
$_.Name -match 'windows-.*\.exe$|setup\.exe$' -or $_.Name -eq 'SHA256SUMS'
} | ForEach-Object {
"{0} {1}" -f (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLower(), $_.Name
} | Set-Content -Path dist/SHA256SUMS
+2
View File
@@ -6,3 +6,5 @@ dist/
agent.yml
!cmd/theta-agent-helper/
!cmd/theta-agent-helper/main.go
# Fetched by scripts/setup-build-env.ps1, pinned in installer/windows/vendor-manifest.json
installer/windows/vendor/
+7 -1
View File
@@ -183,13 +183,19 @@ One `.exe`, built on a connected machine, runnable on an air-gapped one. Bundles
- `theta-agent-windows-amd64.exe` (+ arm64) and `theta-agent-tray-windows-amd64.exe`
- `theta-agent-helper.exe` (desktop-control helper)
- OpenCredential CP binaries + **VC++ v14 redistributable** (its native runtime; .NET
- OpenCredential CP installer + **VC++ v14 redistributable** (its native runtime; .NET
Framework 4.8 is built into Windows 10/11 so needs no bundle)
- The official, vendor-signed WireGuard for Windows client (signed drivers install
offline without signature phone-home)
- OpenCredential's pre-seeded LDAP plugin config
- Agent `agent.yml` template + self-signed CP cert installed into the machine trusted root
The build environment is bootstrapped idempotently by
`scripts/setup-build-env.ps1` (pinned Go version, per-user Inno Setup install, and
vendor assets fetched into `installer/windows/vendor/` — verified against the pinned
sha256 in `installer/windows/vendor-manifest.json`). The CI workflow calls the same
script, so a local build and CI/CD cannot drift.
Install-time behavior:
- `/SILENT` supported; `SERVER_URL=` and `JOIN_KEY=` as install parameters (the SSO's
Binary file not shown.
+20 -11
View File
@@ -58,15 +58,14 @@ Source: "{#AgentDir}\theta-agent-windows-amd64.exe"; DestDir: "{app}"; Flags: ig
Source: "{#AgentDir}\theta-agent-tray-windows-amd64.exe"; DestDir: "{app}\tray"; Flags: ignoreversion
Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
; WireGuard for Windows — official, vendor-signed MSI. Install happens offline
; (the driver is signed; no signature phone-home).
; WireGuard for Windows — official, vendor-signed MSI. Installs offline (the
; driver is signed; no signature phone-home).
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion
; OpenCredential credential provider (BSD-3 pGina fork) + VC++ runtime it needs.
Source: "{#VendorDir}\OpenCredential\*"; DestDir: "{app}\OpenCredential"; Flags: ignoreversion recursesubdirs
; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++
; runtime it needs. Both install silently at [Run].
Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\vc_redist.x64.exe.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion
[Registry]
; Start the tray for every interactive logon.
@@ -75,23 +74,33 @@ Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType:
[Run]
; VC++ v14 runtime (OpenCredential native deps).
Filename: "{app}\vendor\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Installing VC++ runtime..."; Flags: runhidden waituntilterminated
; OpenCredential credential provider — must be registered before logon.
Filename: "{app}\vendor\OpenCredentialInstaller-1.0.0.0.exe"; Parameters: "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated
; WireGuard for Windows client.
Filename: "msiexec.exe"; Parameters: "/i ""{app}\vendor\wireguard-amd64-0.5.3.msi"" /qn /norestart"; StatusMsg: "Installing WireGuard client..."; Flags: runhidden waituntilterminated
; Register the agent as a SYSTEM auto-start service.
Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated
; Credential provider registration + agent.yml are handled in [Code] so we can
; feed SERVER_URL/JOIN_KEY in and sequence the CP install before logon.
Filename: "{app}\OpenCredential\OpenCredentialInstaller.exe"; Parameters: "/S"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated skipifsilent
[Code]
var
ServerURL: String;
JoinKey: String;
// Reads a custom setup command-line parameter (e.g. /SERVER_URL=https://...).
// {param:...} raises when the parameter is absent, so the exception becomes "".
function GetCmdParam(const Name: String): String;
begin
try
Result := ExpandConstant('{param:' + Name + '}');
except
Result := '';
end;
end;
function InitializeSetup(): Boolean;
begin
ServerURL := GetCmdLineParam('/SERVER_URL', '');
JoinKey := GetCmdLineParam('/JOIN_KEY', '');
ServerURL := GetCmdParam('SERVER_URL');
JoinKey := GetCmdParam('JOIN_KEY');
Result := True;
end;
+34
View File
@@ -0,0 +1,34 @@
{
"comment": "Pinned third-party assets bundled into the Windows installer (DESIGN-WINDOWS.md §8). The setup script (scripts/setup-build-env.ps1) fetches these into installer/windows/vendor/ and verifies sha256; nothing is committed to git.",
"assets": [
{
"name": "wireguard-amd64-0.5.3.msi",
"url": "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi",
"sha256": "76FCEC042C5989C5B816CD32EAED1E5B1C3B998A4B1C9ECA55F299E3314EF7E4",
"purpose": "Official WireGuard for Windows client (vendor-signed drivers install offline)"
},
{
"name": "vc_redist.x64.exe",
"url": "https://aka.ms/vs/17/release/vc_redist.x64.exe",
"sha256": "CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B",
"purpose": "VC++ v14 runtime required by the OpenCredential credential provider"
},
{
"name": "OpenCredentialInstaller-1.0.0.0.exe",
"url": "https://github.com/pedropablobm/OpenCredential/releases/download/v1.0.0.0/OpenCredentialInstaller-1.0.0.0.exe",
"sha256": "7687A99F0B3D6E910BBFB883C31231EB8C8F6E4439134CC3522A86CC63DA1A52",
"purpose": "OpenCredential (BSD-3 pGina fork) credential provider installer — LDAP-backed Windows logon"
}
],
"toolchain": {
"go": {
"version": "1.22.2",
"url": "https://go.dev/dl/go1.22.2.windows-amd64.zip"
},
"inno": {
"version": "7.0.2",
"url": "https://github.com/jrsoftware/issrc/releases/download/is-7_0_2/innosetup-7.0.2-x64.exe",
"sha256": "5AD54CA3DEF786F8F4212552E54CC6D8D61329E2D24A1CFEE0571D42C2684FF1"
}
}
}
+291
View File
@@ -0,0 +1,291 @@
<#
.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'
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=$flags" -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=$flags" -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 }
Write-Step "Compiling installer with ISCC"
& $iscc (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
}