acdream/docs/ci-and-releases.md
Erik 6923ca02bd
Some checks failed
CI / linux-portable (push) Failing after 2m3s
CI / windows-gate (push) Failing after 4m52s
CI / release (push) Has been skipped
ci: prune old releases, keeping the newest five
Releases were retained forever. Each build is ~121 MB of attachments, so the
Gitea server grew by that much on every push to main — five builds had already
reached 606 MB, and nothing would have stopped it.

The release job now deletes versioned releases beyond the newest five, and
their tags with them (a tag survives its release and would otherwise pile up).
Five keeps a previous build available for a friend or a bisect while staying
well under a gigabyte. The 'latest' pointer is explicitly excluded from
pruning: it is the launcher's feed, not a build.
2026-08-19 14:59:28 +02:00

7.3 KiB

Continuous integration and alpha releases (Gitea)

Single source of truth for how acdream builds, gates, and ships alpha builds. Landed 2026-08-19. Companion to release-gate.md, which owns the local bounded gate.

What happens on a push to main

git push origin main
        │
        ├─ windows-gate    (RARE-win)        build + full lane-filtered suite
        ├─ linux-portable  (eriktestLinux)   portable closure, Linux lanes
        │
        └─ release   (needs BOTH green)      publish a Gitea Release
                                             + republish the `latest` pointer

Workflow: .gitea/workflows/ci.yml. A red gate cannot publish: release uses needs:, not a workflow_run trigger, whose Forgejo support is unreliable.

Why Gitea and not GitHub

GitHub Actions is billing-blocked on this account ("recent account payments have failed"), and the repo is private, so hosted runners consume paid minutes. Forgejo ships no hosted runners at all, so Actions there requires self-hosted ones — which are free on both platforms. The same two machines can serve GitHub later by registering a second agent; only the workflow's runs-on labels change.

The runners

Windows Linux
Host RARE (10.6.0.3) eriktestLinux (10.0.0.202)
Agent act_runner 0.2.13 forgejo-runner 13.0.0
Persistence Scheduled task ForgejoRunner, at logon of acbot systemd forgejo-runner, Restart=always
Labels windows, windows-latest, windows-x64 ubuntu-latest, ubuntu, linux, ubuntu-slim
Execution host mode (:host) — no Docker on either box host mode

Both poll outbound over HTTPS. Gitea never connects to them, so no inbound ports, no port forwarding, and no static IP; they work behind NAT. The runner does not have to live next to the Gitea container (which runs on bluesnake, a host we have no shell on).

forgejo-runner publishes no Windows binary in any release, which is why Windows uses Gitea's act_runner. Forgejo speaks the same Actions protocol.

Prerequisites on a runner

  • .NET SDK in the global.json band — currently 10.0.3xx. 10.0.400 is a different feature band and rollForward: latestPatch rejects it.
  • Node.jsactions/checkout and actions/upload-artifact are JavaScript actions. Docker images normally supply Node; in host mode the machine must.
  • Git, and outbound HTTPS to git.snakedesert.se.
  • PowerShell 7 on Windows (pwsh); tools/*.ps1 require it.

Releases

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.<yyyyMMddHHmm>     <- 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

Release  latest                          <- pointer, replaced every publish
  manifest.json           names the version above and its asset URLs

The launcher polls the pointer at a URL that never changes (ReleaseManifestClient.ProductionManifestUri):

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.

The newest 5 versioned releases are kept and older ones are pruned with their tags. Each build is ~121 MB of attachments, so retaining every one grew the server by that much per push — 5 builds had already reached 606 MB. Five is enough to grab a previous build or bisect a regression while staying bounded. The latest pointer is never pruned; it is the feed, not a build.

tools/publish-bin.ps1 -BaseUrl <release asset base> 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

dotnet test tests/AcDream.Launcher.Core.Tests --filter Lane=Live

LiveGiteaReleaseInstallTests installs the advertised client from the real feed through the production updater — real SHA-256/size verification, extraction, and atomic activation — then asserts both hosts resolve out of the activated directory and current.json names the installed version.

Landmines

Each of these cost a red pipeline; none was a config typo. Two rows record a fix that was tried and disproved — read those before repeating it.

Symptom Cause
Cannot find: node in PATH JS actions need Node on the host in :host mode
actions/setup-dotnet never resolves data.forgejo.org does not mirror it (404). checkout and upload-artifact are mirrored. Self-hosted runners carry the SDK anyway
Job "failed" while dotnet processes still run run-release-gate.ps1 redirects children to log files, so the step goes silent; Forgejo fails a non-reporting task as a zombie. CI runs dotnet test directly so output streams
~40 tests fail on formatted numbers Runner's HKCU locale was en-SE (comma decimal): expected "update:0.25", got "update:0,25". Set-Culture does not reach a scheduled task without a loaded profile — set the registry directly
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 as the locale fix Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead
FileNotFoundException: client_cell_1.dat DAT-dependent tests missing [Trait("Lane", "InstalledDat")]. Build machines have no DATs
Timing-sensitive test fails only under load FakeAceTransportTests.PausedSelector_… took 37-42 s under load vs ~350 ms isolated. Its harness drives a VIRTUAL clock but asserted on 2 s wall-clock windows; those are patience, not assertions, and now share a 60 s HarnessPatience. Do not serialize the assembly to fix it — that regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s, breaking a loss-soak test that had never failed
Avalonia "calling thread cannot access this object" in cleanup MainWindowViewTests needs a real desktop session and is Lane=Manual. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under act_runner and on Linux. Serializing the assembly does not fix it (tried via xunit.runner.json and a compiled-in CollectionBehavior attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being constructed during teardown — it is the headless session lifecycle, not parallelism

Culture note

The en-SE discovery is worth remembering beyond CI: config files, numeric parsing, and the wire are all culture-safe (System.Text.Json is invariant by spec, every float/double.TryParse passes CultureInfo.InvariantCulture, and the protocol is binary). Only diagnostic strings format with the current culture, so a European player sees local=(8,00; 191,00) in an F3 dump. The client installs and runs correctly in both the US and Europe.