Files
brass-and-sigil/scripts/Deploy-Brass.ps1
T
Matt 4ee4a2bb43 scripts: make Build-Pack and Deploy-Brass cross-platform
Two small fixes that surface when running the deploy pipeline on a
non-Windows host (we now do this on glados via the Telegram bridge):

- Build-Pack.ps1: fall back to launcher/ModpackLauncher.csproj <Version>
  when Get-Item.VersionInfo.FileVersion returns empty. Linux PowerShell
  can't parse PE FileVersion from a Windows .exe, so the previous code
  threw "set <Version> in ModpackLauncher.csproj and republish" -- but
  the csproj <Version> *was* set, just not readable from a foreign PE.
  Appends ".0" so the embedded value still matches the 4-part form the
  Windows compile bakes in.

- Deploy-Brass.ps1: prefer rsync where available, fall back to robocopy
  otherwise. robocopy is Windows-only and bails on Linux/macOS hosts.
2026-05-22 14:23:09 +00:00

371 lines
20 KiB
PowerShell

#requires -Version 5
<#
.SYNOPSIS
One-shot deploy: build launcher + server, regenerate manifest, mirror the
deploy share, scp the server binary.
.DESCRIPTION
Reads `deploy.config.ps1` (sibling file, gitignored) for local paths +
SSH details. Stages run in order; -Stage limits which stages run.
The script does NOT auto-restart the production daemon. After a server
binary deploy it prompts you to do that yourself.
.PARAMETER Stage
All | Launcher | Server | Pack. Defaults to All.
Launcher = build launcher + regenerate manifest + push to deploy share
Server = build server + scp binary (atomic swap)
Pack = regenerate manifest + mirror pack/overrides/* to share
All = everything, in order
.PARAMETER SkipBuild
Skip dotnet publish steps. Use when you've already built and just want
to push artifacts.
.PARAMETER DryRun
Print each action without executing. No files copied, no SSH, no build.
#>
[CmdletBinding()]
param(
[ValidateSet('All','Launcher','Server','Pack')]
[string]$Stage = 'All',
[switch]$SkipBuild,
[switch]$DryRun,
# Skip the version-bump check. Use only for cosmetic/internal-only changes
# where you're SURE clients don't need to re-sync. The default is to refuse
# if the local pack/launcher version matches what's already deployed.
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# ─── Resolve repo + load config ────────────────────────────────────────────
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $here '..')
$cfgPath = Join-Path $here 'deploy.config.ps1'
if (-not (Test-Path $cfgPath)) {
throw "Missing $cfgPath. Copy deploy.config.template.ps1 -> deploy.config.ps1 and fill in real values."
}
. $cfgPath
# Sanity-check required vars actually got set (template ships with CHANGE_ME).
# $ServerSshKey is only required for SSH-mode deploys; skip it when ServerSshHost = 'local'.
$requiredVars = @('DeployShare','ServerSshHost','ServerBinaryRemote')
if ($ServerSshHost -ne 'local') { $requiredVars += 'ServerSshKey' }
foreach ($v in $requiredVars) {
$val = Get-Variable -Name $v -ValueOnly -ErrorAction Stop
if ($val -match 'CHANGE_ME' -or [string]::IsNullOrWhiteSpace($val)) {
throw "deploy.config.ps1 has placeholder/empty `$$v. Fill it in."
}
}
$shouldRunLauncher = $Stage -in @('All','Launcher')
$shouldRunServer = $Stage -in @('All','Server')
$shouldRunPack = $Stage -in @('All','Launcher','Pack') # manifest needs launcher exe meta
# ─── Pre-flight: version-bump check ────────────────────────────────────────
# The launcher caches the pack by version: a client that already synced
# pack v0.9.2 will short-circuit at "already on 0.9.2" if you re-deploy
# under the same version, even if the file list / SHAs changed. Same idea
# applies to launcherVersion (drives the in-launcher upgrade banner).
# We fetch the currently-deployed manifest and refuse to deploy if the
# matching version field hasn't been bumped. Use -Force to override (e.g.
# for cosmetic-only changes where re-sync isn't needed).
if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force) {
if (-not $ManifestPublicUrl -or $ManifestPublicUrl -match 'CHANGE_ME') {
throw "deploy.config.ps1 is missing `$ManifestPublicUrl. Set it to the deployed manifest URL (e.g. https://example.com/pack/manifest.json)."
}
$deployed = $null
try {
$deployed = Invoke-RestMethod -Uri $ManifestPublicUrl -TimeoutSec 8
} catch {
Write-Host "Pre-flight: couldn't fetch deployed manifest at $ManifestPublicUrl -- version check skipped (probably first deploy)." -ForegroundColor DarkGray
}
if ($deployed) {
$errs = @()
# Pack version check applies only when the user is explicitly deploying
# pack content (Stage = All or Pack). A launcher-only deploy intentionally
# leaves pack content alone, so the pack version SHOULD stay constant.
$strictPackCheck = $Stage -in @('All','Pack')
if ($strictPackCheck) {
$lock = Get-Content (Join-Path $repoRoot 'pack\pack.lock.json') -Raw | ConvertFrom-Json
if ($deployed.version -eq $lock.version) {
$errs += "Pack version is unchanged ($($lock.version)). Clients cached at that version will SKIP the sync -- they won't pick up your pack changes. Bump 'version' in pack/pack.lock.json before deploying."
}
}
if ($shouldRunLauncher) {
$csprojPath = Join-Path $repoRoot 'launcher\ModpackLauncher.csproj'
[xml]$csproj = Get-Content $csprojPath
$localLauncherVersion = ($csproj.Project.PropertyGroup.Version | Where-Object { $_ }) | Select-Object -First 1
# Manifest stores 4-part FileVersion (e.g. "0.4.4.0"); csproj <Version> is 3-part ("0.4.4"). Compare normalised.
$deployedNorm = ($deployed.launcherVersion -replace '\.0+$','')
$localNorm = ($localLauncherVersion -replace '\.0+$','')
if ($deployedNorm -eq $localNorm -and $localNorm) {
$errs += "Launcher version is unchanged ($localLauncherVersion). Existing 0.4.x installs won't see an upgrade prompt -- bump <Version> in launcher/ModpackLauncher.csproj before deploying."
}
}
if ($errs.Count -gt 0) {
Write-Host ""
Write-Host "VERSION-BUMP CHECK FAILED:" -ForegroundColor Red
foreach ($e in $errs) { Write-Host " - $e" -ForegroundColor Red }
Write-Host ""
Write-Host "If you're re-deploying without any user-visible changes, pass -Force to skip this check." -ForegroundColor DarkGray
exit 1
}
}
}
# ─── Pre-flight: was the daemon already running? ───────────────────────────
# We don't auto-stop it (kicks active players to fix a problem that isn't
# actually broken -- the atomic swap is safe with the daemon running). But
# knowing the state up front lets us tailor the final "next steps" message
# so a deploy success doesn't silently leave you on the old code.
# Set $ServerSshHost = 'local' in deploy.config.ps1 to skip SSH entirely
# and copy/inspect the server binary on the local filesystem. Use when the
# deploy machine IS the server (e.g. running Deploy-Brass.ps1 on glados).
$isLocalDeploy = ($ServerSshHost -eq 'local')
$daemonWasRunning = $false
if ($shouldRunServer) {
if ($isLocalDeploy) {
Write-Host "Pre-flight: checking daemon state locally..." -ForegroundColor DarkGray
$pgrepOut = & pgrep -f '/brass-sigil-server($| )' 2>$null
} else {
Write-Host "Pre-flight: checking daemon state on $ServerSshHost..." -ForegroundColor DarkGray
# Single-quoted PS string so PowerShell doesn't try to interpret the
# bash-side metacharacters. Match the binary path followed by either
# end-of-line OR a space, so we catch `brass-sigil-server run` (the
# actual invocation) without matching the `/brass-sigil-server/...`
# directory path that appears in the run.sh wrapper's argv.
$remoteCmd = 'pgrep -f /brass-sigil-server($| ) 2>/dev/null'
$pgrepOut = & ssh -i $ServerSshKey -o ConnectTimeout=5 -o BatchMode=yes $ServerSshHost $remoteCmd 2>$null
}
$daemonWasRunning = -not [string]::IsNullOrWhiteSpace($pgrepOut)
if ($daemonWasRunning) {
Write-Host " Daemon is RUNNING. Atomic swap is safe -- but you'll need to" -ForegroundColor Yellow
Write-Host " stop+start it after deploy for the new code to take effect." -ForegroundColor Yellow
} else {
Write-Host " Daemon is stopped. New binary will run as soon as you start it." -ForegroundColor DarkGray
}
}
# ─── Helpers ───────────────────────────────────────────────────────────────
$stepNum = 0
function Step($desc, [scriptblock]$body) {
$script:stepNum++
$start = Get-Date
Write-Host ""
Write-Host ("[{0}] {1}" -f $script:stepNum, $desc) -ForegroundColor Cyan
if ($DryRun) {
Write-Host " (dry-run, skipping)" -ForegroundColor DarkGray
return
}
& $body
$elapsed = (Get-Date) - $start
Write-Host (" done in {0:N1}s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray
}
# ─── Stage 1: build launcher ───────────────────────────────────────────────
$launcherExe = Join-Path $repoRoot (Join-Path $LauncherPublishDir $LauncherExeName)
if ($shouldRunLauncher -and -not $SkipBuild) {
Step "Build launcher (dotnet publish launcher\)" {
Push-Location (Join-Path $repoRoot 'launcher')
try { dotnet publish -c Release -nologo | Out-Host }
finally { Pop-Location }
if (-not (Test-Path $launcherExe)) { throw "Launcher publish didn't produce $launcherExe" }
}
}
# ─── Stage 2: build server ─────────────────────────────────────────────────
$serverExe = Join-Path $repoRoot (Join-Path $ServerPublishDir $ServerExeName)
if ($shouldRunServer -and -not $SkipBuild) {
Step "Build server (dotnet publish server\ -r linux-x64)" {
Push-Location (Join-Path $repoRoot 'server')
try { dotnet publish -c Release -r linux-x64 --self-contained true -nologo | Out-Host }
finally { Pop-Location }
if (-not (Test-Path $serverExe)) { throw "Server publish didn't produce $serverExe" }
}
}
# ─── Stage 3: regenerate manifest ──────────────────────────────────────────
$manifestPath = Join-Path $here 'manifest.json'
if ($shouldRunPack) {
Step "Regenerate manifest (Build-Pack.ps1)" {
$args = @{ OutputPath = $manifestPath }
# Always embed launcher metadata if a local build exists -- otherwise a
# Stage=Pack deploy silently strips launcherVersion/launcherUrl from the
# published manifest, and old launchers stop seeing upgrade banners.
# Tied to Stage=Launcher only would mean every pack-only deploy regresses.
if (Test-Path $launcherExe) {
$args.LauncherExePath = $launcherExe
} else {
Write-Host " WARNING: launcher exe not found at $launcherExe; manifest will have no launcher metadata." -ForegroundColor Yellow
Write-Host " If you want launcherVersion/launcherUrl populated, run -Stage Launcher (or All) first." -ForegroundColor Yellow
}
& (Join-Path $here 'Build-Pack.ps1') @args | Out-Host
}
}
# ─── Stage 4: mirror pack overrides to share ──────────────────────────────
$overridesLocal = Join-Path $repoRoot 'pack\overrides'
$shareFiles = Join-Path $DeployShare 'files'
if ($shouldRunPack -and (Test-Path $overridesLocal)) {
Step "Mirror pack/overrides/ -> $shareFiles" {
# Prefer rsync where available (Linux/macOS); fall back to Windows robocopy.
# Both perform a mirror: orphan files in the destination are removed so
# the share matches the local overrides exactly.
if (Get-Command rsync -ErrorAction SilentlyContinue) {
$src = ($overridesLocal -replace '\\','/').TrimEnd('/') + '/'
$dst = ($shareFiles -replace '\\','/').TrimEnd('/') + '/'
if (-not (Test-Path $shareFiles)) { New-Item -ItemType Directory -Path $shareFiles -Force | Out-Null }
& rsync -a --delete $src $dst
if ($LASTEXITCODE -ne 0) { throw "rsync failed with exit $LASTEXITCODE" }
}
elseif (Get-Command robocopy -ErrorAction SilentlyContinue) {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
}
else {
throw "Neither rsync nor robocopy is available -- can't mirror pack overrides."
}
}
}
# ─── Stage 5: publish launcher exe (only when Stage includes Launcher) ─────
if ($shouldRunLauncher) {
Step "Copy launcher.exe -> $DeployShare" {
Copy-Item $launcherExe (Join-Path $DeployShare $LauncherDeployedAs) -Force
}
}
# ─── Stage 6: publish manifest (any time pack content changed) ─────────────
# Manifest is a pack artifact, not a launcher artifact -- a Pack-only deploy
# (e.g. tweak jar or pack version bump) still needs the new manifest to land
# on the share so clients see the updated SHA-1 list and pack version.
if ($shouldRunPack) {
Step "Copy manifest.json -> $DeployShare" {
Copy-Item $manifestPath (Join-Path $DeployShare 'manifest.json') -Force
}
}
# ─── Stage 6: copy + atomic swap server binary ─────────────────────────────
if ($shouldRunServer) {
if ($isLocalDeploy) {
Step "Copy server binary -> $ServerBinaryRemote (local atomic swap)" {
$localNew = "$ServerBinaryRemote.new"
Copy-Item $serverExe $localNew -Force
& chmod +x $localNew
if ($LASTEXITCODE -ne 0) { throw "chmod failed with exit $LASTEXITCODE" }
Move-Item $localNew $ServerBinaryRemote -Force
$md5 = (& md5sum $ServerBinaryRemote) -split '\s+' | Select-Object -First 1
Write-Host " md5: $md5" -ForegroundColor DarkGray
}
} else {
Step "scp server binary -> $ServerSshHost`:$ServerBinaryRemote (atomic swap)" {
$remoteNew = "$ServerBinaryRemote.new"
& scp -i $ServerSshKey -o ConnectTimeout=15 $serverExe "$ServerSshHost`:$remoteNew"
if ($LASTEXITCODE -ne 0) { throw "scp failed with exit $LASTEXITCODE" }
$cmd = "chmod +x '$remoteNew' && mv '$remoteNew' '$ServerBinaryRemote' && md5sum '$ServerBinaryRemote'"
& ssh -i $ServerSshKey -o ConnectTimeout=10 $ServerSshHost $cmd
if ($LASTEXITCODE -ne 0) { throw "ssh swap failed with exit $LASTEXITCODE" }
}
}
Write-Host ""
if ($daemonWasRunning) {
Write-Host "Server binary swapped on disk, but the daemon was running before this" -ForegroundColor Yellow
Write-Host "deploy and is still on the OLD code in memory (Linux preserves the running" -ForegroundColor Yellow
Write-Host "inode through rename). Stop + start the daemon to pick up the new build." -ForegroundColor Yellow
} else {
Write-Host "Server binary swapped on disk. Daemon was stopped -- start it whenever" -ForegroundColor Green
Write-Host "you're ready and it'll run the new build." -ForegroundColor Green
}
}
# ─── Stage 7: verify deployed manifest ────────────────────────────────────
# Fetch what's actually live and assert the fields we expect. Catches:
# - share copy silently failed (clients see old manifest)
# - manifest was written without launcher metadata
# - URLs in the manifest still point at stale hostnames
# Pre-flight already version-checks the OLD manifest; this checks the NEW one.
if ($shouldRunPack -and -not $DryRun -and $ManifestPublicUrl) {
Step "Verify deployed manifest at $ManifestPublicUrl" {
Start-Sleep -Seconds 1 # give the share a moment to flush
$live = $null
try { $live = Invoke-RestMethod -Uri $ManifestPublicUrl -TimeoutSec 8 }
catch { throw "Failed to fetch deployed manifest: $($_.Exception.Message)" }
$expectedHost = ([uri]$ManifestPublicUrl).Host
$lock = Get-Content (Join-Path $repoRoot 'pack\pack.lock.json') -Raw | ConvertFrom-Json
$problems = @()
$hasLockField = { param($name) ($lock.PSObject.Properties.Name -contains $name) -and $lock.$name }
# ── Top-level required fields (sourced from pack.lock) ──
if (-not $live.name) { $problems += "name missing" }
elseif ($live.name -ne $lock.name) { $problems += "name mismatch: live=$($live.name), local=$($lock.name)" }
if (-not $live.version) { $problems += "version missing" }
elseif ($live.version -ne $lock.version) { $problems += "version mismatch: live=$($live.version), local=$($lock.version)" }
if (-not $live.minecraft.version) { $problems += "minecraft.version missing" }
elseif ($live.minecraft.version -ne $lock.minecraft) { $problems += "minecraft.version mismatch: live=$($live.minecraft.version), local=$($lock.minecraft)" }
if (-not $live.loader.type) { $problems += "loader.type missing" }
elseif ($live.loader.type -ne $lock.loader.type) { $problems += "loader.type mismatch: live=$($live.loader.type), local=$($lock.loader.type)" }
if (-not $live.loader.version) { $problems += "loader.version missing" }
elseif ($live.loader.version -ne $lock.loader.version) { $problems += "loader.version mismatch: live=$($live.loader.version), local=$($lock.loader.version)" }
# ── Launcher metadata (always required once a launcher has ever shipped) ──
if (-not $live.launcherVersion) { $problems += "launcherVersion missing (old launchers won't see upgrade banner)" }
if (-not $live.launcherUrl) { $problems += "launcherUrl missing" }
if ($live.launcherUrl -and ([uri]$live.launcherUrl).Host -ne $expectedHost) {
$problems += "launcherUrl host is $(([uri]$live.launcherUrl).Host), expected $expectedHost"
}
# ── Optional fields: consistency check. If pack.lock has it, manifest must too. ──
if ((& $hasLockField 'panelUrl') -and -not $live.panelUrl) {
$problems += "panelUrl missing from manifest but set in pack.lock.json (friend whitelist requests will fail)"
}
if ((& $hasLockField 'defaultShader') -and -not $live.defaultShader) {
$problems += "defaultShader missing from manifest but set in pack.lock.json"
}
if ((& $hasLockField 'defaultServer') -and (-not $live.defaultServer -or -not $live.defaultServer.ip)) {
$problems += "defaultServer missing/incomplete in manifest but set in pack.lock.json"
}
# ── files[]: every entry has the shape clients depend on; self-hosted URLs use expected host ──
if (-not $live.files -or $live.files.Count -eq 0) {
$problems += "files array is empty"
} else {
$i = 0
foreach ($f in $live.files) {
if (-not $f.path) { $problems += "files[$i].path missing" }
if (-not $f.url) { $problems += "files[$i].url missing (path=$($f.path))" }
if (-not $f.sha1 -or $f.sha1.Length -ne 40) { $problems += "files[$i].sha1 invalid (path=$($f.path))" }
if (-not $f.size -or $f.size -le 0) { $problems += "files[$i].size invalid (path=$($f.path))" }
# Self-hosted (non-CDN) URLs must use the expected host. CDN mods are exempt.
if ($f.url -and $f.url -notmatch 'modrinth|forgecdn|curseforge') {
$h = ([uri]$f.url).Host
if ($h -ne $expectedHost) {
$problems += "files[$i] self-hosted URL host is $h, expected $expectedHost (path=$($f.path))"
}
}
$i++
}
}
if ($problems.Count -gt 0) {
Write-Host ""
Write-Host "DEPLOYED MANIFEST FAILED VERIFICATION:" -ForegroundColor Red
foreach ($p in $problems) { Write-Host " - $p" -ForegroundColor Red }
throw "Deployed manifest has $($problems.Count) issue(s). Fix and redeploy."
}
Write-Host (" version={0} launcherVersion={1} files={2} ok" -f $live.version, $live.launcherVersion, $live.files.Count) -ForegroundColor DarkGray
}
}
Write-Host ""
Write-Host "Deploy finished." -ForegroundColor Green