Skip to content

Environments and releases

A change travels one road: a branch, a pull request, main, UAT, production. The images are built once, for the commit on main; UAT and production run those same images, and a tag says which commit each one runs (ADR 0041). The owner decides when; the agent does the steps.

The four environments

DevelopmentCIUATProduction
ForBuilding and tryingThe automatic gateThe family’s daily use, and where the images bound for production run firstThe public
WhereThe developer’s machine: pnpm dev or compose project tohe, ports 47xxGitHub Actions: the pull request, then mainThe homelab, 10.100.0.2: hub http://10.100.0.2:4820, console :4821, docs :4810, games :4830, compose project tohe-uatHub https://toheplay.io.vn (API at /api), console https://console.toheplay.io.vn behind Cloudflare Access, games https://games.toheplay.io.vn, docs https://docs.toheplay.io.vn (ADR 0040, ADR 0042)
RunsThe working treeThe checks, then the images and the chartThe images of the newest uat- tagThe images of the newest prod- tag
DataThrowawayThrowawayReal, the family’sReal once people other than the owner use it
Config lives incompose.yaml.github/workflows/ci.ymlcompose.uat.yaml and ~/.config/tohe-uat/uat.env on the homelabThe apps/tohe component of the platform-infra repository (Argo CD), secrets in Infisical /tohe
An agent mayAnything—Tag and deploy when the owner says “deploy”; read logsRead from outside; tag and promote when the owner says “lên prod”

What each image reads is in Configuration; how UAT is run in UAT environment; the chart production uses in Self-hosting (ADR 0021, ADR 0010).

From idea to production

flowchart TD
  idea([Idea]) --> big{Bigger change?}
  big -- yes --> plan[Think it through and plan it with the owner]
  plan --> spec[Spec or ADR in docs]
  big -- no --> branch
  spec --> branch[Branch and worktree]
  branch --> pr[Pull request and pnpm verify]
  pr --> ci[CI: verify, chart, stack]
  ci --> merge{Owner says merge}
  merge --> main[main: CI builds images and chart once]
  main --> deploy{Owner says deploy}
  deploy --> uat[Tag uat-YYYY-MM-DD.N]
  uat --> script[deploy/uat/deploy.sh with the tag]
  script --> uatcheck[Checks on UAT]
  uatcheck --> use[Family and owner use it]
  use --> prod{Owner says lên prod}
  prod --> ptag[Tag prod-YYYY-MM-DD.N]
  ptag --> release[GitHub Release with notes]
  release --> infra[platform-infra pull request: targetRevision only, merged by the agent]
  infra --> argo[Argo CD syncs]
  argo --> prodcheck[Checks on production]

Nothing is built after main. A fix in a hurry takes the same road with less time on UAT; nothing is patched on production directly.

The owner’s three words

The owner saysThe agent does
“merge”Merges the pull request the owner reviewed, with a merge commit, once its checks are green. CI then builds and publishes the images for the new commit on main. Nothing is merged without the owner’s word.
“deploy”Tags the commit uat-… (the newest commit of main unless told otherwise), pushes the tag, runs deploy/uat/deploy.sh <tag> and runs the checks. Reports the result.
“lên prod” (or the owner tags prod-… themselves)Takes the commit of the newest uat- tag unless the owner names another, checks that it carries a uat- tag, tags it prod-…, publishes a GitHub Release, opens the platform-infra pull request that changes only targetRevision of apps/tohe, merges it, waits for Argo CD, and runs the checks. Reports the result. If a prod- tag the owner made points at a commit without a uat- tag, stops and asks.

Any other change to platform-infra stays a pull request the owner merges.

Tags

Annotated tags on commits of main: uat-YYYY-MM-DD.N and prod-YYYY-MM-DD.N, where the date is in Việt Nam time and N counts the tags of that day from 1. A tag is never moved or deleted — except a prod- tag that was never promoted, on the owner’s word. The newest tag of a prefix (latest date, then highest N: git tag -l 'uat-*' --sort=-v:refname) is what that environment runs. Rolling back is a new tag on an older commit (below).

Rules before tagging. pnpm release:check <ref> --for uat|prod checks all of them for a commit, read-only, and prints the next free tag name for today. Run git fetch origin --tags first: it reads origin/main and the tags as they are and does not fetch.

