Search

Tuesday, August 4, 2026

Publishing an AI-Built Game: Eleven Runners, Two CPU Architectures, Four Hidden Bugs

Runbook · Public release & CI proof · Part 4

SANYALnet Labs Ludo AI Arena rendered on an Arm64 Linux GitHub Actions runner with no display attached: the full cross-shaped board, four AI player cards, the die, and a scrolling event log.
A complete game played on an Arm64 Linux cloud build machine with no monitor, no desktop and nobody watching. The application drew this picture of itself.

Publishing an AI-Built Game: Eleven Runners, Two CPU Architectures, Four Hidden Bugs

In Part 3 of this series, ChatDev 2.0 built SANYALnet Labs Ludo AI Arena—a C#/.NET 10/Avalonia desktop game in which four autonomous AI players play a full match by themselves. Five free models failed at it; one paid DeepSeek run costing about a dollar shipped it. The post ended with the game running on four machines in my house, and a build report from the agents declaring everything green. Both of those statements were true. Neither of them meant the software was actually finished.

Because at that point the code existed in exactly two places: on the machine that built it, and in a folder next to the blog draft. That is enough to write about. It is not enough for anyone else to have. Part 4 is the unglamorous follow-up that turns a demo into something a stranger can obtain: a public repository, a license, a README, a real version number, downloadable packages—and, because I did not entirely trust the green build report, an attempt to run the thing on every kind of computer GitHub will rent me.

That last part is where it stopped being administrative. Eleven clean machines, building from a public checkout, broke the build in about ninety seconds—and over three rounds of pushes surfaced four defects in total, including one real bug in the rules engine that no amount of playing the game would ever have revealed. This post is the story of the packaging, the proof, and the four things that were hiding behind "well, it runs on my machine."

What you'll end up with

A public GitHub repository with a license, a README and a mandatory per-file copyright header enforced by CI; a workflow that builds, tests and plays a complete game on eleven runner types across three operating systems and two CPU architectures; the same thing running on your own machines as self-hosted runners; and a tagged v1.0.0 release with downloadable packages for six platform/architecture combinations.

Read this before you start

Several steps in this post open doors on your own machines—self-hosted runners in particular. There is a mandatory teardown section at the end. Please read it first, and actually run it when you are done. Unlike a failed build, an open door never reminds you it is there.

In a hurry? Skip to the recipe

This is a long post, and most of it is the evidence—which machines were tried, what broke on each, and what the pictures prove. If you only want to do this to your own project, jump straight to Build and publish your own, end to end and follow the numbered steps, then come back for the teardown checklist.

The four questions

Part 3 answered "can a team of AI agents build this?" Part 4 asks four duller, more consequential questions:

  1. Can anyone else get it? A repository that is clean enough to publish—licensed, documented, free of secrets.
  2. Does it really run everywhere, or just here? Not "it compiles" but "it plays a complete game," on every machine type available.
  3. Does it run on hardware I don't control? And on hardware I do control but did not curate—which turns out to be much harder.
  4. Can someone download and run it without a toolchain? A tagged release with packages for six platforms.

Versions under test

Everything below was produced with exactly this stack. Package versions are pinned centrally in Directory.Packages.props, and a committed NuGet.config clears inherited package sources and pins nuget.org—so a fresh clone resolves an identical package graph everywhere.

ComponentVersionNotes
Target frameworknet10.0One target for every platform
.NET SDK10.0.302Pinned in global.json with rollForward: latestPatch
Avalonia UI11.2.8Desktop, Skia, Fluent/Simple themes, Inter fonts, ReactiveUI, Headless
CommunityToolkit.Mvvm8.4.0MVVM source generators
xUnit2.9.3With Microsoft.NET.Test.Sdk 17.13.0
FluentAssertions7.0.0Plus NSubstitute 5.3.0
Runner agentv2.336.0For the self-hosted machines

Worth pausing on one line of that table: a single SDK patch level, 10.0.302, installed and worked on Linux x64 and arm64, Windows x64 and arm64, and macOS on both Intel and Apple Silicon—including a macOS release from 2020. And one Avalonia version, 11.2.8, drew the identical board on all of them.

Making it fit to publish

The mechanical work first, because it is short. I copied the exact revision that had been built and run on all four machines in the Part 3 multi-platform test—not a newer, untested snapshot—into a clean folder, deliberately leaving behind build output and machine-specific clutter, and initialized a fresh Git repository there so the release would have its own history.

Then the four things that turn a folder of code into a project:

ArtifactWhy it exists
LICENSEA non-commercial license, matched to a sister project, so the terms of use are explicit rather than assumed. Attribution required; use of the source for AI/ML training prohibited.
READMEWhat the project is, how to build and run it on each OS, how the NVIDIA and fallback pieces are configured, and where it came from.
.gitignoreSo build artifacts—and far more importantly, secrets—can never be committed by accident.
Per-file headersA copyright/license/attribution banner at the top of every source file, in the correct comment syntax for each language.

The header is the interesting one, because a rule that depends on human memory is not a rule. Every source file starts with this:

