feat(launcher): Gitea-backed alpha update feed replaces the GitHub Releases source

The launcher reported "no client available" because its update source was
pinned to a GitHub Releases manifest in a PRIVATE repo — nothing anonymous
could ever be fetched from it. Switch the feed to the PUBLIC Gitea repo so a
friend needs no account, and add the two commands that publish it.

- ReleaseManifestClient.ProductionManifestUri now points at
  git.snakedesert.se/erik/acdream raw on the `dist` branch. No update
  machinery changed: the existing strict reader already accepts any HTTPS
  manifest, so this is a URL swap plus a build script.

- tools/publish-bin.ps1 publishes the payloads into /bin and writes
  bin/manifest.json (schema v1, SHA-256 + size per artifact):
    client-win-x64.zip    AcDream.App + acdream-headless
    launcher-win-x64.zip  acdream-launcher + co-deployed acdream-bake
  Stamps InformationalVersion ONLY — never -p:Version, which also rewrites
  project-reference versions inside the committed packages.<rid>.lock.json
  files and churned every one of them with a throwaway build stamp.

- tools/publish-dist.ps1 pushes /bin to the Gitea-only `dist` branch from a
  throwaway worktree, leaving the developer's checkout, index, and HEAD
  untouched. It refuses a GitHub remote outright.

Why `dist` and not main: the launcher payload is ~103 MB because the launcher
and its co-deployed bake CLI are each self-contained single files (deliberate,
see AcDream.Launcher.csproj). GitHub hard-rejects files over 100 MB, and all
three refs currently track main, so payloads on main would break every GitHub
push. `dist` is a single-commit orphan branch that each publish REPLACES, so
superseded builds never accumulate. /bin stays gitignored repo-wide and is
force-added only on that branch.

Verified live: manifest and both payloads serve anonymously over HTTPS, and a
downloaded client payload matches its declared SHA-256 and size byte for byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-18 19:49:06 +02:00
parent 8be14d3992
commit 600c331ac6
5 changed files with 410 additions and 6 deletions

5
.gitignore vendored
View file

@ -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/

View file

@ -19,14 +19,21 @@ public interface IReleaseManifestClient
/// </summary>
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;
/// <summary>
/// Alpha distribution feed: the public Gitea repository's <c>dist</c>
/// 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 <c>dist</c> — pushed to Gitea only — instead of on
/// <c>main</c>. <c>tools/publish-bin.ps1</c> builds them and
/// <c>tools/publish-dist.ps1</c> publishes that branch.
/// </summary>
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()
{

View file

@ -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]

226
tools/publish-bin.ps1 Normal file
View file

@ -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-<rid>.zip AcDream.App[.exe] + acdream-headless[.exe]
launcher-<rid>.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.<rid>.lock.json files, so stamping it churned every lock
# file with a throwaway build stamp (observed 2026-08-18).
"-p:InformationalVersion=$Version",
# No SourceLink '+<sha>' 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.<rid>.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

161
tools/publish-dist.ps1 Normal file
View file

@ -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'