# Runbook: Deploys (CI-gated)
The CI-gated deploy pipeline gate by gate, its one-time setup, secrets and how to read a failed run.
- Page:
- Updated: 2026-10-13
- Verified against: `ce6ed1b` on 2026-09-26
- From the Sri Guru Granth Sahib Ji — Knowledge Base wiki. Sri Guru Granth Sahib Ji is quoted verbatim, cited by Ang, and never altered.
---
Production deploys are performed by **`.github/workflows/deploy-production.yml`**, on
every push to `main` (a merged release PR) or manually via *Run workflow*. Nothing
reaches users unless every required check on that exact commit is green.
```mermaid
flowchart TB
accTitle: The deploy-production job chain
accDescr: Gates and preflight, then the optional approval; the Render API deploy and its verification by commit; the unaliased web deploy with its API functions, the golden contract and the smoke; promotion with the public smoke, or a rollback to the deployment that served the domain; then the release tag.
G[gates: every required check on the SHA] --> A[approve, optional]
P[preflight: every secret present] --> A
A --> DA[deploy-api: Render hook, ref = SHA]
DA --> VA[verify-api: commit, health, superset]
VA --> DW[deploy-web: vercel build, unaliased]
DW --> VW[verify-web: functions, contract, smoke]
VW --> PR[promote, then smoke the domain]
PR --> R[release: tag vX.Y.Z]
PR -. promote or smoke fails .-> RB[vercel rollback + an incident issue]
```
## What each gate proves
| Job | Proves | On failure |
|---|---|---|
| gates | every required check (from `.github/rulesets/main.json`) + `playwright` succeeded on this SHA | nothing deployed |
| preflight | all production secrets exist | nothing deployed |
| approve | optional human click (add reviewers to env `production-approval`) | nothing deployed |
| deploy-api | Render runs **this commit** (`/api/health.commit == SHA`) and is healthy — production's rollback target while `/api` is on the functions | web untouched; old API keeps serving until Render switches |
| verify-api | 100 sampled compositions serve exact lines, headings present, gaps 404 | incident issue; **roll back Render** (rollback.md) |
| deploy-web / verify-web | **before** it is live: every API function is ready at this commit with exactly its contexts and the API's files are not downloadable (`verify_functions.py --env production`), the whole golden contract holds through its `/api` (`contract_http.py`), and the heading smoke passes | not promoted — users unaffected |
| promote | public domain serves this commit and passes smoke | automatic `vercel rollback` to the deployment that served `gurbanisoul.com` when deploy-web started (rollback.md) + incident issue |
| release | tag + GitHub Release are created only after a verified deploy | re-run the job |
## One-time setup (owner)
1. GitHub → Settings → Environments → **`production`**: Deployment branches = `main` only. Secrets:
- `VERCEL_TOKEN` (vercel.com/account/tokens, scope *skalaliyas-projects*)
- `VERCEL_ORG_ID` = `team_zdvzZQr6joigVUzLFhQbxXfI`
- `VERCEL_PROJECT_ID` = `prj_ojl2Klc89syjmgUcqfra3ahvOZnC`
- `VERCEL_AUTOMATION_BYPASS_SECRET` (Vercel → project → Settings → Deployment Protection → Protection Bypass for Automation)
- `RENDER_DEPLOY_HOOK_PROD` (Render → service → Settings → Deploy Hook)
2. Optional: Environments → **`production-approval`** → Required reviewers = you.
3. The Render and Vercel **GitHub Apps must be installed on the `Algorythmos-AI` org** (Render clones the repo to build; `ref=` must exist there).
## The wiki (docs.gurbanisoul.com) — one-time setup (owner)
The wiki (`docs-site/`, ADR-0012) deploys through `deploy-docs.yml` with the same posture: the
Vercel project's git deployments are off; CI builds, deploys, smokes by commit, promotes, rolls back.
1. **Vercel** — create the project with **no Git connection** (so the platform can never deploy
it by itself): `vercel project add sggs-docs --scope skalaliyas-projects`, then Settings → Build
and Deployment → Framework Preset **Astro**. Leave **Root Directory empty** (`.`): the workflow
runs `vercel pull/build/deploy` from inside `docs-site/`, so a Root Directory of `docs-site`
would resolve to `docs-site/docs-site` and fail. (The web project differs: its Root Directory
is `frontend` and its workflow runs from the repository root.)
2. Domains: add **`docs.gurbanisoul.com`** (Production). Cloudflare (zone *Company-Domains*):
`CNAME docs →` the target Vercel recommends for the domain (Domains → *View DNS configuration*;
`cname.vercel-dns.com` also works), **DNS-only** (grey cloud), like the apex and `www`.
3. The staging alias `sggs-docs-staging.vercel.app` is claimed by the first staging run
(`vercel alias set` in `deploy-docs.yml`), and re-pointed on every push to `integration`.
4. GitHub → Settings → Environments → **`staging`** and **`production`**: reuse `VERCEL_TOKEN` and
`VERCEL_ORG_ID`; add **`VERCEL_DOCS_PROJECT_ID`** (the new project's id) and
**`VERCEL_DOCS_BYPASS_SECRET`** (Deployment Protection → Protection Bypass for Automation; it is
per project, so the web project's secret does not work here — Vercel Authentication protects
every URL except the custom domain, so the staging alias and the unaliased production smoke
both need it). Never paste a value anywhere else.
5. Until the domain resolves, `deploy-production`'s public smoke fails after promotion and the job
rolls back; the staging job proves the build in the meantime.
## Staging (review integration before production)
Every push to `integration` runs **`deploy-staging.yml`**: gates (core checks on the SHA) →
staging web deploy with the API inside it → alias to `sggs-staging.vercel.app` → verify. No promote,
no release. Open `sggs-staging.vercel.app` logged in to Vercel (it is SSO-protected).
The API runs as Python functions in the web project (ADR-0011): `tools/build_api_functions.py`
generates one function per bounded context plus `all` from the pinned database (each context's slice
is proven by the slicer), `vercel build` bundles them, and `--verify-output` refuses the build if a
function bundles anything but its entry, the API code and its own database, or if a database or API
source would be published as a static file. After the alias, `scripts/ci/verify_functions.py` checks
every function's `/readyz` (this commit, exactly its contexts, `X-Service`), then the gateway probes,
the web smoke and the whole golden contract run through the staging site.
**One-time staging setup (owner):**
1. **Vercel** — the alias `sggs-staging.vercel.app` already exists (claimed via
`vercel alias set sggs-staging.vercel.app`); CI re-points it each deploy. Nothing to do
unless you want a nicer domain.
2. **GitHub** → Settings → Environments → **`staging`** (deployment branch `integration`). Secrets:
`VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`, `VERCEL_AUTOMATION_BYPASS_SECRET` (same values
as `production`). The preflight job fails clearly if any are missing.
## Preview checks (optional)
`deploy-verify.yml` health-checks **preview** deployments. Vercel Git previews of the web are off
(`frontend/vercel.json` → `git.deploymentEnabled: false`: a Git preview carries no API functions,
so its `/api` would point at nothing — review web changes on staging), so it now fires for the
CI-made preview deployments (staging, the wiki). Previews are behind
Vercel Deployment Protection, so it needs a **repo-level** secret (not the `production`
environment one):
```bash
gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --repo Algorythmos-AI/sggs-platform
```
Without it the check posts a notice and passes. It is a light smoke, not a gate: staging and the
wiki are proven by their own deploy workflows.
## Cutover (do in this order)
1. Merge the pipeline to `integration`, then the release PR to `main` (**merge commit**).
During this first run the platforms' own git deploys may also fire for the same SHA — harmless.
2. After one fully green `deploy-production` run: Render service → **Auto-Deploy: Off**, and
merge the follow-up PR that sets `frontend/vercel.json` → `git.deploymentEnabled` `main`/`integration` to `false`.
3. Prove idempotency: *Run workflow* on `main` → the same SHA redeploys and passes.
4. Prove the stop: *Run workflow* with `expect_commit=0000000` → it fails in **deploy-api**; no web deploy happens.
## Manual redeploy / hotfix
- Redeploy current `main`: Actions → deploy-production → *Run workflow* (branch `main`).
- Hotfix: `hotfix/*` → PR to `main` (squash) → the pipeline deploys it → merge `main` back into `integration`.
## Launch-day App Store switch
The site shows "Coming soon" until the Vercel project env `PUBLIC_APP_STORE_LIVE` is `1`
(`frontend/src/site.ts` → `APP_STORE_LIVE`; the website README, "The App Store switch"). Launch day
is therefore **not** a release — the version stays the one the App Store build carries:
1. Apple approves the version; the owner presses **Release** in App Store Connect.
2. Wait until `curl -s -o /dev/null -w '%{http_code}' https://apps.apple.com/app/id6812982384` prints
`200` (it can take hours). Never flip before: the site would link to a missing page.
3. Vercel → project `sggs-knowledge-base` → Settings → Environment Variables → add
`PUBLIC_APP_STORE_LIVE` = `1` for **Production** only.
4. `gh workflow run deploy-production --ref main --repo Algorythmos-AI/sggs-platform` — the same SHA
redeploys through every gate (see *Manual redeploy*); `deploy-web` pulls the new env, the
`@smoke` spec accepts the live state, and the tag step exits early ("already tagged").
5. Prove it: `SGGS_EXPECT_APP_STORE=live PLAYWRIGHT_BASE_URL=https://gurbanisoul.com npx playwright
test e2e/landing.spec.ts --grep @smoke --project=desktop` (from `frontend/`), and
`/api/meta` still reports the release version.
Rehearse on staging first: the same variable on **Preview** + `gh workflow run deploy-staging --ref
integration`, then remove it and re-run. To take the site back to "Coming soon", delete the
variable and redeploy the same way.
If a release is about to ship anyway, step 4 can be that release's own deploy: set the variable
(steps 2–3 unchanged), let the release run, then do step 5. A green pipeline does not prove the
switch — the `@smoke` spec accepts either state unless `SGGS_EXPECT_APP_STORE` is set — so step 5
is never skipped. When watching a same-commit redeploy, follow the new run by its id
(`gh run list --workflow deploy-production.yml --event workflow_dispatch --limit 1`):
`scripts/release/watch_deploy.sh` selects by commit and can report an older run.
## Concurrency
Runs share the `production` group without cancellation: a run in progress always finishes.
If several pushes queue, GitHub keeps only the newest pending run (older pending runs are replaced).
## If `/api/health.commit` stays `unknown`
The image bakes the commit at build time (`webapp/Dockerfile`: `ARG RENDER_GIT_COMMIT` →
`ENV SGGS_COMMIT`), and serve.py also reads `RENDER_GIT_COMMIT` at runtime. If both are empty,
deploy-api times out with a hint. Check the Render build log for the build arg, then set a
service environment variable `SGGS_COMMIT` manually for that one deploy and re-run the job.
## Staging services
Staging answers each bounded context with its own Vercel function (ADR-0011); which contexts are
split per environment is `gateway/routes.json`, and `frontend/vercel.json` is generated from it
(`python3 tools/gen_gateway.py`; a test fails on drift). To send a context back to `all`, remove it
from `staging.services`, regenerate, and merge. Production answers `/api` with `all` from the
release after 1.3.9; the Render single API keeps deploying as its rollback target until it is
retired (`docs/process/runbooks/services-production.md`, step 4). Staging no longer uses Render: the
six free `sggs-api-staging` / `sggs-staging-*` services are unused and can be deleted in the Render
dashboard (ADR-0011, Consequences).