From 73020997ae96c9b301d530f25f6c4eaac9966fd6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 15:12:28 +0000 Subject: [PATCH] Keep automatic Vercel deploys on main only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent branch pushes were building a preview on both Vercel projects and hit the Hobby plan's 100 deployments a day. git.deploymentEnabled now deploys main and skips every other branch before a deployment exists, so a skipped push does not count. A preview is an explicit CLI deploy. Co-authored-by: Gáborjáni-Szabó Ádám --- AGENTS.md | 11 ++++++++- README.md | 2 +- docs/architecture.md | 2 +- docs/development.md | 2 +- docs/operations.md | 28 +++++++++++++++++++---- docs/staging.md | 2 +- docs/standards/security.md | 2 +- src/test/vercel-deployment-config.test.ts | 15 ++++++++++++ vercel.json | 6 +++++ 9 files changed, 60 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 399e0088d..a667d78d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,21 @@ The local Supabase stack still uses shared default ports and container names. In - `supabase/migrations/` is the database history and schema source of truth. - `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates. - Collaborative Workspace is hidden. Its routes render only when `VITE_COLLABORATION_URL` is set, and production does not set it. Do not extend it unless Adam asks. -- `.github/workflows/` owns CI behavior. `vercel.json` owns production hosting: build output, SPA fallback, cache policy, and security headers. +- `.github/workflows/` owns CI behavior. `vercel.json` owns production hosting: build output, SPA fallback, cache policy, security headers, and which Git branches deploy. ## Deployment The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed to production by that merge; production deploys are separate authorized operations. Staging syncs itself from `main` within about 5 minutes through a VPS timer; [docs/staging.md](docs/staging.md) is the one place for staging access, seed data, and the sync. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. +Vercel previews are opt-in. A push to `main` deploys both Vercel projects (production, and the staging project's site, which also tracks `main`). A push to any other branch does not create a deployment and does not count toward the daily cap. Request a hosted preview only when there is a real UI change to check. From the repo root, with the Vercel CLI logged in (`vercel login`, or `VERCEL_TOKEN` in the environment; never in the repo): + +```bash +npx vercel link --yes --non-interactive --scope adam-gaborjanis-projects --project debatelab-staging +npx vercel deploy --yes --non-interactive --scope adam-gaborjanis-projects +``` + +That preview is pinned to staging. Do not pass `--prod`, and do not run `vercel --yes` before the link: the first deploy of a new project is production. For a preview that uses production data, link `debate-navigator` instead. One preview is enough. Adam can create the same preview from the Vercel dashboard (Create Deployment, pick the branch, leave the target as Preview) without changing project settings. [docs/operations.md](docs/operations.md#opt-in-previews) has the detail. + ## Normal ports - Vite development server: `8080` by default outside Superset; Superset allocates `8080-8179` per worktree. diff --git a/README.md b/README.md index 17a9797f2..45bfbd2a7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Browser or PWA -> OpenRouter models, research providers, Stripe, Google APIs ``` -- The frontend is hosted on **Vercel**. [`vercel.json`](vercel.json) owns the build, app-route rewrites, cache policy, and security headers. Merging to `main` deploys it to production. +- The frontend is hosted on **Vercel**. [`vercel.json`](vercel.json) owns the build, app-route rewrites, cache policy, and security headers. Merging to `main` deploys it to production. Pushes to other branches do not. - **Supabase** holds auth, data, and Edge Functions. Migrations and function deploys are separate operations that need explicit authorization. - The browser never holds provider secrets. AI requests go to the `ai` and `ai-stream` Edge Functions, which check auth, quotas, and controls, load debate sources by ID, call the model through OpenRouter, validate the output, and record usage. diff --git a/docs/architecture.md b/docs/architecture.md index c823f7885..e9f3fab4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -160,6 +160,6 @@ The service worker and manifest provide installable web assets and an offline ap ## Deployment boundary -Vercel builds `dist/` with Node 22 and serves it. [`vercel.json`](../vercel.json) owns the app-route rewrites, the 404 for everything else, cache policy, crawler restrictions, and response security headers. Merging to `main` deploys the frontend to production; Supabase migrations and Edge Functions deploy separately. GitHub workflow files own quality, live-provider, staging, and canary automation. +Vercel builds `dist/` with Node 22 and serves it. [`vercel.json`](../vercel.json) owns the app-route rewrites, the 404 for everything else, cache policy, crawler restrictions, response security headers, and which Git branches deploy. A push to `main` deploys production; any other branch does not until someone requests a preview ([operations.md](operations.md#opt-in-previews)). Supabase migrations and Edge Functions deploy separately. GitHub workflow files own quality, live-provider, staging, and canary automation. Read [operations.md](operations.md) before changing deployment behavior and [testing.md](testing.md) before claiming a boundary works. diff --git a/docs/development.md b/docs/development.md index 13fd072b5..655da5f5e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,7 +53,7 @@ Automated browser tests always start a fresh server. `npm run verify` puts it on ## Preview on the tailnet -A plain `npm run dev` talks to the production Supabase project unless the browser toggle says staging. For anything Adam opens from another device, run the staging preview instead: +A plain `npm run dev` talks to the production Supabase project unless the browser toggle says staging. For anything Adam opens from another device, run the staging preview instead. This local server is not a Vercel deployment. Hosted Vercel previews are opt-in; [operations.md](operations.md#opt-in-previews) has the command. ```bash npm run dev:preview -- --port 9201 --strictPort diff --git a/docs/operations.md b/docs/operations.md index 9e33f997c..85cfa633f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -36,10 +36,11 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou ## Frontend hosting on Vercel -Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests get two preview deployments (see [Per-PR previews](#per-pr-previews)). For a staging-pinned preview on the tailnet, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment. +Vercel builds and serves the frontend from the Git repository. A push to `main` deploys both projects: `debate-navigator` to `https://debate.pi-stack.org`, and `debatelab-staging` to `https://debatelab-staging.vercel.app`. Any other branch waits for an explicit preview (see [Opt-in previews](#opt-in-previews)). For a staging-pinned preview on the tailnet, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment of `debate-navigator`. [`vercel.json`](../vercel.json) owns: +- automatic Git deployments: `main` only. `git.deploymentEnabled` sets `main` to true and `**` to false. A branch deploys when any matching rule is true, and a branch that matches nothing defaults to true, so the `**` rule has to stay. `*` does not match a slash, which would leave branches such as `cursor/…` deploying. A push that matches no true rule creates no deployment and does not count toward the 100-deployments-per-day cap; - the Vite build command and `dist` output; - rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status; - `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly; @@ -99,13 +100,30 @@ Before a production backend deploy, check that the commit's `staging-sync` statu Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. -A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so each PR gets a staging preview; see [Per-PR previews](#per-pr-previews). +A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. -## Per-PR previews +## Opt-in previews -Each pull request gets two Vercel previews, one per Supabase project, and both rebuild on every push: +A push to a branch other than `main` does not deploy. Both Vercel projects read this `vercel.json`, so neither builds a preview on its own. There is no long-lived staging git branch: `debatelab-staging`'s production deployment tracks `main` and stays at `https://debatelab-staging.vercel.app`. + +Request a preview only when a real UI change needs a hosted URL. `npm run dev:preview` and the `verify-debatelab` skill do not spend a Vercel deployment. + +From the repo root, with the CLI logged in (`vercel login`, or `VERCEL_TOKEN` in the environment; never commit a token): + +```bash +npx vercel link --yes --non-interactive --scope adam-gaborjanis-projects --project debatelab-staging +npx vercel deploy --yes --non-interactive --scope adam-gaborjanis-projects +``` + +Leave off `--prod`. Do not run `vercel --yes` before the link: the first deployment of a new project is production. The command above is one preview on `debatelab-staging`, pinned to staging by that project's `VITE_SUPABASE_ENV`. Linking `debate-navigator` instead builds a preview with no `VITE_SUPABASE_ENV`, so it uses production data unless the saved toggle says staging. One preview is enough, and the staging project is the one to use unless the change needs production data. + +Adam can do the same from the Vercel dashboard: Create Deployment, pick the branch, leave the target as Preview. That needs no project setting change. + +A commit message cannot turn a deployment back on. `git.deploymentEnabled` is decided before a build exists, which is what keeps the skipped push off the daily cap. An Ignored Build Step (`ignoreCommand`) runs only after a deployment is created, and that skip still counts, so this repo does not use one. + +The preview hosts are unchanged: - `debate-navigator` (the production Vercel project) builds previews with no `VITE_SUPABASE_ENV`, so they use production unless the saved toggle says staging. Production's Auth redirect allowlist must include `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` for Google sign-in to return to the preview. - `debatelab-staging` builds the same repository with `VITE_SUPABASE_ENV=staging`. Staging's Auth redirect allowlist must include `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**`. Its production deployment tracks `main`. @@ -114,6 +132,8 @@ Edge Function CORS and the demo-code sign-in redirect accept these hosts through The staging project's name must not start with `debate-navigator-`, or production's allowlist pattern would also match it. A production preview runs unmerged frontend code with real production sessions and data, so use the staging preview unless the change needs production data. A staging preview runs against whatever backend staging has; if the PR changes migrations or Edge Functions, sync staging first (see [Staging sync](#staging-sync)). +A branch that does not contain this `vercel.json` still deploys on push. After the change is on `main`, branches cut from `main` inherit it. + ## Backend drift check `npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN`, or the `access-token` file in `SUPABASE_HOME`, default `~/.supabase`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role. diff --git a/docs/staging.md b/docs/staging.md index adae12056..eed7535cb 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -6,7 +6,7 @@ Staging is the Supabase project `debate-trainer-staging` (`brifeyypjauyusmkptkx` ## Get in -**On your phone or laptop, for a PR.** Every PR gets a `debatelab-staging` Vercel preview; its link is in the Vercel comment on the PR. That build is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. +**On your phone or laptop, for a PR.** A pull request does not get a Vercel preview unless someone requests one ([operations.md](operations.md#opt-in-previews)). The preview to open is `debatelab-staging`: it is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. **Sign in.** Two ways: diff --git a/docs/standards/security.md b/docs/standards/security.md index d9ad20a18..cdba2878a 100644 --- a/docs/standards/security.md +++ b/docs/standards/security.md @@ -24,7 +24,7 @@ These were raised in review, judged valid, and accepted on purpose. Changing the - Live provider keys reach same-repository PR jobs in `live-e2e.yml`. Adam accepted this on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563258)). - The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)). - Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)). -- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#per-pr-previews) describes the current setup. This one has no recorded decision yet. +- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#opt-in-previews) describes the current setup. This one has no recorded decision yet. - `import-source` checks every resolved A and AAAA address against the IANA special-purpose ranges before each fetch, but the Edge runtime cannot pin the connection to the checked address. A hostname whose DNS answer changes between the check and the connection can still reach a blocked range. Citation verification avoids the same gap by not fetching at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closing it needs an egress proxy outside the runtime. Raised as finding [7] of the 2026-08-19 security review; this needs Adam's decision. ## Telemetry and error reports diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 7f8c39765..3880212b2 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -9,6 +9,9 @@ type HeaderRule = { }; type VercelConfig = { + git?: { + deploymentEnabled?: boolean | Record; + }; trailingSlash?: boolean; redirects?: Array<{ source: string; destination: string; statusCode?: number }>; rewrites?: Array<{ source: string; destination: string }>; @@ -59,6 +62,18 @@ const cspDirective = (csp: string, name: string) => csp.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name} `)) ?? ""; describe("Vercel frontend deployment configuration", () => { + it("deploys main automatically and does not deploy any other branch", () => { + // Unspecified branches default to true, and a branch deploys when any + // matching rule is true. `*` does not match a slash, so `cursor/…` needs `**`. + // https://vercel.com/docs/project-configuration/git-configuration + expect(loadConfig().git).toEqual({ + deploymentEnabled: { + main: true, + "**": false, + }, + }); + }); + it("serves the SPA shell for app routes only, so unknown paths get the real 404", () => { const config = loadConfig(); diff --git a/vercel.json b/vercel.json index 80800c2de..4adfb3c7f 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,11 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "git": { + "deploymentEnabled": { + "main": true, + "**": false + } + }, "framework": "vite", "buildCommand": "npm run build", "outputDirectory": "dist",