The gate list is on the main engineering page. This page is the mechanics underneath it: the actual GitHub Actions job sequence, the refusal points built into the promotion path, why there's still no rollback, and the stored procedures every score update runs through.
A green build means a commit matches its spec. It doesn't mean the system works. Getting from a reviewed commit to something a real person is using takes two hops - and only one of them has a person in it.
SIT moves on its own, the instant the gates are green. Nobody chooses that hop; the checks do. Production moves only when a specific person decides. He runs the promotion, a required reviewer approves it, and only then does a commit that's already been tested in SIT - byte-for-byte, never a rebuild - reach a real server.
Every arrow below is a real step in the deploy and promote workflows, not a simplification. The refusal points exist specifically to be refused - they're not edge cases, they're the design.
mainsit rebuilt and health-checked - automaticsit's exact tip - ✗ refused on any other shaThe full safeguard list lives on the main engineering page. Three of them fire specifically at this one boundary:
The green check. Before anything is promoted, a check asks whether every automated check on that exact commit actually passed - and it fails closed in three separate directions: a check that hasn't run at all, one still running, and one that genuinely failed. Treating "nothing has reported yet" as a refusal, not a pass, is the detail a naive version of this gets wrong - pushing and promoting before CI even starts would otherwise sail straight through.
The human reviewer. Moving a commit into production requires a named person's approval on that specific release, through GitHub's own required-reviewer gate - a real click, not a formality. Stated honestly, it's also a limit: it's one person approving their own release, not a second independent reviewer of the release decision itself.
The SIT-tip rule. Production can only fast-forward to whatever commit SIT is currently running. No shortcut, no "just this once" build that skips SIT.
The application splits into three self-contained Docker containers - the website, the database, the reverse proxy - so it runs the same way on a laptop as it does in production. One config for local development, a stricter, locked-down one for production.
web built from source, port open for direct testing
live code reload for developers
db standard MySQL image, port open for local inspection
starter data loaded automatically on first run
nginx reverse proxy, ports 80/443 open
handles HTTPS certificates
web production mode, served by gunicorn
4 worker processes, 120s request timeout
not directly reachable - traffic must go through nginx
db same MySQL image, auto-restarts if it crashes
only reachable by the website container, not the internet
nginx reverse proxy, port 80 only
the single public entry point to the whole system
Flask's own built-in server is fine for local testing but isn't built to handle real traffic reliably, so gunicorn - a production-grade application server - takes over that job once deployed. The website container's own build is intentionally minimal: only what production needs to run (the web framework, the database driver, the SES email library, the Claude integration) - a long tail of heavier tools (browser automation, data scraping, desktop GUI libraries) that only now-retired scripts ever used stays out of the image entirely, which keeps the attack surface smaller and deploys fast.
Merging to main doesn't deploy anything by itself - it only runs the five gate jobs. A change only reaches players after it's separately promoted to prod, and only a push to prod triggers the real deploy.
Once triggered, a deploy only ever rebuilds and restarts the website container; the database and reverse proxy stay running throughout, so a release never causes a database outage.
There's no dedicated migration tool in place - a deliberate ease-of-delivery choice against a hard, immovable deadline (the tournament starts when it starts), not a lack of awareness. A real migration tool is already scoped as the next offseason enhancement.
The original schema - core tables and all reusable database logic - is set up automatically the first time the database is created, and never runs again. Everything added since (accounts, password resets, scoring logs, individual columns) is instead built into the application's own startup code and re-checked on every restart. In practice, the startup routine doubles as the migration process: rolling out a schema change means adding a small piece of startup logic and deploying it. The code is written defensively so four containers restarting at once don't step on each other.
| Procedure | Used by | What it's for |
|---|---|---|
InsertTeams / DeleteTeamsByYear | Season setup | Loads the 68-team bracket field for the year |
InsertPlayers / DeletePlayersByYear / DeletePlayersOnTeamByYear | Season setup, admin tools | Loads team rosters, or reloads one team at a time |
getAllPlayers | Season setup | Reads back the full player pool for the active year |
InsertPlayer_Pts | Live scoring engine | Records a player's or team's points for a completed game |
DeletePlayer_pts / DeleteTeamPlayer_pts | Live scoring engine | Clears a game's prior results right before rewriting them, so a rescore never leaves stale numbers on screen |
ExistsPlayer_pts | Live scoring engine | Checks whether a game's results are already recorded, so it isn't scored twice |
DeletePicks | Admin tools | Clears all player picks, for a full season reset |
Every score update follows the same safe pattern: clear the old numbers and write the new ones in a single, all-or-nothing step, rather than editing figures in place - so a score can be corrected and rewritten at any time, even hourly, without a player ever seeing a half-updated number.