// ============================================================================
// Copyright (c) 2026 Supratim Sanyal of SANYALnet Labs.
// Proprietary rights reserved except as expressly licensed herein.
//
// LUDO ARENA
// This file is governed by the SANYALnet Labs Non-Commercial License in the
// root LICENSE file. Non-Commercial use is permitted; Commercial Use and use
// for AI/ML model training are prohibited unless separately authorized.
//
// Attribution is required: "Based on original work by Supratim Sanyal of
// SANYALnet Labs." See LICENSE for full terms, warranty disclaimer, termination,
// patent, trademark, and governing-law provisions.
// ============================================================================

Applying it across a mixed codebase is fiddlier than it looks: C# and XAML want // and <!-- --> respectively, Python and shell want #, and shebang lines and XML declarations must stay on line 1. One file had a UTF‑8 byte-order mark, which the prepend politely pushed into the middle of the file and broke the XML—worth knowing before you script this.

Then the enforcement. A small script walks every tracked source file and fails if the banner is missing, and a workflow runs it on every push and pull request:

# scripts/check_license_headers.sh (abridged)
MARKER='Copyright (c) 2026 Supratim Sanyal of SANYALnet Labs.'
missing=0
while IFS= read -r f; do
  case "$f" in
    *.cs|*.axaml|*.py|*.sh|*.toml|*.yml|*.yaml|*.csproj|*.props|*.slnx|*.config) ;;
    *) continue ;;
  esac
  grep -qF "$MARKER" "$f" || { echo "::error file=$f::missing license header"; missing=$((missing+1)); }
done < <(git ls-files)
[ "$missing" -gt 0 ] && exit 1
echo "OK: every project source file carries the mandatory license header."
A gate that only checks what you remembered to list is not a gate

My first version of that case pattern omitted *.config. The NuGet.config file had the header, so nothing looked wrong—but deleting it would have sailed straight through. A later audit caught it. When you write a check like this, audit the check's own coverage against the actual file list, not against your intentions.

With that in place the repository was pushed public, and then—rather than assuming the push did what I meant—verified from the other side: clone it fresh, count the files, scan every commit in the history for keys, tokens and passwords, and confirm no build artifacts came along. That verification habit pays for itself later in this post.

Eleven clean machines

Now the interesting half. GitHub rents build machines by the minute, and on a public repository the whole catalogue—including the Arm ones—is free. That is an unusually good deal for a portability claim: instead of asserting that a .NET 10 + Avalonia application is cross-platform, I could simply try it on everything.

Rather than trusting a documentation table about which machines exist, I pushed a probe workflow that attempts to start a job on every runner label I believed in, and had each job report what it actually is. Labels that are unavailable simply fail to start, which is itself the answer. All eleven answered:

Runner labelOSArchReported machine
ubuntu-24.04LinuxX64Linux x86_64
ubuntu-22.04LinuxX64Linux x86_64
ubuntu-24.04-armLinuxARM64Linux aarch64
ubuntu-22.04-armLinuxARM64Linux aarch64
windows-2025WindowsX64Windows Server 2025
windows-2022WindowsX64Windows Server 2022
windows-11-armWindowsARM64MINGW64_NT-10.0-26200-ARM64
macos-15macOSARM64 (Apple Silicon)Darwin arm64
macos-14macOSARM64 (Apple Silicon)Darwin arm64
macos-15-intelmacOSX64 (Intel)Darwin x86_64
macos-13macOSX64 (Intel)Darwin x86_64

.NET 10 support: confirmed on all eleven. The SDK installed and dotnet restore of the whole solution—Avalonia packages included—succeeded on every one, including both Arm Linux variants, Windows on Arm, and both Intel and Apple-Silicon macOS.

And then all eleven failed at the next step, for the same reason.

Four defects that "works on my machine" was hiding

This is the part I would ask you to take away from this post, if you take away one thing.

Defect 1 — the build was never green

Every runner failed the Release build identically. Not an architecture problem, not a platform problem: a test file referenced a type that does not exist. AiTests.cs constructed a List<NimLegalMoveDto>; the DTO is called NimMoveDto.

The Part 3 specification demanded that "the whole solution MUST build with 0 errors and ALL test projects MUST compile," and the agents' final report claimed exactly that. It was not true. On the development machine nobody had built the test projects, and the game itself—which does compile—ran beautifully. So nothing ever surfaced it. A one-line fix, and a useful reminder that a build report is a claim, not evidence.

Defects 2 and 3 — tests asserting a product that no longer existed

With the build fixed, the next push got further and then failed at the tests. Two stale assertions in the App test project: one demanded the window title be "Ludo NIM Arena" when the specification mandates the exact branding "SANYALnet Labs Ludo AI Arena"; the other demanded generic "Red AI"/"Green AI" names when the shipped roster is HAL 9000, Marvin, Mal and Deckard. The product had moved on—correctly—and the tests had not.

Defect 4 — a real bug in the rules engine

Fixing those exposed something genuinely broken underneath:

The one that mattered

RulesEngine.ApplyMove recorded the PlayerWon domain event into the new state but never added it to the event collection it returns. Every caller therefore observed a game ending with no win event at all—while the specification explicitly lists PlayerWon among the domain events that must be recorded.

