diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2208f27c..9f92d5a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -136,26 +136,42 @@ jobs: Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($f.Name)" -Form @{ attachment = Get-Item $f.FullName } | Out-Null } - - name: Point the launcher manifest at the new release + - name: Republish the `latest` pointer release shell: pwsh env: TAG: ${{ steps.ver.outputs.version }} TOKEN: ${{ secrets.GITEA_TOKEN }} run: | $ErrorActionPreference = 'Stop' - # dist carries ONLY manifest.json (~500 bytes), force-replaced each - # publish, never pushed to GitHub. Payloads stay in release - # attachments, outside git history entirely. - $url = "${{ github.server_url }}/${{ github.repository }}.git" -replace '^https://', "https://x:$env:TOKEN@" - git config --global user.email 'ci@acdream.local' - git config --global user.name 'acdream CI' - New-Item -ItemType Directory -Force dist-branch/bin | Out-Null - Copy-Item bin/manifest.json dist-branch/bin/manifest.json - Push-Location dist-branch - git init -q - git checkout -q -b dist - git add -f bin/manifest.json - git commit -q -m "release: manifest for $env:TAG" - git push -q --force $url dist:dist - Pop-Location - Write-Host "manifest published for $env:TAG" + $api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + $headers = @{ Authorization = "token $env:TOKEN" } + + # Forgejo has no /releases/latest/download/ route, so the launcher + # needs a pointer at a URL that never changes. A one-asset release on + # the fixed `latest` tag is that pointer. Keeping it in a release + # rather than in git means no payload branch, no bot commits on main, + # and no push that would retrigger this workflow. + $existing = Invoke-RestMethod -Method Get -Headers $headers ` + -Uri "$api/releases/tags/latest" -SkipHttpErrorCheck + if ($existing.id) { + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($existing.id)" | Out-Null + # The tag outlives its release and would block recreation. + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/latest" -SkipHttpErrorCheck | Out-Null + Write-Host "removed the previous latest pointer" + } + + $body = @{ + tag_name = 'latest' + name = "Update feed -> $env:TAG" + body = "Pointer the launcher polls. The downloads live in the ``$env:TAG`` release." + draft = $false + prerelease = $false + target_commitish = 'main' + } | ConvertTo-Json + $pointer = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers ` + -ContentType 'application/json' -Body $body + + Invoke-RestMethod -Method Post -Headers $headers ` + -Uri "$api/releases/$($pointer.id)/assets?name=manifest.json" ` + -Form @{ attachment = Get-Item bin/manifest.json } | Out-Null + Write-Host "latest pointer now advertises $env:TAG" diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 4369f6b4..bc64eb3c 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -13,7 +13,7 @@ git push origin main ├─ linux-portable (eriktestLinux) portable closure, Linux lanes │ └─ release (needs BOTH green) publish a Gitea Release - + repoint the launcher manifest + + republish the `latest` pointer ``` Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). A red gate @@ -58,28 +58,40 @@ Windows uses Gitea's `act_runner`. Forgejo speaks the same Actions protocol. ## Releases -Payloads are **release attachments**, deliberately outside git history: a build -is ~120 MB and would otherwise land in a branch every push. Only the ~500-byte -`manifest.json` is committed, to the payload-free `dist` branch. +Everything about distribution lives under **Releases** — nothing in git. A build +is ~120 MB, so payloads are release attachments; and the pointer the launcher +polls is itself a release asset, so there is no payload branch, no bot commit on +`main`, and no push that could retrigger the pipeline. ``` -Release 0.1.0-build. +Release 0.1.0-build. <- the actual build client-win-x64.zip AcDream.App.exe + acdream-headless.exe launcher-win-x64.zip acdream-launcher.exe + acdream-bake.exe manifest.json -dist branch (manifest only, force-replaced each publish) - bin/manifest.json -> points at the release attachment URLs +Release latest <- pointer, replaced every publish + manifest.json names the version above and its asset URLs ``` -The launcher polls the manifest at a fixed raw URL -(`ReleaseManifestClient.ProductionManifestUri`). **Forgejo has no -`/releases/latest/download/` route** (verified: 404), which is why the manifest -cannot simply live in the release itself. +The launcher polls the pointer at a URL that never changes +(`ReleaseManifestClient.ProductionManifestUri`): -`tools/publish-bin.ps1 -BaseUrl ` builds the payloads; the -pipeline passes the tag's asset base. With no `-BaseUrl` it falls back to the -dist-branch layout for a manual local publish. +``` +https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json +``` + +A pointer is needed because **Forgejo has no `/releases/latest/download/` +route** (verified: 404) — unlike GitHub, there is no built-in stable URL for +"the newest release". Publishing it recreates the `latest` tag each time, which +means deleting the old release *and* its tag; the tag outlives its release and +would otherwise block recreation. + +Versioned releases are kept, so an older build stays downloadable and the +launcher's local rollback still has something to fall back to. + +`tools/publish-bin.ps1 -BaseUrl ` builds the payloads; CI +passes the tag's asset base. Running it locally is for inspection only — +publishing is CI's job. ### Verifying a release diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index a62d9004..01f2350e 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -24,16 +24,23 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable 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. + /// Alpha distribution feed: the manifest asset of the fixed latest + /// release on the public Gitea repo (anonymous reads verified, so a friend + /// needs no account). Every build publishes a versioned release carrying + /// the payloads, then republishes this one-asset latest release as + /// the pointer to it. + /// + /// + /// A pointer is required because Forgejo has no + /// /releases/latest/download/ route (verified: 404), so there is no + /// built-in stable URL for "the newest release". Keeping the pointer as a + /// release asset means nothing about distribution lives in git: no payload + /// branch, no bot commits on main, and no build loop to guard + /// against. + /// /// public static Uri ProductionManifestUri { get; } = new( - "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json"); + "https://git.snakedesert.se/erik/acdream/releases/download/latest/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 b43c7aa3..58bcb582 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -91,14 +91,12 @@ public sealed class ReleaseManifestClientTests Assert.Equal("2.1.0", manifest.Version.Value); Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size); - // 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. + // Alpha distribution feed: the manifest asset of the fixed `latest` + // release. Nothing about distribution lives in git — the CI release job + // republishes this pointer after each versioned release, so it must + // agree with this URL. Assert.Equal( - "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json", + "https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json", ReleaseManifestClient.ProductionManifestUri.AbsoluteUri); Assert.Equal(Uri.UriSchemeHttps, ReleaseManifestClient.ProductionManifestUri.Scheme); } diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 index 918889b4..ba6511f0 100644 --- a/tools/publish-bin.ps1 +++ b/tools/publish-bin.ps1 @@ -62,13 +62,12 @@ foreach ($candidate in @($Version, $MinimumLauncherVersion)) { } } -# Where the manifest says the payloads live. The CI pipeline passes the Gitea -# RELEASE asset base for the tag it is publishing, so payloads live outside git -# entirely; the default keeps the older dist-branch layout working for a manual -# local publish. The manifest itself always stays at the stable dist raw URL -# that ReleaseManifestClient.ProductionManifestUri points at. +# Where the manifest says the payloads live. Publishing is CI's job: the release +# workflow passes the Gitea RELEASE asset base for the tag it is creating. This +# default only makes a local build self-describing — it names a release tag that +# will not exist until CI publishes one. $RawBase = if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' + "https://git.snakedesert.se/erik/acdream/releases/download/$Version" } else { $BaseUrl.TrimEnd('/') } @@ -247,8 +246,6 @@ if ($dirtyLocks.Count -gt 0) { } 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 +Write-Host 'Releases are published by CI, not from here:' -ForegroundColor Cyan +Write-Host ' push to main -> .gitea/workflows/ci.yml -> Gitea Release' -ForegroundColor DarkGray +Write-Host ' This build is for local inspection; bin/ stays gitignored.' -ForegroundColor DarkGray diff --git a/tools/publish-dist.ps1 b/tools/publish-dist.ps1 deleted file mode 100644 index 7628b863..00000000 --- a/tools/publish-dist.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -<# -.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'