Part four
A verdict says a build matches its spec. It doesn't say 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 human in it.
SIT moves on its own, the instant the gates are green. Nobody chooses that hop; the checks do.
Production moves only when the Principal decides. He runs the promotion, a required reviewer approves it, and only then does a commit that has already been tested in SIT — byte-for-byte, never a rebuild — reach a real server.
Three steps
Refused unless every automated check on that exact commit is green — enforced by needs: [all five gate jobs] on the deploy-sit job, not by a person choosing to run something. SIT is meant to churn; nothing about this step asks anyone's permission.
A real person, a real browser. The first time anyone asks "does this work?" rather than "does this match the order?"
Only the exact commit SIT already tested, never a new build. A required reviewer approves the specific release before anything moves, and running the workflow is the signature.
The whole path, end to end
Every arrow below is a real step in .github/workflows/deploy.yml and promote-to-prod.yml — not a simplification. The ╳ marks are the paths that exist specifically to be refused.
Push to main
5 gates — secrets · tests · lint · bandit · pip-audit
Automatic · all green, no one asked
sit — rebuilt, health-checked
Automatic · deploy-sit job
Business testing — /accept
Principal · the first "does it work?"
Runs "Promote to prod"
Principal · human signature, every time
Required reviewer approves
Fast-forward-only to sit's exact tip
Green check re-verified on that sha
Prod branch moves — explicit re-trigger
EC2 rebuilt, health-checked, live
Any commit reaching prod without passing through SIT first: cannot happen.
Production can only ever receive a commit SIT already ran, byte-for-byte. There is 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 single promotion, with no override.
What actually guards this step
The full eleven-safeguard reference table lives on Build. These three fire specifically at the SIT-to-production 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 with three separate messages: 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 on the production environment — a real click, not a formality. It is honestly also a limit: it is 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. It refuses any other sha, which means nothing can reach production without first having passed through the test environment as that exact commit — no shortcut, no "just this once" build that skips SIT.
Said plainly
This pipeline has moved real commits into production — the automatic SIT hop and the human-gated promotion have both fired for real, more than once, not just been built and left unexercised. Every check named above ran on those releases, not a rehearsal.
There is still no rollback. The same rule that stops history being rewritten also blocks going back to a previous release. Roll-forward-only through SIT remains the accepted posture, by decision, not by oversight — a bad release is fixed by rolling a new commit forward through the same path, not by reverting.
Under the hood
The application is split into three self-contained building blocks, called containers, all built and run with Docker: the website itself, the database, and the reverse proxy. Packaging the app this way means it runs the same way on a developer's laptop as it does in production, which reduces "it worked on my machine" surprises. There's one setup for local development and a stricter, locked-down setup for production.
web → built from source :5000 open for direct testing
live code reload for developers
db → standard MySQL image :3307 open for local inspection
starter data loaded automatically on first run
nginx → reverse proxy :80 and :443 open
handles HTTPS certificates
web → production mode, served by gunicorn
4 worker processes, 120s request timeout
not directly reachable — traffic must go through the router
db → same MySQL image, auto-restarts if it crashes
only reachable by the website component, not the internet
nginx → reverse proxy, port 80 only
the single public entry point to the whole system
Two terms worth defining, since they show up throughout this page: a reverse proxy (Nginx, here) is the component that sits in front of everything else and routes incoming traffic to the right place — it's also where encryption is handled, so the actual application never deals with raw internet traffic directly. Gunicorn is the production-grade application server that runs our Python code — Flask's own built-in server is fine for a developer testing locally, but isn't designed to handle real production traffic reliably, so gunicorn takes over that job once deployed.
The build recipe for the website component is intentionally minimal: it installs only what production needs to run (the web framework, the database driver, the AWS email library, and the Claude integration), and leaves out a long tail of heavier tools — browser automation, data scraping, desktop GUI libraries — that only the project's original, now-retired scripts ever used. Keeping the production build lean reduces the app's attack surface and keeps deploys fast.
Under the hood
Merging to main no longer deploys anything by itself — it only runs the gate jobs below. A change only reaches players after it's separately promoted to prod, and only prod triggers the actual deploy.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub (main/sit/prod)
participant CI as Gate jobs
(secret-scan, test, lint,
security-lint, dependency-audit)
participant GA as GitHub Actions
deploy job
participant EC2 as EC2 host
participant DC as docker compose
Dev->>GH: git push (main, sit, or prod)
GH->>CI: on: push → branches: [main, sit, prod]
CI->>CI: run all five gate jobs
alt any gate job fails
CI--)Dev: stops here — no branch reaches deploy
else all five pass, and the push was to prod
CI->>GA: needs: [...] satisfied, if: ref == refs/heads/prod
GA->>EC2: SSH (key-based, scoped secrets)
EC2->>GH: git fetch (auth: scoped token)
EC2->>EC2: git reset --hard FETCH_HEAD
EC2->>DC: docker compose up -d --build web
DC->>DC: rebuild + restart web only
(db, nginx untouched)
end
The five gate jobs run on every push to main, sit, and prod — but the deploy job itself only runs when the ref is prod (if: github.ref == 'refs/heads/prod'), and reaching prod only happens through scripts/promote.sh, which only ever promotes from SIT's current tip. See The Development Pipeline §4 for the full main → sit → prod promotion workflow — this section owns the architecture of what a deploy does once triggered, that page owns how a commit earns its way to prod. Once triggered, deploys only ever rebuild and restart the website component; the database and reverse proxy are left running throughout, so a release never causes a database outage.
Under the hood
Five independent gate jobs now run on every single push to main, sit, or prod, and all five have to pass before a deploy is even eligible to run.
Every file changed in a push is automatically checked for the structural signatures of real credentials — private keys, cloud API keys, access tokens — before it can be deployed. This same check also runs locally on a developer's machine at the moment of committing, so a credential is caught before it's even shared, not just before it reaches production. Nothing else changes about how anyone works day to day; it only activates if a real credential pattern shows up.
A starter automated test suite now runs on every push, covering the bracket-projection engine — the logic that decides which team advances and what color a bracket slot renders, which is exactly the kind of thing a player would notice immediately if it broke. It's intentionally small today rather than exhaustive, and is meant to grow over time; the value is that this is now a real, enforced gate rather than nothing at all.
Three more gate jobs run alongside the two above — fast, free, and non-LLM, complementing rather than replacing the per-file Qcoder model review a Developer runs locally before ever pushing:
ruff — style/correctness lintbandit — static security anti-patternspip-audit — known CVEs in the dependencies actually shipped to productionFull detail on what each one catches, and the codebase-wide sweep beyond this baseline, is on Docs — Security Measures §7.
Under the hood
There is no dedicated migration tool in place. Database changes reach production through two different paths, depending on how old the change is.
The original database structure — the core tables (teams, players, scores, entries, picks, settings) and all reusable database logic — is set up automatically the very first time the database is created, and never runs again after that.
Everything added after day one — user accounts, password resets, scoring logs, and a number of individual columns — is instead built into the application's own startup code, and re-checked every time the website component restarts.
In practice, this means the application's startup routine doubles as the database change process: rolling out a schema change is done by adding a small piece of startup logic and deploying it, rather than through a dedicated migration tool. The code is written defensively so that four processes restarting at once don't step on each other. This was the deliberate, ease-of-delivery path chosen to hit a hard, immovable deadline — the tournament starts when it starts — rather than a lack of awareness: a standard migration tool is already the planned next step and is scoped as a Tournament 2027 enhancement (see Big Picture's Known Architecture Decisions), once there's runway to make that change outside of live tournament season.
| 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. Practically, that means a score can be corrected and rewritten at any time — even hourly — without a player ever seeing a half-updated or incorrect number on their screen.
6 of 6