The winner was set correctly and the game-over phase was set correctly, so on screen everything looked perfect. The event was simply invisible to anything observing the engine rather than watching the window. You could play a thousand games and never notice. One line to fix:

// Check victory. The PlayerWon event must go into BOTH the returned event
// collection and the new state — callers observe the returned events, so
// recording it only in the state made every win look eventless.
if (newState.GetPlayer(color).HasWon)
{
    var playerWon = new PlayerWon(newState.GameId, color,
        newState.GetPlayer(color).DisplayName, newState.TurnNumber);
    events.Add(playerWon);                        // <-- this line
    newState = newState.WithEvent(playerWon);
    newState = newState.WithWinner(color);
}
Tally

Publishing the code to clean machines found four defects that a working demo had hidden: one build break, two tests asserting obsolete behaviour, and one genuine engine bug. Final state: 71 tests green—Core 52, AI 11, App 8. None of these required cleverness to find. They required a machine that had never seen the project before.

One smaller fix worth mentioning: restore was quietly picking up a stale machine-local package source. A committed NuGet.config that clears inherited sources and pins nuget.org means a fresh clone now restores an identical package graph everywhere.

Teaching the game to play by itself

Proving the game compiles on eleven machines is worth something. Proving it plays is worth much more—and is considerably harder, for three reasons:

  1. It is paced for humans. The die animation takes about 0.7 s, tokens glide roughly 0.33 s per cell, and there are pauses between turns. A full game runs into the hundreds of turns.
  2. There is nobody to press START GAME.
  3. There is no monitor attached to a build machine.

The answer was a set of opt-in environment switches. With none of them set, the game behaves exactly as it does for a human player:

SwitchEffect
LUDO_AUTOSTART=1presses START GAME shortly after the window opens
LUDO_SPEED=<n>animation-speed multiplier—every human-paced delay is divided by n
LUDO_TRANSCRIPT=<path>appends every event-log line to a file (the on-screen log keeps only the last 100)
LUDO_EXIT_ON_GAMEOVER=1closes the app once a winner is declared, giving CI a clean exit code

The speed multiplier deserves a footnote: it was not a hack invented for CI. An "animation-speed multiplier" was in the original Part 3 requirements all along and simply never got built. Part 4 finally implemented a specification item that had been quietly skipped—another thing that surfaced only because someone tried to use the software in a way the demo never did.

Each game now ends its transcript with a fixed, greppable footer, which is what CI actually asserts on:

WINNER: Marvin (Green)
TURNS: 295
GAME COMPLETE

Validated locally before spending a single CI minute: the real application at 60× played a complete 295-turn game in about two minutes twenty, exited with code 0, and left a 1,554-line transcript.

Playing a real game on every runner

The workflow builds the solution, runs all 71 tests, then launches the actual Avalonia desktop application—not a simulation, not a test double, not headless mode—and plays one complete game. Linux build machines have no display, so there the real GUI runs inside a virtual X server; Windows and macOS launch it directly.

# Linux runners: install a virtual display, then run the real GUI inside it
sudo apt-get install -y -qq xvfb libx11-6 libice6 libsm6 libfontconfig1

xvfb-run -a --server-args="-screen 0 1280x900x24" \
  dotnet run --project src/LudoNimArena.App -c Release --no-build

# then assert on the transcript — this is the actual pass/fail gate
grep -q '^GAME COMPLETE$' proof/transcript.txt \
  || { echo "::error::game did not reach a winner"; exit 1; }

Making the game photograph itself

Transcripts prove a game finished. Pictures prove it rendered—and every conventional way of taking one fails somewhere in this matrix. Hosted Linux runners have no display. Windows service sessions cannot see the desktop, so a screen grab returns a blank desktop rather than the game. An older Mac that appears later in this post cannot run screen-capture through any JavaScript action at all. And macOS screencapture over SSH returns the login-screen context, as Part 3 already discovered the hard way.

So I stopped trying to photograph the screen, and taught the application to render its own window to a PNG using Avalonia's RenderTargetBitmap—the same drawing pipeline that paints the board, aimed at a bitmap instead of a display. It needs no display, no compositor, no desktop session and no external tool, so it behaved identically on every machine in this experiment that ran at all.

SwitchEffect
LUDO_SCREENSHOT=<prefix>saves <prefix>-001.png, -002.png… while playing, and <prefix>-final.png at the winner screen
LUDO_SCREENSHOT_INTERVAL=<secs>how often to grab a frame (default 25)

The final frame is triggered by the game-over property change, which fires before the shutdown request, so the winner screen is always captured before the application exits. Screenshot failures are swallowed deliberately—a diagnostic must never be able to disturb the thing it is observing.

Every image in this post was produced that way: by the game, on the machine named in the caption, with nobody watching.

Three things those pictures show that no log could. The rendering is genuinely correct on every architecture—the Arm64 Linux frame at the top of this post has a pin-sharp 15×15 board, four player cards with live yard/track/home/done counts, the die, the status line and the fixed bottom-right copyright, exactly as on x64. The winner is visible: in each final frame the winning player's card reads Done:4 and is highlighted, matching the WINNER: line in that machine's transcript. And the application follows platform theming without being asked—you will see that vividly further down.

One honest blemish

