Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ jobs:
- name: Verify project
run: npm run verify

# Pre-merge guard: simulate the Netlify Build pipeline so we catch
# Netlify-Build-specific failures (image incompatibilities, functions
# bundling, plugin issues) on PRs instead of in production. This is
# independent of where the actual deploy runs.
netlify-build-check:
name: Netlify build (pre-merge guard)
needs: verify
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm

- name: Install dependencies
run: npm ci

- name: Run netlify build (offline)
run: npx -y netlify-cli@latest build --offline

# secrets cannot appear in job-level `if`. Expose a boolean via env + step output.
check-production-migrate:
name: Check production database secret
Expand Down Expand Up @@ -105,3 +129,102 @@ jobs:
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
run: npm run db:seed

# Mirror of `check-production-migrate`: secrets cannot be referenced in
# job-level `if`, so we gate `deploy-production` on a boolean derived
# here from the presence of NETLIFY_AUTH_TOKEN + NETLIFY_SITE_ID.
check-production-deploy:
name: Check production deploy secrets
needs: verify
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
run_deploy: ${{ steps.gate.outputs.run_deploy }}
steps:
- id: gate
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
if [ -n "${NETLIFY_AUTH_TOKEN}" ] && [ -n "${NETLIFY_SITE_ID}" ]; then
echo "run_deploy=true" >> "$GITHUB_OUTPUT"
else
echo "run_deploy=false" >> "$GITHUB_OUTPUT"
fi

