diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index befa01d..ec00592 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -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 diff --git a/.gitignore b/.gitignore index a03c039..5181c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DESIGN-WINDOWS.md b/DESIGN-WINDOWS.md index 1bac02b..ee83072 100644 --- a/DESIGN-WINDOWS.md +++ b/DESIGN-WINDOWS.md @@ -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 diff --git a/dist/theta-agent-windows-arm64.exe b/dist/theta-agent-windows-arm64.exe index 7fcf5dc..8ff5471 100755 Binary files a/dist/theta-agent-windows-arm64.exe and b/dist/theta-agent-windows-arm64.exe differ diff --git a/installer/windows/installer.iss b/installer/windows/installer.iss index 8bf2921..73a88a4 100644 --- a/installer/windows/installer.iss +++ b/installer/windows/installer.iss @@ -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; diff --git a/installer/windows/vendor-manifest.json b/installer/windows/vendor-manifest.json new file mode 100644 index 0000000..813fa0a --- /dev/null +++ b/installer/windows/vendor-manifest.json @@ -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" + } + } +} diff --git a/scripts/setup-build-env.ps1 b/scripts/setup-build-env.ps1 new file mode 100644 index 0000000..51aeb21 --- /dev/null +++ b/scripts/setup-build-env.ps1 @@ -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 +}