On the Windows frames the text renders slightly heavier than on Linux and macOS. That is a font-fallback difference in offscreen rendering, it is purely cosmetic, and it does not appear in the on-screen application. I am pointing it out because a post full of screenshots that claims everything is identical should say where it isn't.

Linux — x64 and Arm64

Ludo AI Arena final board on Ubuntu 24.04 x64 GitHub-hosted runner, Mal (Yellow) winning after 508 turns.
ubuntu-24.04 · x64 — Mal (Yellow) wins in 508 turns.
Ludo AI Arena final board on Ubuntu 22.04 x64 GitHub-hosted runner, HAL 9000 (Red) winning after 367 turns.
ubuntu-22.04 · x64 — HAL 9000 (Red) wins in 367 turns.
Ludo AI Arena final board on Ubuntu 22.04 arm64 GitHub-hosted runner, Mal (Yellow) winning after 352 turns.
ubuntu-22.04-arm · arm64 — Mal (Yellow) wins in 352 turns.

Windows — x64 and Windows on Arm

Ludo AI Arena final board on Windows Server 2025 x64 GitHub-hosted runner, Marvin (Green) winning after 246 turns.
windows-2025 · x64 — Marvin (Green) wins in 246 turns.
Ludo AI Arena final board on Windows Server 2022 x64 GitHub-hosted runner, Mal (Yellow) winning after 322 turns.
windows-2022 · x64 — Mal (Yellow) wins in 322 turns.
Ludo AI Arena final board on Windows 11 arm64 GitHub-hosted runner, HAL 9000 (Red) winning after 406 turns.
windows-11-arm · arm64 — HAL 9000 (Red) wins in 406 turns. Note this for later: this is Windows 11, and it ran the game perfectly.

macOS — Apple Silicon and Intel

Ludo AI Arena final board on macOS 15 Apple Silicon GitHub-hosted runner, Mal (Yellow) winning after 413 turns.
macos-15 · Apple Silicon — Mal (Yellow) wins in 413 turns.
Ludo AI Arena final board on macOS 14 Apple Silicon GitHub-hosted runner, Marvin (Green) winning after 420 turns.
macos-14 · Apple Silicon — Marvin (Green) wins in 420 turns.
Ludo AI Arena final board on macOS 15 Intel x64 GitHub-hosted runner, HAL 9000 (Red) winning after 349 turns.
macos-15-intel · Intel x64 — HAL 9000 (Red) wins in 349 turns.

The scoreboard

RunnerOS / ArchBuildTestsFull gameWinnerTurns
ubuntu-24.04Linux / x64✓ 71Mal (Yellow)508
ubuntu-22.04Linux / x64✓ 71HAL 9000 (Red)367
ubuntu-24.04-armLinux / arm64✓ 71Mal (Yellow)367
ubuntu-22.04-armLinux / arm64✓ 71Mal (Yellow)352
windows-2025Windows / x64✓ 71Marvin (Green)246
windows-2022Windows / x64✓ 71Mal (Yellow)322
windows-11-armWindows / arm64✓ 71HAL 9000 (Red)406
macos-15macOS / arm64✓ 71Mal (Yellow)413
macos-14macOS / arm64✓ 71Marvin (Green)420
macos-15-intelmacOS / x64✓ 71HAL 9000 (Red)349
macos-13macOS / x64Never scheduled — GitHub is retiring this image and capacity is scarce. Not a failure; macos-15-intel covers macOS/Intel regardless.

Every winner and turn count differs, because the die uses a cryptographic RNG—these are genuinely independent games, not one canned result replayed ten times. An earlier round produced an entirely different set of winners. Each job uploads its transcript and board images as artifacts, so every claim in that table is checkable by anyone.

One codebase

Three operating systems. Two CPU architectures. Zero platform-specific code, no conditional compilation, no per-platform branches. A single .NET SDK patch level—10.0.302—and a single Avalonia version, 11.2.8, drew that identical board on every one of them.

The machines I own but did not curate

Hosted runners are a curated product: a known image, prepared by people whose job is making them work. The four machines in my house are not that. Registering them as GitHub Actions self-hosted runners puts the same workflow on real, uncontrolled hardware—and this is where the interesting failures live.

Security — read this before registering anything

A self-hosted runner is a machine sitting and waiting for GitHub to send it code to execute, inside your network, as your user. Attached to a public repository, the hazard is obvious once stated: anyone can open a pull request, and if a workflow is triggered by pull_request, a stranger's code runs on your hardware. The mitigation here is that the self-hosted workflow has workflow_dispatch only—no pull_request trigger anywhere, so fork contributions can never reach these machines. Mitigation is not removal, though: see the teardown section.

Registering the agents is a fifteen-minute job with four sharp edges worth knowing in advance:

  • The label is what matters, not the name. --name is cosmetic—it is what shows in the GitHub UI. --labels is what a workflow's runs-on: actually matches. (And actions-runner is merely the folder the package unpacks into; it means nothing to GitHub.)
  • Registration tokens expire in an hour, and a stale one fails with a thoroughly unhelpful HTTP error. Mint it immediately before you configure, not while you make coffee.
  • Never paste a token into a chat window or a shared terminal. Mint it where the GitHub CLI is authenticated, write it straight onto the target machine, read it into a variable so it never appears on screen—set /p TOK=<token.txt on Windows—and delete the file immediately after use.
  • "Runner already configured" means stale local state (.runner, .credentials, .credentials_rsaparams). --replace only replaces the runner server-side; clear the local state first with config.cmd remove --local.

