#requires -Version 5.1 <# .SYNOPSIS Build the dockmv web UI and cross-compile release binaries. .DESCRIPTION Produces dist/dockmv--[.exe] for every target, one archive per binary (.zip for Windows, .tar.gz elsewhere) and dist/SHA256SUMS.txt. The PowerShell equivalent of `make release`, so a Windows host without make can cut the same artifacts. The web UI is built first: vite writes into internal/webui/dist, which the Go binary embeds, so the UI must be current before the compile step — that directory is committed, so -SkipUi is safe for a backend-only iteration. Runs anywhere: the Go toolchain cross-compiles with CGO_ENABLED=0, and no target needs a platform-specific linker. .EXAMPLE ./scripts/build-release.ps1 Build every default target, tagging archives with `git describe`. .EXAMPLE ./scripts/build-release.ps1 -Tag v1.2.0 -Targets windows/amd64, linux/amd64 Build two targets only, named v1.2.0. .EXAMPLE ./scripts/build-release.ps1 -SkipUi Reuse the committed internal/webui/dist instead of running npm. #> [CmdletBinding()] param( # os/arch pairs to build. Anything `go tool dist list` accepts works. [string[]]$Targets = @("linux/amd64", "linux/arm64", "darwin/arm64", "darwin/amd64", "windows/amd64"), # Version stamped into the binary and used in archive names. Defaults to # `git describe --tags --always --dirty`, matching the Makefile. [string]$Tag, # Go toolchain to build with. [string]$Go = "go", # Skip the `npm ci && npm run build` UI step. [switch]$SkipUi, # Remove dist/ before building. [switch]$Clean, # Build the binaries but skip archives and checksums. [switch]$NoArchive ) $ErrorActionPreference = "Stop" function Invoke-Checked { param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args) Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray & $Exe @Args if ($LASTEXITCODE -ne 0) { throw "'$Exe $($Args -join ' ')' failed with exit code $LASTEXITCODE." } } function Invoke-GoBuild { <# One cross-compile. GOOS/GOARCH/CGO_ENABLED are process-wide env vars, so they are saved and restored: leaving GOOS=windows set would silently poison every later `go` call in the same session. #> param( [Parameter(Mandatory)][string]$Go, [Parameter(Mandatory)][string]$Goos, [Parameter(Mandatory)][string]$Goarch, [Parameter(Mandatory)][string]$Output, [Parameter(Mandatory)][string]$Ldflags ) $saved = @{ GOOS = $env:GOOS GOARCH = $env:GOARCH CGO_ENABLED = $env:CGO_ENABLED } try { $env:GOOS = $Goos $env:GOARCH = $Goarch $env:CGO_ENABLED = "0" Invoke-Checked $Go @("build", "-trimpath", "-ldflags", $Ldflags, "-o", $Output, ".") } finally { foreach ($k in $saved.Keys) { if ($null -eq $saved[$k]) { Remove-Item "env:$k" -ErrorAction SilentlyContinue } else { Set-Item "env:$k" $saved[$k] } } } } function New-Archive { <# Archive one binary. .zip for Windows consumers, .tar.gz for the unix targets — a tar preserves the executable bit, which a zip made by Compress-Archive does not, so a downloaded linux binary stays runnable without a chmod. #> param( [Parameter(Mandatory)][string]$BinaryPath, [Parameter(Mandatory)][string]$ArchivePath ) if (Test-Path $ArchivePath) { Remove-Item -Force $ArchivePath } $dir = Split-Path -Parent $BinaryPath $name = Split-Path -Leaf $BinaryPath if ($ArchivePath.EndsWith(".zip")) { Compress-Archive -Path $BinaryPath -DestinationPath $ArchivePath } else { # bsdtar ships with Windows 10 1803+; -C keeps the archive flat so it # extracts as a bare binary rather than a dist/ tree. $tar = (Get-Command tar -ErrorAction SilentlyContinue).Source if (-not $tar) { throw "tar not found — needed for .tar.gz archives (pass -NoArchive to skip)." } Invoke-Checked $tar @("-czf", $ArchivePath, "-C", $dir, $name) } } $repoRoot = Split-Path -Parent $PSScriptRoot Push-Location $repoRoot try { if (-not $Tag) { try { $Tag = (git describe --tags --always --dirty 2>$null).Trim() } catch { } if (-not $Tag) { $Tag = "dev" } } $bin = "dockmv" $distDir = Join-Path $repoRoot "dist" $ldflags = "-s -w -X main.version=$Tag" Write-Host "dockmv release build" -ForegroundColor Cyan Write-Host " go : $Go" Write-Host " tag : $Tag" Write-Host " targets : $($Targets -join ', ')" Write-Host " output : $distDir" Write-Host "" if ($Clean) { Write-Host "Cleaning dist/..." -ForegroundColor Cyan if (Test-Path $distDir) { Remove-Item -Recurse -Force $distDir } } New-Item -ItemType Directory -Force -Path $distDir | Out-Null # --- Web UI --------------------------------------------------------------- # vite's outDir is internal/webui/dist, which embed.go embeds, so this has to # run before the compile — not after, and not in parallel with it. if (-not $SkipUi) { Write-Host "Building web UI..." -ForegroundColor Cyan # npm.cmd, not npm(.ps1): the ps1 shim reparses $MyInvocation.Line as text # when called via `& $Exe @Args`, which mangles splatted args into a bogus # single token and makes npm print usage instead of running the command. $npmCmd = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source if (-not $npmCmd) { throw "npm not found — install Node 22+, or pass -SkipUi to reuse the committed internal/webui/dist." } Push-Location (Join-Path $repoRoot "web") try { Invoke-Checked $npmCmd @("ci", "--no-audit", "--no-fund") Invoke-Checked $npmCmd @("run", "build") } finally { Pop-Location } Write-Host "" } $embedded = Join-Path $repoRoot "internal/webui/dist/index.html" if (-not (Test-Path $embedded)) { throw "internal/webui/dist/index.html is missing — the binary would serve an empty UI. Run without -SkipUi." } # --- Compile -------------------------------------------------------------- Write-Host "Cross-compiling..." -ForegroundColor Cyan $built = @() foreach ($target in $Targets) { $parts = $target -split "/" if ($parts.Count -ne 2) { throw "target '$target' is not in os/arch form." } $goos, $goarch = $parts $ext = if ($goos -eq "windows") { ".exe" } else { "" } $binaryPath = Join-Path $distDir "$bin-$goos-$goarch$ext" Write-Host " $goos/$goarch" -ForegroundColor DarkGray Invoke-GoBuild -Go $Go -Goos $goos -Goarch $goarch -Output $binaryPath -Ldflags $ldflags if (-not (Test-Path $binaryPath)) { throw "go build reported success but $binaryPath is missing." } $archivePath = $null if (-not $NoArchive) { $archiveExt = if ($goos -eq "windows") { "zip" } else { "tar.gz" } $archivePath = Join-Path $distDir "$bin-$Tag-$goos-$goarch.$archiveExt" New-Archive -BinaryPath $binaryPath -ArchivePath $archivePath } $built += [pscustomobject]@{ Target = $target Binary = $binaryPath Archive = $archivePath SizeMb = [math]::Round((Get-Item $binaryPath).Length / 1MB, 1) } } Write-Host "" # --- Checksums ------------------------------------------------------------ # Over the archives, since those are what a release serves. Written in the # `sha256sum -c` format so it verifies on a linux host as-is. $sumsPath = $null if (-not $NoArchive) { Write-Host "Writing checksums..." -ForegroundColor Cyan $sumsPath = Join-Path $distDir "SHA256SUMS.txt" $lines = foreach ($b in $built) { $hash = (Get-FileHash -Algorithm SHA256 $b.Archive).Hash.ToLower() "$hash $(Split-Path -Leaf $b.Archive)" } # ASCII with LF: a BOM or CRLF makes `sha256sum -c` reject the first line. [System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new()) Write-Host "" } Write-Host "Done. Built:" -ForegroundColor Green foreach ($b in $built) { Write-Host " $($b.Binary) ($($b.SizeMb) MB)" -ForegroundColor Green if ($b.Archive) { Write-Host " $($b.Archive)" -ForegroundColor Green } } if ($sumsPath) { Write-Host " $sumsPath" -ForegroundColor Green } } finally { Pop-Location }