# Production deploy: build with pinned Node, ship to Netlify directly,
# then verify the live endpoint. Replaces Netlify's git auto-deploy as
# the source of truth so noble-image / Prisma-binary regressions in
# Netlify Build no longer block production releases.
deploy-production:
name: Deploy to Netlify production
needs: [verify, netlify-build-check, migrate-production, check-production-deploy]
# `migrate-production` is conditional. `always()` + status checks let
# the deploy run when migrate was skipped (no production DB secret)
# but block it when migrate failed.
if: >-
always() &&
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
needs.verify.result == 'success' &&
needs.netlify-build-check.result == 'success' &&
needs.check-production-deploy.outputs.run_deploy == 'true' &&
(needs.migrate-production.result == 'success' || needs.migrate-production.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: production-deploy-main
cancel-in-progress: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npm run db:generate

- name: Build site
run: npm run build

- name: Deploy to Netlify (prod)
# --skip-functions-cache forces a fresh function bundle so the
# deployed Prisma client always matches the current schema. The
# earlier deploy-race incident happened because Netlify reused a
# cached function bundle generated under a stale schema.
run: |
npx -y netlify-cli@latest deploy \
--prod \
--dir dist \
--functions netlify/functions \
--skip-functions-cache \
--message "ci: ${GITHUB_SHA::7} (${GITHUB_REF_NAME})"

- name: Verify live API
# Smoke-check: hit a public endpoint to make sure the new Lambda
# bundle actually serves traffic. Catches the class of failure
# where deploy reports success but the function 500s on cold
# start (e.g. Prisma client + DB column mismatch).
run: |
set -e
for attempt in 1 2 3 4 5; do
code=$(curl -s -o /tmp/body.json -w '%{http_code}' \
"https://prode.grupoeglu.net/api/tournaments?status=active,upcoming")
echo "attempt $attempt: HTTP $code"
if [ "$code" = "200" ]; then exit 0; fi
sleep 5
done
echo 'Live verification failed. Last body:'
head -c 500 /tmp/body.json
exit 1
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,36 @@ migration first (column added as nullable or with a default), wait
for the migrate job to finish, and only then ship the code that
treats the column as required.

### Production Deploy Pipeline

Production deploys are owned by GitHub Actions (`deploy-production`
job in `.github/workflows/ci.yml`), not Netlify's git auto-deploy.

Rules:

- never deploy by pushing a release branch through Netlify's UI
- the only supported deploy path on `main` is the GitHub Actions
workflow; if it is skipped (missing secrets, gated false), do not
shadow it with a manual `netlify deploy` from a workstation
- the deploy step uses `--skip-functions-cache` so the bundled
Prisma client always matches the current schema; do not remove
that flag
- the deploy step ends with a live-API smoke check; if it fails,
the workflow fails red — investigate before re-running
- if you need to roll back, prefer `netlify api restoreSiteDeploy`
via the Netlify CLI against a known-good deploy id rather than
pushing a revert commit, which would re-run the migrate job too

Required GitHub secrets:

- `PRODUCTION_DATABASE_URL` (existing, gates migrate)
- `NETLIFY_AUTH_TOKEN` (gates deploy)
- `NETLIFY_SITE_ID` (gates deploy)

If `NETLIFY_AUTH_TOKEN` or `NETLIFY_SITE_ID` is missing, the deploy
job is skipped cleanly; the workflow stays green, and Netlify's own
git auto-deploy (still enabled as a fallback) takes over.

## 4. API And Data Safety

When changing API behavior:
Expand Down
54 changes: 43 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,19 +741,51 @@ Current automation contract:

This keeps the migration path explicit and checked-in while allowing repositories without configured production secrets to keep using the same CI workflow safely.

### 15.4 Production Deploy Pipeline

Production code is shipped to Netlify from GitHub Actions, not via Netlify's git auto-deploy.

Why: Netlify's `noble` build image rollout (June 2026) broke our
auto-deploy for ~7 weeks. We had no logs and no signal, so the live
Lambda silently stayed on the April 28 build while migrations kept
running on `main`. Owning the deploy from GitHub Actions gives us
pinned Node, full build logs, and a smoke check.

Pipeline contract (`.github/workflows/ci.yml`):

| Job | Trigger | Purpose |
|---|---|---|
| `verify` | every push + PR | lint, validate, tests, build |
| `netlify-build-check` | every push + PR (after `verify`) | runs `netlify build --offline` to catch Netlify-Build-specific failures on the PR, not in prod |
| `check-production-migrate` | push to `main` | gates the migrate job on `PRODUCTION_DATABASE_URL` |
| `migrate-production` | push to `main` (when gated true) | `npm run db:migrate:deploy` + `npm run db:seed` |
| `check-production-deploy` | push to `main` | gates the deploy job on `NETLIFY_AUTH_TOKEN` + `NETLIFY_SITE_ID` |
| `deploy-production` | push to `main` (when gated true) | builds, generates Prisma client, `netlify deploy --prod --skip-functions-cache`, then smoke-checks the live API |

Required GitHub secrets:

- `PRODUCTION_DATABASE_URL` — gates the migrate path
- `NETLIFY_AUTH_TOKEN` — personal access token with deploy scope
- `NETLIFY_SITE_ID` — Netlify project id

Notes:

- `--skip-functions-cache` is mandatory: the older "Deploying functions from cache" path can publish a function bundle whose Prisma client predates the current schema.
- The deploy job runs a 5-attempt curl smoke check against `/api/tournaments?status=active,upcoming` and fails the CI run if the live API does not 200.
- Netlify's git auto-deploy remains enabled as a fallback. With `[build.environment] NODE_VERSION = "24.1.0"` pinned in `netlify.toml`, future image rollouts have one fewer variable; but the GitHub Actions path is the source of truth.

### Destructive Migrations And The Deploy Race

CI's `migrate-production` job and Netlify's build are independent
triggers on the same `main` push event. There is no ordering
guarantee between "schema is migrated" and "new Lambda bundle is
serving traffic." For backward-compatible changes (`ADD COLUMN`
nullable, new tables) the race is harmless. For destructive
changes it is not: an incompatible drop can land in the DB while
the live Lambda still selects the column, returning 500s until
Netlify catches up.

Use a two-step pattern for destructive changes (see §3 of
`AGENTS.md`):
CI's `migrate-production` job and the deploy step are sequenced in the
same workflow (`deploy-production` `needs:` `migrate-production`), so
the deploy waits for the migration to finish before publishing the new
Lambda. That removes the historical race between Netlify's
out-of-band build and the migrate job.

A residual race still exists when Netlify's git auto-deploy is left
on as a fallback (it runs in parallel with our pipeline). Until we
fully disable it, keep using the two-step pattern for destructive
changes (see §3 of `AGENTS.md`):

1. ship code that no longer references the column, merge, and
confirm the new Lambda is live
Expand Down
10 changes: 10 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
publish = "dist"
functions = "netlify/functions"

# Pin runtime versions for the (now-secondary) Netlify-side build path.
# The primary deploy path is the GitHub Actions `deploy-production` job in
# `.github/workflows/ci.yml`, which runs `netlify deploy --prod` from a
# pinned Node 24.1.0 runner. Keeping these here is defence-in-depth so a
# stray Netlify auto-build does not silently regress on a newer image
# (the June 2026 `noble` rollout took our auto-deploys offline for 7 weeks
# before this pipeline existed).
[build.environment]
NODE_VERSION = "24.1.0"

[[redirects]]
from = "/api/*"
# Function must not be named "api": it collides with the /api source directory
Expand Down
Loading