And then the lesson, in one line:

Hosted vs self-hosted

A hosted runner hands you a curated image. A self-hosted runner hands you whatever that machine happens to be.

The identical workflow that sailed through eleven hosted runners broke on three of the four home machines, each for a different and entirely local reason. None of these are bugs in the game.

1. The SDK was not where the agent could see it

A runner agent does not inherit a login shell's environment. The macOS machine keeps its SDK under ~/.dotnet and resolves no dotnet at all in a non-login shell; the Linux machine resolves different paths in login versus non-login shells (both happened to be 10.0.302—harmless, but only by luck). The fix is to probe rather than assume:

if ! command -v dotnet >/dev/null 2>&1; then
  for c in "$HOME/.dotnet" /opt/dotnet /usr/local/share/dotnet /usr/local/bin; do
    if [ -x "$c/dotnet" ]; then
      echo "$c" >> "$GITHUB_PATH"
      echo "DOTNET_ROOT=$c" >> "$GITHUB_ENV"
      break
    fi
  done
fi

2. A 2020 Mac cannot run the runner's Node.js

Every job on the macOS Big Sur machine died before doing any work at all:

dyld: Symbol not found: __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj

The agent bundles its own Node.js, built against a newer macOS. On Big Sur 11 the dynamic linker refuses it—so no JavaScript action can run there at all, which includes actions/checkout and actions/upload-artifact, and that is most of what a normal workflow is made of.

The irony worth naming

The CI tooling turned out to be less portable than the cross-platform application it was testing. The game builds and plays happily on that machine; the harness could not start. So Big Sur got a job built purely from shell steps—git clone in place of actions/checkout, and the proof left on the machine because uploading an artifact also needs JavaScript. It then played a full game.

3. The Windows machines have no bash

GitHub's default shell for run: steps is bash, which on Windows arrives with Git for Windows—not installed on either box. Every step failed with bash: command not found. (actions/checkout survived, because it falls back to the REST API when git is missing.)

4. …and PowerShell was locked down

Switching those jobs to shell: powershell then failed on the Windows 11 machine:

...\_temp\<guid>.ps1 cannot be loaded because running scripts is disabled on this system.

The runner writes every PowerShell step to a temporary .ps1 file, and a Restricted execution policy refuses script files. The insight that resolves it is worth keeping: execution policy governs script files, not inline -Command strings—which is precisely why the Part 3 remote-control tether for these same machines worked. So the Windows jobs use shell: cmd, calling powershell -NoProfile -Command inline where needed.

That detail matters beyond the trivia: the alternative was asking someone to weaken a protection on their own PC to satisfy a build. The locked-down machine now runs the job with no security setting changed on it, which is the right way round.

Results from the home machines

Ludo AI Arena final board on a self-hosted Ubuntu Linux machine, Deckard (Blue) winning after 327 turns.
Self-hosted Linux — Deckard (Blue) wins in 327 turns. A job dispatched from GitHub, executed on a machine in the next room.
Ludo AI Arena final board on a self-hosted macOS Big Sur 11.7.11 machine, rendered in dark mode, Deckard (Blue) winning after 178 turns.
Self-hosted macOS Big Sur 11.7.11 — Deckard (Blue) wins in 178 turns, from a shell-only job with no JavaScript actions. Note it came back in dark mode: the same build following the host's appearance setting, with no code involved.
Ludo AI Arena final board on a self-hosted Windows 10 x64 machine, Marvin (Green) winning after 261 turns.
Self-hosted Windows 10 — Marvin (Green) wins in 261 turns, driven entirely through cmd.
MachineShellResultWinnerTurns
Linux (Ubuntu x86_64)bash✓ playedDeckard (Blue)327
macOS Big Sur 11.7.11bash, shell-only✓ playedDeckard (Blue)178
Windows 10 x64cmd✓ playedMarvin (Green)261
Windows 11 x64cmdBuilt with 0 errors, then blocked from running — see below.

Watching it happen, live, on a desktop across the room

Screenshots are proof; watching is fun. Since the Linux machine draws through X11, it can be told to put its window on an X server running on a different computer—so a job dispatched from GitHub's cloud can paint a board onto the desktop in front of you.

The first test was manual, and doubled as a genuine stranger's-eye check of the release: clone the public repository onto that machine, build it (0 errors), and launch the game with its display pointed at the desktop's X server. It appeared, and played a full game at 15× speed:

[22:18:22] *** Marvin (Green) WINS in 360 turns! ***
WINNER: Marvin (Green)
TURNS: 360
GAME COMPLETE

After that the self-hosted workflow itself could do it, by passing the display through as a workflow input. That is the moment worth picturing: a job dispatched from a data centre, executing on a machine in the next room, drawing its window on the desktop in front of me, and reporting a completed game back to GitHub.

Two constraints, and one hazard

