Game repos
Every game has one private git repo. The builder's sandbox clones it, the workflow pushes each run's commit, community drafts live on community/<userId>, a merge lands on the public branch with git merge --no-ff, and local developer mode hands a developer's machine an hour of write access.
Two hosts can hold them, behind one interface (src/lib/game-repo/):
| Host | Where | Remote | Used for |
|---|---|---|---|
github | A GitHub App on GAMES_GITHUB_ORG | https://github.com/<org>/<name>.git, username x-access-token | The default. |
artifacts | Cloudflare Artifacts, namespace GAMES_ARTIFACTS_NAMESPACE, through the edge Worker's ARTIFACTS binding | https://<account>.artifacts.cloudflare.net/git/<namespace>/<name>.git, username x | Games made while GAMES_REPO_HOST=artifacts, and games moved by scripts/move-repos-to-artifacts.mts. |
The Artifacts host is built and tested against Cloudflare's local simulator. Artifacts is in open beta on the Workers Paid plan; check its limits (per file, per push and per repo) and pricing before you switch a deployment to it.
How it works
src/lib/game-repo/index.ts is the only way in:
ensureRepo(game): creates the repo (private, empty) on first need and savesgames.repoand, for Artifacts,games.repo_host.createRepoandsaveReposplit that for scripts that push before saving.readAccess(game),writeAccess(game):{remoteUrl, username, password, expiresAt}, valid for an hour, for this repo only. Local developer mode's repo-token route returns this (plusbranch: "main"), so the CLI never knows the host.moveBranch(game, sha, branch?),readFile(game, path, ref),commitExists(game, sha).plannedRepo(game): the repo's name before it exists (a run's Cloudflare gateway token is scoped by it).
Which host: a game with a repo stays on games.repo_host (missing means github). A game without a repo gets GAMES_REPO_HOST (github when unset, or artifacts).
games.repo is <owner>/<name> on both hosts: the GitHub org or the Artifacts namespace, then the same cm-<slug>-<id6>. The Cloudflare gateway scopes a game's backend by the name only, so moving a repo does not move its Cloudflare resources.
The run script (runScript, src/lib/daytona.ts) clones with the password in REPO_TOKEN, through git's credential helper, and unsets it before Claude Code starts. pushRunCommit takes a GitAccess.
GitHub
src/lib/game-repo/github.ts: GitHub App calls on fetch, with a JWT signed in Node, so scripts run under tsx use the same code. One-repo installation tokens, contents: read or write, for an hour.
Artifacts
src/lib/game-repo/artifacts.ts calls the edge Worker, which holds the binding (edge/src/repos.ts):
POST /internal/repos/ensure {name, description?} -> {name, remote, defaultBranch, created}
POST /internal/repos/info {name} -> {name, remote, defaultBranch, lastPushAt}
POST /internal/repos/token {name, scope, ttl?} -> {remote, token, scope, expiresAt}
POST /internal/repos/file {name, ref, path} -> the file's bytes, or 404
POST /internal/repos/commit {name, sha} -> {exists}
Authorization: Bearer <INTERNAL_API_SECRET>
- The binding is used instead of Artifacts' REST API so that the server holds no Cloudflare API token that can reach every namespace, the binding is limited to one namespace, and the local simulator tests the whole path. Switching to REST would be another
RepoHostinartifacts.ts. ensurerevokes the day-long write token that creating a repo returns.- Tokens last an hour by default.
moveBranchmints a five-minute write token and sends git's own ref update over smart HTTP (src/lib/game-repo/smart-http.ts), the same requestgit push --force <remote> <sha>:<branch>makes, because the server has nogitbinary.- No route deletes a repo.
Forks
A fork is a player's own copy of someone else's game, used by local developer mode. The host interface covers it:
| Function | What it does |
|---|---|
forkRepoName(game, userId) | The fork's name: <repo name>-fork-<12 hex> |
createFork(game, userId, seed) | On Artifacts, the binding's fork with the default branch only. On GitHub, a new repo seeded by a copy, since GitHub cannot fork a repo into the organization that owns it |
copyCommit(from, to, sha, branch, {expected}) | Copies a commit from one repo onto another's branch over git smart HTTP, as a compare-and-swap, with no git binary |
branchHead(game, branch) | The branch's current commit |
isAncestor(game, a, b) | Whether one commit is in another's history. GitHub: the compare API. Artifacts: the edge walks every parent, at most 3,000 commits |
On Artifacts the server reaches these through two more internal edge routes:
POST /internal/repos/ancestor {name, ancestor, sha} -> {ancestor, complete}
POST /internal/repos/fork {name, target, description?} -> {name, remote, defaultBranch, created}
Tests
npm test runs src/lib/game-repo/game-repo.test.ts: the edge Worker on Miniflare with Cloudflare's local Artifacts simulator, and the server's Artifacts host calling it. It covers creating a repo twice, pushing with the run script's own push command (a 12 MB model included), cloning with a read token and failing to push with it, a draft branch, a forced push, a merge onto the public branch, reading a file, commitExists, moveBranch, the move script's mirror, and the edge refusing bad requests. The GitHub host is tested with GitHub's API stubbed. The Artifacts tests need cd edge && npm install; without it they are skipped.
Moving existing games
scripts/move-repos-to-artifacts.mts --game <id> | --all [--dry-run]. For each game on GitHub it makes <namespace>/<same name>, mirrors every branch and tag, checks that the public head and every version's commit_sha are on the new repo and that nothing moved meanwhile, then sets games.repo and games.repo_host = 'artifacts'. A game with a run in progress is skipped. It is safe to run again, and the GitHub repo is left in place.
A developer working on a moved game in local developer mode gets the new remote from their next repo token.
Switching a deployment to Artifacts
- Pick a Cloudflare account on Workers Paid and a namespace name.
- In
edge/wrangler.jsonc, enable theartifactsbinding with that namespace, runnpx wrangler types, and deploy the edge withINTERNAL_API_SECRETset to the server's value. - Apply
db/migrations/0016_game_repo_host.sql(npm run db:migrate). It is additive. - Set
GAMES_ARTIFACTS_NAMESPACEon the server, withCRESCENT_EDGE_URLandINTERNAL_API_SECRET, and deploy. Nothing changes yet. - Move games: a dry run, then one game, then
--all. - Set
GAMES_REPO_HOST=artifactsso new games are made there.
To go back, unset GAMES_REPO_HOST: new games go to GitHub again. Moved games stay on Artifacts unless their games.repo and repo_host are set back by hand; their GitHub repos still have everything up to the move.