Skip to main content

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/):

HostWhereRemoteUsed for
githubA GitHub App on GAMES_GITHUB_ORGhttps://github.com/<org>/<name>.git, username x-access-tokenThe default.
artifactsCloudflare Artifacts, namespace GAMES_ARTIFACTS_NAMESPACE, through the edge Worker's ARTIFACTS bindinghttps://<account>.artifacts.cloudflare.net/git/<namespace>/<name>.git, username xGames 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 saves games.repo and, for Artifacts, games.repo_host. createRepo and saveRepo split 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 (plus branch: "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 RepoHost in artifacts.ts.
  • ensure revokes the day-long write token that creating a repo returns.
  • Tokens last an hour by default.
  • moveBranch mints a five-minute write token and sends git's own ref update over smart HTTP (src/lib/game-repo/smart-http.ts), the same request git push --force <remote> <sha>:<branch> makes, because the server has no git binary.
  • 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:

FunctionWhat 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​

  1. Pick a Cloudflare account on Workers Paid and a namespace name.
  2. In edge/wrangler.jsonc, enable the artifacts binding with that namespace, run npx wrangler types, and deploy the edge with INTERNAL_API_SECRET set to the server's value.
  3. Apply db/migrations/0016_game_repo_host.sql (npm run db:migrate). It is additive.
  4. Set GAMES_ARTIFACTS_NAMESPACE on the server, with CRESCENT_EDGE_URL and INTERNAL_API_SECRET, and deploy. Nothing changes yet.
  5. Move games: a dry run, then one game, then --all.
  6. Set GAMES_REPO_HOST=artifacts so 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.