Only Linux can do this. Avalonia draws through native Win32 on Windows and native Cocoa on macOS; those builds have no X11 back end at all, so Windows and macOS runners—cloud or local—cannot render to an X server however it is configured.

And showing a cloud runner this way needs your X server reachable from the internet. I got as far as installing cloudflared to tunnel it and then deliberately stopped: an X server with no meaningful authentication, on a publicly resolvable hostname, is a keylogger and screen recorder waiting for a port scan. It is the obvious next idea and it is a bad one. Whatever you do here, it belongs in the teardown.

The one that would not run: Windows 11 Smart App Control

The Windows 11 machine got all the way through. The cmd steps ran, the checkout worked, the solution built with 0 errors—and then the game refused to start:

Unhandled exception. System.IO.FileLoadException: Could not load file or assembly
'...\LudoNimArena.App.dll'. An Application Control policy has blocked this file. (0x800711C7)

The registry names the culprit:

HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy
    VerifiedAndReputablePolicyState    REG_DWORD    0x1     # Smart App Control is ON

Smart App Control refuses to load binaries that are not signed by a recognised publisher or otherwise known to be reputable. A DLL compiled thirty seconds ago on the machine itself is, by definition, neither. Copying the build out of the runner's working directory into an ordinary user folder changes nothing: the policy is about the binary, not the path.

The detail that makes this a story rather than a footnote: this very machine ran this very game in Part 3. Smart App Control ships in evaluation mode and promotes itself to enforcement on its own once it decides the machine is a good candidate. Somewhere between Part 3 and Part 4 it did exactly that, and a computer that used to run my build quietly stopped being able to.

Would signing fix it? Mostly no

The obvious next thought is "just sign it," and the cheap version of that does not work. Smart App Control is a WDAC policy in Verified and Reputable mode, which has two consequences people are routinely surprised by:

  • Local trust is irrelevant. Generating a self-signed certificate and installing it into Trusted Root or Trusted Publishers does nothing. SAC's verdicts come from Microsoft's reputation service, not from the machine's own trust stores—unlike SmartScreen prompts or ordinary Authenticode checks, which local trust can influence.
  • SAC's policy cannot be edited. An ordinary WDAC policy lets an administrator add signer rules; the built-in SAC policy does not. Trusting your own certificate means replacing SAC with a custom WDAC policy—which first requires turning SAC off.
ApproachResult
Self-signed certificateIgnored by Smart App Control.
OV certificate from a public CAWorks eventually; reputation accrues with downloads and time.
Azure Trusted SigningThe realistic modern route—trusted essentially immediately, low monthly cost.
EV certificateStrong reputation, but expensive and tied to a hardware token.
Disable Smart App ControlRejected. On Windows 11 this is a one-way door: once disabled it cannot be re-enabled without resetting Windows. Permanently weakening a machine's security to make a demo run is a bad trade, and it is the owner's call, not the build's.

The honest framing: this is not a Windows 11 portability gap. GitHub's hosted windows-11-arm runner is Windows 11 and played a complete 406-turn game from the same source, and Windows 10 on the same home network played one too. What I actually found is a single machine whose security policy refuses locally-compiled binaries—a distribution problem rather than a cross-platform one, and one that every unsigned open-source Windows build now runs into. If you ship unsigned binaries to Windows users, some of them cannot run your software, and the error message will blame a missing assembly rather than the policy.

An actual release

A repository you can clone is still a repository that requires a toolchain. The last step was a tagged v1.0.0 release with packages someone can simply download and run.

Version identity is stamped into every assembly in the solution—Version, AssemblyVersion, FileVersion, Product, Company, Copyright, repository URL—so a stray binary can be identified without the repository around it:

ProductName    : SANYALnet Labs Ludo AI Arena
FileVersion    : 1.0.0.0
ProductVersion : 1.0.0+3fe00b23557841bbb9fddfdef93d923b01214ade
CompanyName    : SANYALnet Labs

The packages are deliberately minimal—framework-dependent builds that use the .NET 10 runtime you already have, rather than self-contained bundles that each carry their own copy:

Platformx64arm64
Linux9.98 MB9.5 MB
Windows11.42 MB10.78 MB
macOS13.29 MB13.29 MB

Roughly 10–13 MB each, several times smaller than the equivalent self-contained bundles would be. There is no installer: extract and run. Nothing is written outside the folder, no registry keys are added and no services are installed, so uninstalling is deleting the folder. Each archive carries INSTALL.txt and LICENSE, and the release publishes SHA256SUMS.txt so a download can be verified:

# Linux / macOS
mkdir ludo-arena && tar -xzf LudoArena-1.0.0-linux-x64.tar.gz -C ludo-arena && cd ludo-arena
chmod +x LudoNimArena.App      # archives are built on Windows; restore the exec bit
./LudoNimArena.App

# verify what you downloaded
sha256sum -c SHA256SUMS.txt --ignore-missing
Two platform notes for downloaders

On macOS, Gatekeeper quarantines downloaded files—clear it in the extracted folder with xattr -dr com.apple.quarantine . On Windows 11 with Smart App Control enforcing, the unsigned binaries will not load at all, for the reasons above. Building from source avoids both.

Build and publish your own, end to end

