diff --git a/docs/operations.md b/docs/operations.md index f8d17317b..ff9709db4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -101,7 +101,7 @@ No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, be ## Staging sync -Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Staging syncs itself: a timer on the VPS applies migrations and deploys changed Edge Functions to staging within about 5 minutes of every commit on `main`. It sets the `staging-sync` commit status, and on failure it opens the **Staging is out of sync with main** issue. No per-run authorization is needed for staging. [staging.md](staging.md) covers the timer, its credential, the seed, and how to get in. +Staging is project `brifeyypjauyusmkptkx` ("DebateOwl Staging"). Production is `uziwdtkzofptihahfooo` ("DebateOwl"). Scripts go by ref; the display names can change. Staging syncs itself: a timer on the VPS applies migrations and deploys changed Edge Functions to staging within about 5 minutes of every commit on `main`. It sets the `staging-sync` commit status, and on failure it opens the **Staging is out of sync with main** issue. No per-run authorization is needed for staging. [staging.md](staging.md) covers the timer, its credential, the seed, and how to get in. Before a production backend deploy, check that the commit's `staging-sync` status is green and that `Protected staging E2E` passed for it. Then follow [Supabase changes](#supabase-changes) with the list of migrations and functions that `npm run backend:status` reports for production. diff --git a/docs/staging.md b/docs/staging.md index eed7535cb..6a08894f6 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -2,7 +2,7 @@ **Current.** The one place that says how to reach staging, what is on it, and how it stays in sync with `main`. -Staging is the Supabase project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Staging has its own users, data, and secrets. Nothing on staging can read or write production. +Staging is the Supabase project `brifeyypjauyusmkptkx` (named "DebateOwl Staging" in the dashboard). Production is `uziwdtkzofptihahfooo` ("DebateOwl"). Scripts identify projects by ref only; the names are display labels and can change. Staging has its own users, data, and secrets. Nothing on staging can read or write production. ## Get in @@ -43,7 +43,7 @@ Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing A user systemd timer on Adam's VPS, `debatelab-staging-sync.timer`, runs [`ops/staging-sync/run.sh`](../ops/staging-sync/run.sh) every 5 minutes. When `origin/main` has a commit it has not synced, it: 1. checks that commit out in a dedicated clone at `~/ops/debatelab-staging-sync/repo` (it never runs `npm install`); -2. runs [`scripts/staging-sync.mjs`](../scripts/staging-sync.mjs), which refuses unless the Supabase CLI sees `brifeyypjauyusmkptkx` named `debate-trainer-staging`, passes `--project-ref brifeyypjauyusmkptkx` to every CLI call, applies pending migrations, deploys new or changed Edge Functions except the hidden `workspace-invites`, and then checks that nothing is left pending. A function counts as changed when git has changed it since its last deploy, or when the source staging runs (from `supabase functions download`) differs byte for byte from the commit. The second check catches an older checkout deployed by hand; +2. runs [`scripts/staging-sync.mjs`](../scripts/staging-sync.mjs), which refuses unless the Supabase CLI can see `brifeyypjauyusmkptkx` (by ref, so renaming the project in the dashboard doesn't break it) and `SUPABASE_PROJECT_ID`, if set, is that same ref, passes `--project-ref brifeyypjauyusmkptkx` to every CLI call, applies pending migrations, deploys new or changed Edge Functions except the hidden `workspace-invites`, and then checks that nothing is left pending. A function counts as changed when git has changed it since its last deploy, or when the source staging runs (from `supabase functions download`) differs byte for byte from the commit. The second check catches an older checkout deployed by hand; 3. reruns the seed; 4. sets the `staging-sync` commit status on that commit, and opens or comments on the GitHub issue **Staging is out of sync with main** when anything fails. A later successful sync closes the issue. @@ -57,7 +57,7 @@ PRs that change `supabase/migrations/` or `supabase/functions/` get a comment sa The timer uses the Supabase CLI login already on the VPS (`~/.supabase/access-token`). It is a classic personal access token for Adam's account, so it reaches production too. The scripts' ref checks keep the sync itself on staging, but they are not a security boundary. Anyone who can merge to `main` can change those scripts, and the timer will run the new code with that token within 5 minutes. A malicious or broken merge could read or change production, including its database and secrets. Today that is the same trust as the other agents on the VPS that use this token, and only Adam merges to `main`. -To shrink it, replace the token with a Supabase scoped token limited to `debate-trainer-staging` (Database and Edge Functions read-write) and pass it to the service as `SUPABASE_ACCESS_TOKEN`. Then a bad merge can touch staging only. +To shrink it, replace the token with a Supabase scoped token limited to the staging project `brifeyypjauyusmkptkx` (Database and Edge Functions read-write) and pass it to the service as `SUPABASE_ACCESS_TOKEN`. Then a bad merge can touch staging only. ### Operate it diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index a17ace8e1..b957259fc 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -26,7 +26,6 @@ import { join, relative } from "node:path"; import { backendStatus } from "./backend-status.mjs"; export const STAGING_REF = "brifeyypjauyusmkptkx"; -export const STAGING_NAME = "debate-trainer-staging"; export const PRODUCTION_REF = "uziwdtkzofptihahfooo"; export const GITHUB_REPO = "Programcode/DebateLab"; const STATUS_CONTEXT = "staging-sync"; @@ -78,7 +77,13 @@ function supabase(root, args) { return output; } -/** Refuses to run unless the CLI sees the staging ref under the staging name. */ +/** + * Refuses to run unless the CLI can see the staging project by its ref. The + * check is keyed on the ref, which never changes, not on the display name, + * which anyone can rename in the dashboard (staging went from + * "debate-trainer-staging" to "DebateOwl Staging"). Every CLI call passes + * STAGING_REF explicitly, so production is never the target. + */ export function assertStagingTarget(projects, env = process.env) { const override = env.SUPABASE_PROJECT_ID?.trim(); if (override && override !== STAGING_REF) { @@ -87,9 +92,7 @@ export function assertStagingTarget(projects, env = process.env) { if (STAGING_REF === PRODUCTION_REF) throw new RefusedError("Staging and production refs are the same."); const staging = projects.find((project) => (project.ref ?? project.id) === STAGING_REF); if (!staging) throw new RefusedError(`The Supabase account cannot see ${STAGING_REF}.`); - if (staging.name !== STAGING_NAME) { - throw new RefusedError(`${STAGING_REF} is named "${staging.name}", expected "${STAGING_NAME}".`); - } + return staging; } /** What the sync has to do, from a backend-status result. */ diff --git a/src/test/scripts/staging-sync.test.ts b/src/test/scripts/staging-sync.test.ts index 42ca4bcb5..e0ee28e72 100644 --- a/src/test/scripts/staging-sync.test.ts +++ b/src/test/scripts/staging-sync.test.ts @@ -5,12 +5,17 @@ import { describe, expect, it } from "vitest"; // @ts-expect-error -- plain ESM script without type declarations import { assertNoUndeclared, assertStagingTarget, contentDrift, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; -const staging = { ref: STAGING_REF, name: "debate-trainer-staging" }; -const production = { ref: PRODUCTION_REF, name: "debate-trainer" }; +const staging = { ref: STAGING_REF, name: "DebateOwl Staging" }; +const production = { ref: PRODUCTION_REF, name: "DebateOwl" }; describe("staging sync target guard", () => { - it("accepts the staging project under its own name", () => { - expect(() => assertStagingTarget([production, staging], {})).not.toThrow(); + it("accepts the staging project by its ref, whatever it is named", () => { + expect(assertStagingTarget([production, staging], {})).toBe(staging); + for (const name of ["debate-trainer-staging", "DebateOwl Staging", "Renamed again"]) { + expect(() => assertStagingTarget([production, { ref: STAGING_REF, name }], {})).not.toThrow(); + } + // Older CLI output has only `id`. + expect(() => assertStagingTarget([{ id: STAGING_REF, name: "DebateOwl Staging" }], {})).not.toThrow(); }); it("refuses when SUPABASE_PROJECT_ID points anywhere else", () => { @@ -18,9 +23,14 @@ describe("staging sync target guard", () => { .toThrow(/only targets staging/); }); - it("refuses when the staging ref is missing or renamed", () => { + it("refuses when SUPABASE_PROJECT_ID is set to production even if staging is visible", () => { + expect(() => assertStagingTarget([staging], { SUPABASE_PROJECT_ID: ` ${PRODUCTION_REF} ` })).toThrow(/only targets staging/); + expect(() => assertStagingTarget([staging], { SUPABASE_PROJECT_ID: STAGING_REF })).not.toThrow(); + }); + + it("refuses when the account cannot see the staging ref, even if a project is named like staging", () => { expect(() => assertStagingTarget([production], {})).toThrow(/cannot see/); - expect(() => assertStagingTarget([{ ref: STAGING_REF, name: "debate-trainer" }], {})).toThrow(/expected "debate-trainer-staging"/); + expect(() => assertStagingTarget([{ ref: PRODUCTION_REF, name: "DebateOwl Staging" }], {})).toThrow(/cannot see/); }); });