# 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`](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 + repoint the launcher manifest ``` Workflow: [`.gitea/workflows/ci.yml`](../.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.js** — `actions/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 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. ``` Release 0.1.0-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 ``` 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. `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. ### Verifying a release ```powershell 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. | 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 s under full-assembly parallelism vs ~350 ms isolated. Cap `xUnit.MaxParallelThreads` on small runners rather than edit transport code | | Avalonia "calling thread cannot access this object" in cleanup | Serialize that assembly (`xunit.runner.json`). The stack shows a compositor being **constructed** during teardown; `TestAppBuilder` documents the hazard. Do **not** "fix" it by de-async-ing the test — that causes the failure | ## 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.