If you want to do this to your own project, the whole sequence is short. Everything below assumes the GitHub CLI is installed and authenticated (gh auth login).

STEP 01

Stage a clean copy and start a fresh history

Copy only the source—no bin/, no obj/, nothing machine-specific—into a new folder, and give it its own repository so it inherits nothing from the build workspace.

mkdir -p ~/dev/MyProject-release && cd ~/dev/MyProject-release
# copy your sources in here, leaving build output behind
git init -b main
STEP 02

Add the license, README and .gitignore

Write the LICENSE first, because the per-file headers refer to it. The .gitignore should cover build output and an explicit secrets block—.env, *.key, *.pem, *.token, anything resembling an API key—so a stray credential cannot be committed by accident.

STEP 03

Add the header gate and run it locally

Put the checker in scripts/, then wire it to a workflow that runs on every push and pull request. Run it locally before you trust it, and check its file-type list against what is actually in the repository.

bash scripts/check_license_headers.sh
Checked 54 source file(s).
OK: every project source file carries the mandatory license header.
STEP 04

Publish, then verify from the outside

Push, then prove the result rather than assuming it. Clone the public repository into a temporary directory and scan the whole history—not just the current files—for anything that should not be there.

gh repo create myname/MyProject --public --source=. --push

# verify from a fresh clone of what actually landed
git clone https://github.com/myname/MyProject /tmp/verify && cd /tmp/verify
git ls-files | wc -l
git grep -nIE "api[_-]?key|secret|password|BEGIN [A-Z ]*PRIVATE KEY" $(git rev-list --all) -- . | head
Screenshots leak more than you think

My first commit included four screenshots from the Part 3 tests—whole-desktop captures that incidentally showed taskbars, unrelated windows, a running virtual machine and file names. Deleting them in a later commit is not enough, because they remain in history. They had to be stripped from every commit with git filter-repo and force-pushed. Crop or re-shoot before publishing, and prefer application-only captures.

STEP 05

Probe every runner GitHub will give you

Before writing a real matrix, find out empirically which labels you can actually get. Give each job continue-on-error: true and fail-fast: false so unavailable labels report themselves instead of aborting everything.

strategy:
  fail-fast: false
  matrix:
    label: [ubuntu-24.04, ubuntu-22.04, ubuntu-24.04-arm, ubuntu-22.04-arm,
            windows-2025, windows-2022, windows-11-arm,
            macos-15, macos-14, macos-15-intel, macos-13]
STEP 06

Make the run prove something a human would accept

"It compiled" is a weak claim. Decide what would actually convince you—for a game, a completed match; for a service, a real request served—and make the job assert on it. A greppable marker in a transcript is enough, and it is far more honest than a green checkmark that only means the compiler was satisfied.

STEP 07

Tag and release

git tag -a v1.0.0 -m "First public release"
git push origin v1.0.0

# minimal, framework-dependent packages per platform
dotnet publish src/MyApp -c Release -r linux-x64 --self-contained false -o out/linux-x64

