Deploy

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

From a green commit to a released one

08

Automatic promotion to SIT

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.

Automaticdeploy-sit job
09

Business testing

A real person, a real browser. The first time anyone asks "does this work?" rather than "does this match the order?"

Principal/accept
10

Promote to production

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.

Principalpromotion workflow

The whole path, end to end

What has to happen, and what's refused along the way

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.

  1. Push to main

  2. 5 gates — secrets · tests · lint · bandit · pip-audit

    Automatic · all green, no one asked

  3. sit — rebuilt, health-checked

    Automatic · deploy-sit job

  4. Business testing — /accept

    Principal · the first "does it work?"

  5. Runs "Promote to prod"

    Principal · 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

Any commit reaching prod without passing through SIT first: cannot happen.

The invariant

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

Three safeguards, each closing a different gap

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

What this pipeline has actually done

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

Packaging and containers

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.

Development configuration

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

Production configuration

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

How a code change reaches production

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 mainsitprod 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

Automated checks before every deploy

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.

Secret scanning

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.

Automated tests

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.

Lint, security-lint, dependency-audit

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 lint
  • bandit — static security anti-patterns
  • pip-audit — known CVEs in the dependencies actually shipped to production

Full detail on what each one catches, and the codebase-wide sweep beyond this baseline, is on Docs — Security Measures §7.

Under the hood

How database changes are rolled out

There is no dedicated migration tool in place. Database changes reach production through two different paths, depending on how old the change is.

Set once, at day one

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.

Applied on every restart since

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.

Reusable database logic (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. 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.