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
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
13 changes: 8 additions & 5 deletions scripts/staging-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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. */
Expand Down
22 changes: 16 additions & 6 deletions src/test/scripts/staging-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,32 @@ 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", () => {
expect(() => assertStagingTarget([production, staging], { SUPABASE_PROJECT_ID: PRODUCTION_REF }))
.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/);
});
});

Expand Down
Loading