gh release create v1.0.0 --title "v1.0.0" --notes-file NOTES.md out/*.tar.gz out/*.zip SHA256SUMS.txt

When it goes sideways

bash: command not found (on a self-hosted Windows runner)

Cause: GitHub's default shell for run: steps is bash, which on Windows comes from Git for Windows. It isn't installed.

Fix: give the Windows job shell: cmd (or install Git for Windows). Don't assume the default shell exists on a machine you didn't build.

...ps1 cannot be loaded because running scripts is disabled on this system

Cause: the runner writes each PowerShell step to a temporary .ps1, and a Restricted execution policy blocks script files.

Fix: use shell: cmd and call powershell -NoProfile -Command inline—execution policy does not restrict inline commands. Preferable to asking the machine's owner to relax a security setting.

dyld: Symbol not found ... (every job on an older Mac)

Cause: the runner's bundled Node.js is built for a newer macOS, so no JavaScript action can start.

Fix: write that machine a shell-only job—git clone instead of actions/checkout, and leave artifacts on the machine instead of uploading them.

dotnet: command not found on a self-hosted runner that definitely has .NET

Cause: the agent does not inherit your login shell's PATH.

Fix: probe the usual install locations and publish the winner via GITHUB_PATH and DOTNET_ROOT (see the snippet above).

The job runs but you never see the window on the machine's screen

Cause: the agent is in Windows session 0—either because it was installed as a service, or because run.cmd was started over SSH, which also lands in session 0. Session 0 is isolated from the desktop.

Fix: start run.cmd from a console on the actual desktop. Or stop needing the screen at all—have the application render itself to a PNG.

An Application Control policy has blocked this file (0x800711C7)

Cause: Windows 11 Smart App Control is enforcing and will not load unsigned, unreputable binaries—including ones you just compiled.

Fix: there is no cheap one. Sign with a reputable certificate (Azure Trusted Signing is the practical route), or build and run on a machine without SAC enforcing. Do not disable SAC casually—it cannot be re-enabled without resetting Windows.

pkill over SSH kills your own session

Cause: pkill -f MyApp matches the command line of the shell that is running it, so it kills itself before doing anything useful.

Fix: use a character class so the pattern cannot match its own text: pkill -f "MyAp[p]".

Mandatory: tear it all down when you are finished

Do not skip this

The self-hosted-runner work above opens doors on your own machines. A reader who follows along and then loses interest leaves those doors open, and unlike a failed build, nothing will ever remind you. Run the checklist when you finish—not "later."

Three exposures get created here, and it is worth being explicit about what each one actually risks:

  1. Self-hosted runners attached to a public repository. The serious one. A registered runner is a machine waiting for GitHub to send it code to execute, inside your network, as your user. Manual-dispatch-only is a real mitigation, but mitigation is not removal: while the agents stay registered, the capability exists, and it outlives your interest in the project.
  2. An X server listening on the network. An X server bound to 0.0.0.0:6000 has no meaningful authentication—anything that can reach the port can read the screen and inject keystrokes. Behind NAT that is a LAN-only exposure; forward the port and it becomes an internet-wide one.
  3. A tunnel that publishes a local service. I considered one so cloud runners could draw on a desktop X server at home, and deliberately did not go through with it. A quick tunnel hands out a publicly resolvable hostname, and an unauthenticated X server on a public hostname is a keylogger and screen recorder waiting for a port scan. It is the obvious next idea, and it is a bad one.
# 1. Remove every self-hosted runner FIRST — this revokes GitHub's ability to
#    dispatch to those machines immediately, even if the agents are still running.
gh api repos/<owner>/<repo>/actions/runners -q '.runners[].id' |
  while read id; do gh api -X DELETE "repos/<owner>/<repo>/actions/runners/$id"; done

gh api repos/<owner>/<repo>/actions/runners -q '.total_count'    # must print 0

# 2. Stop the agent on each machine
#    Linux/macOS : kill the run.sh process   (screen -r <name>, then Ctrl-C)
#    Windows     : close the run.cmd window
#    Installed as a service?  ./svc.sh uninstall   (or sc stop / sc delete <name>)

# 3. Kill any tunnel — check for cloudflared / ngrok processes AND for a
#    registered service, because a service quietly returns after a reboot.

# 4. Quit your X server and confirm nothing is listening on :6000

# 5. Delete leftover credentials: any registration-token file written to disk,
#    plus .runner / .credentials / .credentials_rsaparams if you are done.

Then verify instead of assuming: list listening sockets, and re-read the runner count until it says zero.

Service or plain process? Check before you walk away

None of my four agents had been installed as services—they were plain run.sh / run.cmd processes, so a reboot ends them permanently. That distinction is the difference between "I closed the window" and "this thing restarts every time I boot," and it is worth checking rather than assuming. For the record: all four runners were deregistered (total_count: 0), cloudflared was removed having never established a tunnel at all, and the X server stayed LAN-only behind NAT throughout.

AI parody notice

The game and its on-screen "reasoning" are fictional and generated by artificial intelligence for demonstration only. Nothing here is a real statement, policy, endorsement or official position of any person, company or institution, including GitHub, Microsoft, NVIDIA, DeepSeek or any model provider named above.

The result

Part 3 ended with a game that worked and a build report that said everything was fine. Part 4 took that same code, put it somewhere strangers can reach, and pointed fifteen computers at it. Here is what that was actually worth:

  • Eleven clean machines rejected the very first push in about ninety seconds, and over three rounds surfaced four defects—one of them a real engine bug that playing the game could never have revealed.
  • A specification item that had been silently skipped got implemented, because someone finally tried to use the software in a way the demo never did.
  • The portability claim stopped being a claim: three operating systems, two CPU architectures, one codebase, zero platform-specific code—and a picture from each machine to show for it.
  • The CI tooling turned out to be less portable than the application it was testing, which was not the result I expected.
  • A Windows 11 security feature that had quietly promoted itself to enforcement now refuses to run freshly-compiled software—a distribution problem every unsigned open-source project will meet.

None of that required cleverness. It required machines that had never seen the project before, and a willingness to let them disagree with the build report. That is the whole trick, and it is available to anyone with a public repository: GitHub will hand you eleven computers, for free, and they are not impressed by "it works on my machine."

The game is at github.com/tuklusan/Ludo-Arena—clone it, or download a package from the v1.0.0 release and watch four AI players argue over a plastic board on whichever computer you happen to own.


There is a particular satisfaction in the moment a stranger's machine runs your software correctly for the first time—and a sharper one when eleven of them refuse, all for the same honest reason, and hand you a bug you would never have found alone. The AI agents wrote this game in an afternoon for about a dollar. Proving it was real took longer than building it, and taught me more.

The series: Part 1: Install ChatDev 2.0 on Linux · Part 2: Live AI News Debate Wall · Part 3: Building the Ludo AI Arena

References: Ludo Arena repository · v1.0.0 release & downloads · My ChatDev fork · GitHub-hosted runners · Avalonia UI · .NET 10

Runner images, available labels and SDK patch levels all drift. Re-check GitHub's runner documentation and the repository's global.json if a detail here has moved since publication. Every screenshot in this post was rendered by the application itself on the machine named in its caption.

No comments:

Post a Comment

"SEO" link builders: move on, your spam link will not get posted.

Note: Only a member of this blog may post a comment.