From a green commit to a released one

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.

3 refusal gates
required reviewer, fast-forward-only, green-check re-verified - any one blocks the promotion
Byte-for-byte
production runs the exact commit SIT already tested - never a rebuild
No rollback, by decision
a bad release rolls forward through SIT again, it doesn't revert
4 gunicorn workers
production's web container - not Flask's own dev server
11 stored procedures
set up once, on day one, and never touched again
0 dedicated migrations
schema changes ride the app's own startup code instead

Two hops, one of them human

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.

  1. Push to main
  2. 5 gates - secrets · tests · lint · bandit · pip-audit - automatic, all green, no one asked
  3. sit rebuilt and health-checked - automatic
  4. Business testing - a real person, a real browser, the first time anyone asks "does this work?" rather than "does this match the order?"
  5. Runs "Promote to prod" - a human signature, every time
  6. Required reviewer approves - ✗ refused if no approval
  7. Fast-forward-only to sit's exact tip - ✗ refused on any other sha
  8. Green check re-verified on that sha - ✗ refused if failed, still running, or nothing reported
  9. Prod branch moves - explicit re-trigger, EC2 rebuilt, health-checked, live
The invariant. Production can only ever receive a commit SIT already ran, byte-for-byte. There's no path from a laptop, a hotfix branch, or an urgent one-off straight into production - the fast-forward-only rule refuses anything that isn't exactly SIT's current tip, on every promotion, with no override.

What actually guards the SIT-to-production boundary

The 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.

Said plainly: this pipeline has actually done it. The automatic SIT hop and the human-gated promotion have both fired for real, more than once, not just been built and left unexercised. There's still no rollback. The same rule that stops history from being rewritten also blocks going back to a previous release - a bad release is fixed by rolling a new commit forward through the same path, by decision, not by oversight.

Packaging and containers

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.

Development

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

Production

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.

How a code change actually reaches production

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.

git push main, sit, or prod 5 gate jobs secrets · tests · lint bandit · pip-audit any fails → stops here all 5 pass, ref == prod deploy job if: ref == refs/heads/prod SSH, key-based EC2 host git fetch, reset --hard docker compose rebuild web only db, nginx untouched
The five gate jobs run on every push to main, sit, and prod - but the deploy job itself only fires when the ref is prod, and reaching prod only happens through the fast-forward-only promotion script.

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.

How database changes actually roll out

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.

Stored procedures, set up on day one

ProcedureUsed byWhat it's for
InsertTeams / DeleteTeamsByYearSeason setupLoads the 68-team bracket field for the year
InsertPlayers / DeletePlayersByYear / DeletePlayersOnTeamByYearSeason setup, admin toolsLoads team rosters, or reloads one team at a time
getAllPlayersSeason setupReads back the full player pool for the active year
InsertPlayer_PtsLive scoring engineRecords a player's or team's points for a completed game
DeletePlayer_pts / DeleteTeamPlayer_ptsLive scoring engineClears a game's prior results right before rewriting them, so a rescore never leaves stale numbers on screen
ExistsPlayer_ptsLive scoring engineChecks whether a game's results are already recorded, so it isn't scored twice
DeletePicksAdmin toolsClears 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.