diff --git a/.gitignore b/.gitignore index 894a5f03..045c317a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ bin/ obj/ out/ +# NOTE: the repo-root /bin folder holds the alpha distribution feed written by +# tools/publish-bin.ps1. It stays IGNORED here on purpose so a stray `git add` +# can never put ~150 MB of payloads on main (GitHub also hard-rejects any file +# over 100 MB). tools/publish-dist.ps1 force-adds it onto the Gitea-only `dist` +# branch instead, which is what the launcher's update feed reads. # Rider / VS .idea/ diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index 7736fee5..a62d9004 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -19,14 +19,21 @@ public interface IReleaseManifestClient /// public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable { - public const string GitHubOwner = "eriknihlen"; - public const string GitHubRepository = "acdream"; public const int MaximumManifestBytes = 1024 * 1024; public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; public const int MaximumRedirects = 5; + /// + /// Alpha distribution feed: the public Gitea repository's dist + /// branch, served as raw files (anonymous raw reads verified 2026-08-18, + /// so a friend needs no account). GitHub stays private, and its 100 MB + /// per-file limit could not carry the launcher payload anyway, so the + /// payloads live on dist — pushed to Gitea only — instead of on + /// main. tools/publish-bin.ps1 builds them and + /// tools/publish-dist.ps1 publishes that branch. + /// public static Uri ProductionManifestUri { get; } = new( - $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); + "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json"); private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index 0208d60d..b43c7aa3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -91,11 +91,16 @@ public sealed class ReleaseManifestClientTests Assert.Equal("2.1.0", manifest.Version.Value); Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size); - Assert.Equal("eriknihlen", ReleaseManifestClient.GitHubOwner); - Assert.Equal("acdream", ReleaseManifestClient.GitHubRepository); + // Alpha distribution feed: the PUBLIC Gitea repo's `dist` branch + // (GitHub stays private, so its Releases feed cannot serve anonymous + // friend installs, and its 100 MB per-file limit could not carry the + // launcher payload). tools/publish-bin.ps1 writes the payloads and + // tools/publish-dist.ps1 pushes that branch — all three must agree on + // this URL. Assert.Equal( - "https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json", + "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json", ReleaseManifestClient.ProductionManifestUri.AbsoluteUri); + Assert.Equal(Uri.UriSchemeHttps, ReleaseManifestClient.ProductionManifestUri.Scheme); } [Theory] diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 new file mode 100644 index 00000000..6348826e --- /dev/null +++ b/tools/publish-bin.ps1 @@ -0,0 +1,226 @@ +<# +.SYNOPSIS + Publishes the alpha distribution feed into the repo's /bin folder. + +.DESCRIPTION + Builds self-contained client and launcher payloads, zips them, and writes + bin/manifest.json pointing at this repository's PUBLIC Gitea raw URLs. + Commit and push /bin afterwards and the launcher's "Check for updates" + finds the new build — no release server, no tokens, no CDN. + + Payload contents (what the launcher expects to find at each zip root): + client-.zip AcDream.App[.exe] + acdream-headless[.exe] + launcher-.zip acdream-launcher[.exe] + acdream-bake[.exe] + (the launcher csproj co-deploys the bake CLI) + + The launcher's own published version is stamped to the manifest version so + a freshly published launcher does NOT report itself as out of date. + +.PARAMETER Version + SemVer 2.0 release version. Defaults to a monotonic UTC build stamp, + e.g. 0.1.0-build.202608181552. Must sort ABOVE the previous published + version or the launcher will not offer it as an update. + +.PARAMETER IncludeLinux + Also publish linux-x64 payloads. Off by default: Linux graphical is parked + at Slice L1, and each RID roughly doubles build time and feed size. + +.EXAMPLE + pwsh -NoProfile -File tools/publish-bin.ps1 + git add bin + git commit -m "release: alpha build" + git push origin main +#> +[CmdletBinding()] +param( + [string]$Version, + [switch]$IncludeLinux, + [string]$MinimumLauncherVersion = '0.0.1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'publish-bin requires PowerShell 7 or newer.' +} + +$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if (-not (Test-Path (Join-Path $RepoRoot 'AcDream.slnx'))) { + throw "Could not locate AcDream.slnx above '$PSScriptRoot'." +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = '0.1.0-build.{0}' -f ([DateTime]::UtcNow.ToString('yyyyMMddHHmm')) +} + +$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' +foreach ($candidate in @($Version, $MinimumLauncherVersion)) { + if ($candidate -notmatch $semver) { + throw "Version '$candidate' is not SemVer 2.0 (build metadata '+' is not allowed here)." + } +} + +# Raw-file base for the PUBLIC Gitea repo's dist branch. Must agree with +# ReleaseManifestClient.ProductionManifestUri. +$RawBase = 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' + +$BinRoot = Join-Path $RepoRoot 'bin' +$Staging = Join-Path $BinRoot 'payload' +$Rids = @('win-x64') +if ($IncludeLinux) { $Rids += 'linux-x64' } + +Write-Host "acdream alpha feed" -ForegroundColor Cyan +Write-Host " version : $Version" +Write-Host " rids : $($Rids -join ', ')" +Write-Host " output : $BinRoot" +Write-Host '' + +if (Test-Path $Staging) { Remove-Item -LiteralPath $Staging -Recurse -Force } +$null = New-Item -ItemType Directory -Path $Staging -Force + +function Invoke-Publish { + param( + [Parameter(Mandatory)][string]$Project, + [Parameter(Mandatory)][string]$Rid, + [Parameter(Mandatory)][string]$OutputDirectory, + [switch]$SingleFile + ) + + $arguments = @( + 'publish', (Join-Path $RepoRoot $Project), + '-c', 'Release', + '-r', $Rid, + '--self-contained', 'true', + # InformationalVersion ONLY — never -p:Version. The launcher reads + # AssemblyInformationalVersion (App.GetLauncherVersion), while Version + # also rewrites project-reference versions inside the committed + # packages..lock.json files, so stamping it churned every lock + # file with a throwaway build stamp (observed 2026-08-18). + "-p:InformationalVersion=$Version", + # No SourceLink '+' suffix: LauncherVersion parses this as SemVer. + '-p:IncludeSourceRevisionInInformationalVersion=false', + '-o', $OutputDirectory, + '--nologo' + ) + if ($SingleFile) { $arguments += '-p:PublishSingleFile=true' } + + & dotnet @arguments | Out-Null + if ($LASTEXITCODE) { throw "publish failed: $Project ($Rid)" } +} + +function New-PayloadZip { + param( + [Parameter(Mandatory)][string]$SourceDirectory, + [Parameter(Mandatory)][string]$ZipPath, + [Parameter(Mandatory)][string[]]$RequiredFiles + ) + + foreach ($required in $RequiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory $required))) { + throw "Payload '$SourceDirectory' is missing required file '$required'." + } + } + + if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath -Force } + # includeBaseDirectory:$false -> entries sit at the zip ROOT, which is where + # LauncherExecutableSet resolves the hosts after extraction. + [IO.Compression.ZipFile]::CreateFromDirectory( + $SourceDirectory, + $ZipPath, + [IO.Compression.CompressionLevel]::Optimal, + $false) +} + +function Get-Artifact { + param( + [Parameter(Mandatory)][string]$ZipPath, + [Parameter(Mandatory)][string]$Url + ) + + $item = Get-Item -LiteralPath $ZipPath + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + +$clients = [ordered]@{} +$launchers = [ordered]@{} + +foreach ($rid in $Rids) { + $suffix = if ($rid -like 'win-*') { '.exe' } else { '' } + $clientDirectory = Join-Path $Staging "client-$rid" + $launcherDirectory = Join-Path $Staging "launcher-$rid" + + Write-Host "[$rid] publishing client (App + Headless)..." -ForegroundColor Yellow + Invoke-Publish 'src/AcDream.App/AcDream.App.csproj' $rid $clientDirectory + Invoke-Publish 'src/AcDream.Headless/AcDream.Headless.csproj' $rid $clientDirectory + + # PublishSingleFile/SelfContained come from the launcher csproj itself, and + # its PublishCoDeployedBakeTool target adds acdream-bake as a SECOND + # self-contained single file in the same directory. Two independent .NET + # runtimes is deliberate (see that csproj's comments: a framework-dependent + # bake would scatter AcDream.Content assemblies into the launcher's output), + # so this payload is ~103 MB and CANNOT be pushed to GitHub, whose hard + # per-file limit is 100 MB. The alpha feed is Gitea-only by design. + Write-Host "[$rid] publishing launcher (+ co-deployed bake)..." -ForegroundColor Yellow + Invoke-Publish 'src/AcDream.Launcher/AcDream.Launcher.csproj' $rid $launcherDirectory + + $clientZip = Join-Path $BinRoot "client-$rid.zip" + $launcherZip = Join-Path $BinRoot "launcher-$rid.zip" + + Write-Host "[$rid] packing..." -ForegroundColor Yellow + New-PayloadZip $clientDirectory $clientZip @("AcDream.App$suffix", "acdream-headless$suffix") + New-PayloadZip $launcherDirectory $launcherZip @("acdream-launcher$suffix", "acdream-bake$suffix") + + $clients[$rid] = Get-Artifact $clientZip "$RawBase/client-$rid.zip" + $launchers[$rid] = Get-Artifact $launcherZip "$RawBase/launcher-$rid.zip" +} + +$manifest = [ordered]@{ + schemaVersion = 1 + version = $Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = $clients + launchers = $launchers +} + +$manifestPath = Join-Path $BinRoot 'manifest.json' +$json = $manifest | ConvertTo-Json -Depth 6 +[IO.File]::WriteAllText($manifestPath, $json + "`n", [Text.UTF8Encoding]::new($false)) + +Remove-Item -LiteralPath $Staging -Recurse -Force + +Write-Host '' +Write-Host 'Feed written:' -ForegroundColor Green +$total = 0L +foreach ($file in (Get-ChildItem -LiteralPath $BinRoot -File | Sort-Object Name)) { + $total += $file.Length + ' {0,-26} {1,10:N1} MB' -f $file.Name, ($file.Length / 1MB) | Write-Host +} +' {0,-26} {1,10:N1} MB' -f 'TOTAL', ($total / 1MB) | Write-Host +# A RID-specific restore rewrites packages..lock.json for the projects it +# touches, so a feed build can leave the working tree dirty even though nothing +# about the source changed. Report it rather than silently reverting: the files +# are the developer's, and a real dependency change must not be swallowed here. +$dirtyLocks = @( + & git -C $RepoRoot status --porcelain -- '*packages.*.lock.json' 2>$null +) | Where-Object { $_ } +if ($dirtyLocks.Count -gt 0) { + Write-Host '' + Write-Host 'Note: the RID restore modified these lock files:' -ForegroundColor Yellow + $dirtyLocks | ForEach-Object { " $($_.Trim())" | Write-Host } + Write-Host ' If you did not change dependencies, discard them:' -ForegroundColor DarkGray + Write-Host " git checkout -- '*packages.*.lock.json'" -ForegroundColor DarkGray +} + +Write-Host '' +Write-Host 'Next — publish the feed to Gitea:' -ForegroundColor Cyan +Write-Host " pwsh -NoProfile -File tools/publish-dist.ps1" +Write-Host '' +Write-Host ' (bin/ is gitignored on purpose: publish-dist puts it on the' -ForegroundColor DarkGray +Write-Host ' Gitea-only dist branch, never on main / GitHub.)' -ForegroundColor DarkGray diff --git a/tools/publish-dist.ps1 b/tools/publish-dist.ps1 new file mode 100644 index 00000000..7628b863 --- /dev/null +++ b/tools/publish-dist.ps1 @@ -0,0 +1,161 @@ +<# +.SYNOPSIS + Publishes the /bin alpha feed to the Gitea-only `dist` branch. + +.DESCRIPTION + Copies the payloads written by tools/publish-bin.ps1 onto an orphan `dist` + branch and pushes it to Gitea (the `origin` remote). The launcher's update + check reads that branch's raw URLs. + + Why a separate branch, not main: + * The launcher payload is ~103 MB. GitHub hard-rejects any file over + 100 MB, so payloads on main would break every GitHub push. + * Each build is ~150 MB. On main that weight would land in the history + every developer clones forever. + `dist` is a single-commit ORPHAN branch — each publish REPLACES it, so the + feed never accumulates old builds. Nothing on it is a parent of main. + +.PARAMETER Remote + Remote to publish to. Defaults to `origin` (Gitea). Never pass the GitHub + remote: the payload exceeds its per-file limit. + +.EXAMPLE + pwsh -NoProfile -File tools/publish-bin.ps1 + pwsh -NoProfile -File tools/publish-dist.ps1 +#> +[CmdletBinding()] +param( + [string]$Remote = 'origin', + [switch]$KeepHistory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'publish-dist requires PowerShell 7 or newer.' +} + +$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$BinRoot = Join-Path $RepoRoot 'bin' +$ManifestPath = Join-Path $BinRoot 'manifest.json' + +if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "No feed found at '$ManifestPath'. Run tools/publish-bin.ps1 first." +} + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$version = $manifest.version +if ([string]::IsNullOrWhiteSpace($version)) { + throw 'The manifest has no version.' +} + +$payloads = @(Get-ChildItem -LiteralPath $BinRoot -File -Filter *.zip) +if ($payloads.Count -eq 0) { + throw "No .zip payloads in '$BinRoot'. Run tools/publish-bin.ps1 first." +} + +$remoteUrl = (& git -C $RepoRoot remote get-url $Remote 2>&1) +if ($LASTEXITCODE) { throw "Remote '$Remote' is not configured." } +if ($remoteUrl -match 'github\.com') { + throw "Refusing to publish payloads to '$Remote' ($remoteUrl): GitHub " + + 'rejects files over 100 MB and the alpha feed is Gitea-only.' +} + +Write-Host "Publishing alpha feed $version to $Remote ($remoteUrl)" -ForegroundColor Cyan +foreach ($payload in $payloads) { + ' {0,-26} {1,8:N1} MB' -f $payload.Name, ($payload.Length / 1MB) | Write-Host +} + +# A throwaway worktree keeps the developer's checkout, index, and HEAD +# completely untouched while the dist branch is built and pushed. +$stamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') +$workTree = Join-Path ([IO.Path]::GetTempPath()) "acdream-dist-$stamp" +$branch = 'dist' +# Build under a unique local branch and push it AS dist. Reusing the name +# locally breaks the second publish outright: `checkout --orphan dist` fails +# once a local dist ref exists (observed 2026-08-18). +$stagingBranch = "dist-publish-$stamp" + +try { + if ($KeepHistory) { + & git -C $RepoRoot fetch $Remote $branch 2>&1 | Out-Null + $hasRemoteBranch = -not $LASTEXITCODE + & git -C $RepoRoot worktree add --no-checkout -b $stagingBranch $workTree ` + $(if ($hasRemoteBranch) { "$Remote/$branch" } else { 'HEAD' }) 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } + & git -C $workTree checkout . 2>&1 | Out-Null + } + else { + # Default: one commit, no ancestry. Each publish REPLACES the branch so + # superseded payloads never pile up in the object store. + & git -C $RepoRoot worktree add --detach $workTree 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } + & git -C $workTree checkout --orphan $stagingBranch 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not start the dist branch.' } + & git -C $workTree rm -rf --cached . 2>&1 | Out-Null + Get-ChildItem -LiteralPath $workTree -Force | + Where-Object { $_.Name -ne '.git' } | + Remove-Item -Recurse -Force + } + + $targetBin = Join-Path $workTree 'bin' + if (Test-Path -LiteralPath $targetBin) { + Remove-Item -LiteralPath $targetBin -Recurse -Force + } + $null = New-Item -ItemType Directory -Path $targetBin -Force + Copy-Item -LiteralPath $ManifestPath -Destination $targetBin + foreach ($payload in $payloads) { + Copy-Item -LiteralPath $payload.FullName -Destination $targetBin + } + + $readme = @" +# acdream alpha feed + +Published by ``tools/publish-dist.ps1``. This branch carries ONLY the launcher +update feed — it has no source history and is never merged into ``main``. + +Current release: **$version** + +## For players + +1. Download ``bin/launcher-win-x64.zip``. +2. Unzip it anywhere and run ``acdream-launcher.exe``. +3. The launcher installs the game client and keeps both up to date. + +You need Asheron's Call's DAT files for first-run setup. +"@ + [IO.File]::WriteAllText( + (Join-Path $workTree 'README.md'), + $readme, + [Text.UTF8Encoding]::new($false)) + + # bin/ is gitignored repo-wide (so main can never take the payloads by + # accident) — force-add it here, where it is the whole point of the branch. + & git -C $workTree add -f bin README.md + if ($LASTEXITCODE) { throw 'Could not stage the feed.' } + + & git -C $workTree commit -q -m "release: acdream alpha $version" 2>&1 | Out-Null + if ($LASTEXITCODE) { + Write-Host 'Nothing changed since the last publish.' -ForegroundColor Yellow + } + + & git -C $workTree push --force $Remote "${stagingBranch}:${branch}" + if ($LASTEXITCODE) { throw "Push to $Remote/$branch failed." } +} +finally { + if (Test-Path -LiteralPath $workTree) { + & git -C $RepoRoot worktree remove --force $workTree 2>&1 | Out-Null + if (Test-Path -LiteralPath $workTree) { + Remove-Item -LiteralPath $workTree -Recurse -Force -ErrorAction SilentlyContinue + } + } + & git -C $RepoRoot worktree prune 2>&1 | Out-Null + # The staging branch exists only to carry one publish to the remote. + & git -C $RepoRoot branch -D $stagingBranch 2>&1 | Out-Null +} + +Write-Host '' +Write-Host "Published $version." -ForegroundColor Green +Write-Host 'Feed: https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json' +Write-Host 'Players: https://git.snakedesert.se/erik/acdream/src/branch/dist'