# Copy of D:\TTRPG-Release\publish.ps1 - keep in sync when updating the release folder tool. param( [switch]$CheckOnly, [switch]$SkipMac ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ReleaseDir = $PSScriptRoot $ConfigPath = Join-Path $ReleaseDir 'publish-config.json' function Expand-ConfigPath([string]$value) { if ($value -match '%([^%]+)%') { $envName = $Matches[1] $envVal = [Environment]::GetEnvironmentVariable($envName) if ($null -ne $envVal) { return $value.Replace("%$envName%", $envVal) } } return $value } function Write-Title([string]$text) { Write-Host '' Write-Host "=== $text ===" -ForegroundColor Cyan } function Write-Ok([string]$text) { Write-Host " [OK] $text" -ForegroundColor Green } function Write-Fail([string]$text) { Write-Host " [!!] $text" -ForegroundColor Red } function Write-Warn([string]$text) { Write-Host " [--] $text" -ForegroundColor Yellow } function Get-YmlVersion([string]$ymlPath) { $content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8 $m = [regex]::Match($content, '(?m)^version:\s*(\S+)\s*$') if ($m.Success) { return $m.Groups[1].Value.Trim() } return $null } function Get-YmlPrimaryPath([string]$ymlPath) { $content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8 $m = [regex]::Match($content, '(?m)^path:\s*(\S+)\s*$') if ($m.Success) { return $m.Groups[1].Value.Trim() } return $null } function Get-YmlReferencedFiles([string]$ymlPath) { $names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8 foreach ($m in [regex]::Matches($content, '(?m)^(?:\s*-\s*)?(?:url|path):\s*(\S+)\s*$')) { [void]$names.Add($m.Groups[1].Value.Trim()) } return @($names) } function Get-YmlOptionalUrls([string]$ymlPath) { $primary = Get-YmlPrimaryPath $ymlPath $names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $content = Get-Content -LiteralPath $ymlPath -Raw -Encoding UTF8 foreach ($m in [regex]::Matches($content, '(?m)^\s*-\s*url:\s*(\S+)\s*$')) { $name = $m.Groups[1].Value.Trim() if ($primary -and $name.Equals($primary, [StringComparison]::OrdinalIgnoreCase)) { continue } [void]$names.Add($name) } return @($names) } function Resolve-ReleaseFile([string]$name) { $direct = Join-Path $ReleaseDir $name if (Test-Path -LiteralPath $direct) { return Get-Item -LiteralPath $direct } if ($name -match 'x64' -and $name -notmatch 'x86_64') { $alt = $name -replace 'x64', 'x86_64' $altPath = Join-Path $ReleaseDir $alt if (Test-Path -LiteralPath $altPath) { return Get-Item -LiteralPath $altPath } } if ($name -match 'x86_64') { $alt = $name -replace 'x86_64', 'x64' $altPath = Join-Path $ReleaseDir $alt if (Test-Path -LiteralPath $altPath) { return Get-Item -LiteralPath $altPath } } return $null } # electron-builder may leave TTRPGPlayer-x86_64.AppImage while yml/site expect x64. # Prefer the newer x86_64 build and force the feed name before upload. function Normalize-LinuxAppImageNames { $imgs = Get-ChildItem -LiteralPath $ReleaseDir -Filter 'TTRPGPlayer-*.AppImage' -File -ErrorAction SilentlyContinue foreach ($img in $imgs) { if ($img.Name -notmatch 'x86_64') { continue } $toName = $img.Name -replace 'x86_64', 'x64' $toPath = Join-Path $ReleaseDir $toName if (Test-Path -LiteralPath $toPath) { Remove-Item -LiteralPath $toPath -Force Write-Warn "removed stale $toName (replaced by $($img.Name))" } Rename-Item -LiteralPath $img.FullName -NewName $toName Write-Ok "normalized $($img.Name) -> $toName" } } function Add-Upload { param( [System.Collections.Generic.Dictionary[string, string]]$map, [string]$remoteName, [System.IO.FileInfo]$file ) if ($map.ContainsKey($remoteName)) { $prev = $map[$remoteName] if (-not $prev.Equals($file.FullName, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "upload name collision for $remoteName: keeping $($file.Name)" } } $map[$remoteName] = $file.FullName } Write-Title 'TTRPG Release Publisher' Write-Host "Release folder: $ReleaseDir" if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Missing publish-config.json: $ConfigPath" } $config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json $sshKey = Expand-ConfigPath $config.sshKey $sshTarget = [string]$config.sshTarget $remoteDir = [string]$config.remoteDir $feedUrl = [string]$config.feedUrl if (-not (Test-Path -LiteralPath $sshKey)) { throw "SSH key not found: $sshKey" } $errors = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() # remote file name -> local full path (scp must use the feed/site name, not disk alias) $uploadMap = [System.Collections.Generic.Dictionary[string, string]]::new([StringComparer]::OrdinalIgnoreCase) Write-Title 'Windows (required)' $winYml = Join-Path $ReleaseDir 'latest.yml' if (-not (Test-Path -LiteralPath $winYml)) { $errors.Add('Missing latest.yml') } else { Write-Ok 'latest.yml' Add-Upload $uploadMap ([System.IO.Path]::GetFileName($winYml)) (Get-Item -LiteralPath $winYml) foreach ($name in (Get-YmlReferencedFiles $winYml)) { $file = Resolve-ReleaseFile $name if ($null -eq $file) { $errors.Add("Windows: missing file $name (from latest.yml)") } else { Write-Ok $file.Name Add-Upload $uploadMap $name $file } } $blockmap = Resolve-ReleaseFile 'TTRPGPlayer-Setup.exe.blockmap' if ($null -eq $blockmap) { $warnings.Add('Missing TTRPGPlayer-Setup.exe.blockmap (recommended)') } else { Write-Ok $blockmap.Name Add-Upload $uploadMap 'TTRPGPlayer-Setup.exe.blockmap' $blockmap } } Write-Title 'Linux (if latest-linux*.yml present)' Normalize-LinuxAppImageNames $linuxYmls = Get-ChildItem -LiteralPath $ReleaseDir -Filter 'latest-linux*.yml' -File -ErrorAction SilentlyContinue if ($linuxYmls.Count -eq 0) { Write-Warn 'No latest-linux*.yml - skipping Linux' } else { foreach ($yml in $linuxYmls) { Write-Ok $yml.Name Add-Upload $uploadMap $yml.Name $yml foreach ($name in (Get-YmlReferencedFiles $yml.FullName)) { $file = Resolve-ReleaseFile $name if ($null -eq $file) { $errors.Add("Linux ($($yml.Name)): missing file $name") } else { if ($file.Name -ne $name) { Write-Warn "$($yml.Name): yml expects $name, disk has $($file.Name) - uploading as $name" } else { Write-Ok "$($yml.Name) -> $name" } Add-Upload $uploadMap $name $file } } } } Write-Title 'macOS (if latest-mac.yml present)' $macYml = Join-Path $ReleaseDir 'latest-mac.yml' if ($SkipMac) { Write-Warn 'macOS skipped (-SkipMac)' } elseif (-not (Test-Path -LiteralPath $macYml)) { Write-Warn 'No latest-mac.yml - skipping macOS' } else { Write-Ok 'latest-mac.yml' Add-Upload $uploadMap ([System.IO.Path]::GetFileName($macYml)) (Get-Item -LiteralPath $macYml) $macVersion = Get-YmlVersion $macYml $winYml = Join-Path $ReleaseDir 'latest.yml' $winVersion = $null if (Test-Path -LiteralPath $winYml) { $winVersion = Get-YmlVersion $winYml } if ($macVersion -and $winVersion -and $macVersion -ne $winVersion) { $errors.Add( "macOS: latest-mac.yml is v$macVersion but latest.yml is v$winVersion (stale Mac feed). " + 'On Mac run: npm run pack:mac, then copy latest-mac.yml + TTRPGPlayer-*.zip (+ optional *.dmg). ' + 'Or publish Win/Linux only: publish.cmd -SkipMac' ) } $primaryName = Get-YmlPrimaryPath $macYml if (-not $primaryName) { $errors.Add('macOS: latest-mac.yml has no path: entry') } else { $primaryFile = Resolve-ReleaseFile $primaryName if ($null -eq $primaryFile) { $errors.Add( "macOS: missing primary update file $primaryName (path in latest-mac.yml). " + 'electron-updater on macOS needs the .zip from pack:mac, not only .dmg. Rebuild on Mac or use -SkipMac.' ) } else { Write-Ok "primary update: $($primaryFile.Name)" Add-Upload $uploadMap $primaryName $primaryFile } } foreach ($name in (Get-YmlOptionalUrls $macYml)) { $file = Resolve-ReleaseFile $name if ($null -eq $file) { $warnings.Add("macOS: optional file missing (not uploaded): $name") } else { Write-Ok "optional: $($file.Name)" Add-Upload $uploadMap $name $file } } } Write-Title 'Summary' foreach ($w in $warnings) { Write-Warn $w } if ($errors.Count -gt 0) { foreach ($e in $errors) { Write-Fail $e } Write-Host '' Write-Host 'Upload cancelled. Fix the release folder and run again.' -ForegroundColor Red exit 1 } Write-Host '' Write-Host "Files to upload: $($uploadMap.Count)" -ForegroundColor Green foreach ($remoteName in ($uploadMap.Keys | Sort-Object)) { $localName = [System.IO.Path]::GetFileName($uploadMap[$remoteName]) if ($localName -eq $remoteName) { Write-Host " - $remoteName" } else { Write-Host " - $remoteName (from $localName)" } } if ($CheckOnly) { Write-Host '' Write-Host 'CheckOnly: upload skipped.' -ForegroundColor Yellow exit 0 } Write-Title 'Upload' Write-Host "Target: ${sshTarget}:${remoteDir}" Write-Host "Feed: $feedUrl" foreach ($remoteName in ($uploadMap.Keys | Sort-Object)) { $localPath = $uploadMap[$remoteName] Write-Host " -> $remoteName" & scp -i $sshKey -q $localPath "${sshTarget}:${remoteDir}/$remoteName" if ($LASTEXITCODE -ne 0) { throw "scp failed for $remoteName (exit $LASTEXITCODE)" } } Write-Host '' Write-Host 'Setting www-data ownership on server...' & ssh -i $sshKey $sshTarget "chown -R www-data:www-data '$remoteDir'" if ($LASTEXITCODE -ne 0) { throw "ssh chown failed (exit $LASTEXITCODE)" } Write-Title 'Done' Write-Host 'Verify:' Write-Host " ${feedUrl}latest.yml" if (Test-Path -LiteralPath (Join-Path $ReleaseDir 'latest-linux.yml')) { Write-Host " ${feedUrl}latest-linux.yml" } if (Test-Path -LiteralPath (Join-Path $ReleaseDir 'latest-mac.yml')) { Write-Host " ${feedUrl}latest-mac.yml" } exit 0