RuleWhat release:check looks at
The commit is on maingit merge-base --is-ancestor <commit> origin/main
CI is green for itThe CI workflow’s run for the push of that commit to main, from the GitHub API, concluded success
Its images and chart existThe four images ghcr.io/vinh466/tohe-play/<api, hub, games or docs>:sha-<commit> and the chart version <Chart.yaml version at that commit>-g<7-char sha> are on GHCR
A prod- tag goes only on a commit that has a uat- tag (--for prod)git tag --points-at <commit> --list 'uat-*'
Terminal window
git tag -a uat-2026-10-21.1 <commit> -m "UAT 2026-10-21.1"
git push origin uat-2026-10-21.1
deploy/uat/deploy.sh uat-2026-10-21.1
git tag -a prod-2026-10-23.1 <commit> -m "Production 2026-10-23.1"
git push origin prod-2026-10-23.1
gh release create prod-2026-10-23.1 --generate-notes --notes-start-tag <previous prod- tag> # the first time: no start tag

The release notes come from the pull requests since the previous prod- tag, so pull request titles matter. git tag -l 'prod-*' and the GitHub Releases list what production ran and when. A GitHub tag ruleset on uat-* and prod-* (no update, no deletion) stops a tag from being moved by mistake; the owner can add it in the repository’s settings.

What CI publishes for each commit on main: the images ghcr.io/vinh466/tohe-play/<api|hub|games|docs>:sha-<full commit> and the chart oci://ghcr.io/vinh466/charts/tohe at version <Chart.yaml version>-g<7-char sha>. The targetRevision in platform-infra is that chart version.

Checks after a deploy

Two read-only scripts, in the package tools/env-check. Each prints one line per check (✔ or ✘ with a short detail) and a summary, and exits 1 if any check failed. They write nothing: they only read (GET and HEAD, DNS over HTTPS, docker compose logs, one SELECT).

pnpm check:uat (on the homelab)pnpm check:prod (from anywhere)
Expects--expect <ref>, else the newest uat- tag; apiVersion is the first 12 characters of its commit--expect <ref>, else the newest prod- tag; apiVersion is sha-<full commit>. With neither, it prints the version and warns
Hub/api/health is Healthy; /api/config; / is the public home page (indexed, its own CSP without inline script, Chơi ngay leads to /play); /sitemap.xml is XML with absolute URLs and robots.txt names it; /play answers; /admin/ is a 404/api/health is Healthy; /api/config (version, registration, proof of work, providers, email); the same checks of /, /sitemap.xml and robots.txt; /play answers with CSP, HSTS and nosniff; http:// redirects to https://; www redirects to the apex; /admin/ is a 404
Console:4821: / is the console’s page with noindex; /api/admin/overview is a 401 without its session; /play is a 404https://console.toheplay.io.vn/ redirects to Cloudflare Access (*.cloudflareaccess.com) — or, without it, is the console’s page with noindex, nofollow
DocsThe docs host: / answers the docs, /docs/<page> answers 301 to /<page>, the query kept, and /play redirects to the hub’s /playThe same
Games/catalog.json lists games; / redirects to the hub’s /play with noindexThe same, and the first game’s entry page answers with form-action 'none' in its CSP
UnderneathThe API log since --since (default 15 minutes) has no fail:, crit:, Unhandled or Exception line; the newest migration in the database is the newest migration file of the commit. Skipped with --no-docker, or when Docker is missingThe mail DNS records: DKIM, the send subdomain’s SPF and MX, DMARC, and the domain’s MX

The addresses can be overridden (--hub, --console, --games, --docs, --domain; --no-dns skips the mail records). After a deploy the agent runs pnpm check:uat --expect <tag> or pnpm check:prod --expect <tag> with the tag it just made. --since for UAT is how far back to read the log, for example the time of the deploy.

If a check fails, say so and stop. Rolling back is the owner’s call (below).

Testing, layer by layer

LayerWhatRuns
UnitVitest: the game logic, the SDK, the hubpnpm test, in pnpm verify and CI (what runs when)
APIxUnit against PostgreSQL through Testcontainers, never a shared databasepnpm test, in pnpm verify and CI (needs Docker)
GamesThe conformance kit: the Game Contract, the CSP, both themespnpm conformance, in pnpm verify and CI
The stackThe e2e smoke test, routes (the hub serves the map of its paths, ADR 0042), and rooms: three phones play a Tiến Lên table (ADR 0016)CI against the compose stack; locally pnpm --filter @tohe/e2e smoke and rooms against a running stack
The chartLint, every profile rendered, every guard, an install and an upgrade on kind, helm testCI, job chart; locally deploy/helm/tohe/ci/check.sh for the first part
After a deployThe checks abovepnpm check:uat and pnpm check:prod, run by the agent after a deploy
Real devicesPhones: touch, sound, install, the installed PWAThe owner; below for how to get a build on a phone

Definition of Done says what a pull request needs before it is finished.

Continuous integration

.github/workflows/ci.yml runs on every pull request and every push to main. A first job, What the change touches (scripts/ci-scope.sh), sorts a pull request by the files it changed since it left its base:

ScopeThe pull request changes onlyWhat runs
notesplan notes (plans/), agent and skill definitions (.claude/), AGENTS.md, CLAUDE.md, README.mdnothing: none of it is built or shipped
docsthe above and the spec under docs/Biome, and the docs app’s build, which checks every internal link
allanything elseevery job: verify, chart, stack
  • A push to main is always all, and its pnpm verify runs every task without reading a cache (--cache=local:w): what gets published has passed every check from scratch.
  • On a pull request, pnpm verify reads the Turborepo cache the base branch saved: a task whose inputs did not change replays its result. A task’s inputs are declared in turbo.json; a file a task reads from outside its package (as the docs app reads docs/) must be listed there, or a change to it will not rebuild the task. Turborepo’s --affected is not used for the same reason: it looks only at which package folders changed.
  • Verify, chart and stack run side by side; publish waits for all three, on main only.

Local development

Detail is in Local development. In short:

  • Mode A, pnpm dev: hot reload for the API, the games origin, the hub, the public pages and the docs; PostgreSQL and Mailpit from compose.
  • Mode B, docker compose up --build -d: the production-like containers. Do not run both at once; they share ports.
  • Mode C, pnpm stack:image <tag|commit>: the exact images CI published for a commit, in the tohe-image project on the same ports, to reproduce a problem seen on UAT or production; --down removes it and its throwaway database. It never builds, and refuses while the development stack runs.
  • Demo data, pnpm seed: a family account (an adult and a child profile), a friend’s account and a few scores in the local database, in any of the modes. It refuses outside the Development environment, so UAT and production can never be seeded.
  • Phones on the LAN: TOHE_PUBLIC_HOST=<lan address> with Mode B. Plain HTTP, so no service worker and no install.
  • An Android phone over USB: forward the hub and the games origin, adb reverse tcp:4720 tcp:4720 and adb reverse tcp:4730 tcp:4730, then open http://localhost:4720. localhost is a secure context, so the service worker runs and Chrome can offer to install the app.
  • An iPhone: try it on production while only the owner uses it.
  • One dev stack runs per machine at a time. Parallel work (several branches or worktrees) relies on pnpm verify, whose API tests use their own Testcontainers, and on CI.

Data and migrations

  • Development and CI data is throwaway. UAT holds the family’s data; production runs the same migrations on its own database.
  • A migration is final once it is merged to main: it is never edited again, and a correction is a new migration. Until then, the pull request that adds it may still change it.
  • A column is dropped or renamed one release after the code stops using it, so the previous tag still runs.
  • Never run migrations against UAT or production by hand: the API applies them when it starts, and a UAT deploy backs the database up first.
  • No data leaves UAT or production for a development machine, except a UAT dump used to dry-run a migration and deleted afterwards.
  • Changing the product’s behaviour freely, removing a feature without a compatibility window, stays allowed while production is the owner’s test bed.

Rolling back

  • UAT: a new uat- tag on the older commit, deployed with deploy/uat/deploy.sh <new tag>; the old tags stay. The database is not rolled back with the code: that is why a migration waits one release before it removes anything. A backup from before the deploy is in ~/backups/tohe-uat/ (UAT).
  • Production: a new prod- tag on an older commit that has a uat- tag, promoted the same way (release, platform-infra pull request, Argo CD). The old tag stays.