Skip to content

Commit d7864e7

Browse files
committed
Staging sync checks the project by ref, not its display name
The staging project was renamed from debate-trainer-staging to DebateOwl Staging, and the sync refused to run since 2026-10-28 11:18 UTC. The target check now keys on the ref (brifeyypjauyusmkptkx), which never changes. The SUPABASE_PROJECT_ID override check, the staging != production check and the explicit --project-ref on every CLI call are unchanged.
1 parent a079628 commit d7864e7

4 files changed

Lines changed: 28 additions & 15 deletions

File tree

‎docs/operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, be
101101

102102
## Staging sync
103103

104-
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.
104+
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.
105105

106106
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.
107107

‎docs/staging.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Current.** The one place that says how to reach staging, what is on it, and how it stays in sync with `main`.
44

5-
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.
5+
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.
66

77
## Get in
88

@@ -43,7 +43,7 @@ Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing
4343
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:
4444

4545
1. checks that commit out in a dedicated clone at `~/ops/debatelab-staging-sync/repo` (it never runs `npm install`);
46-
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;
46+
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;
4747
3. reruns the seed;
4848
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.
4949

@@ -57,7 +57,7 @@ PRs that change `supabase/migrations/` or `supabase/functions/` get a comment sa
5757

5858
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`.
5959

60-
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.
60+
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.
6161

6262
### Operate it
6363

‎scripts/staging-sync.mjs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import { join, relative } from "node:path";
2626
import { backendStatus } from "./backend-status.mjs";
2727

2828
export const STAGING_REF = "brifeyypjauyusmkptkx";
29-
export const STAGING_NAME = "debate-trainer-staging";
3029
export const PRODUCTION_REF = "uziwdtkzofptihahfooo";
3130
export const GITHUB_REPO = "Programcode/DebateLab";
3231
const STATUS_CONTEXT = "staging-sync";
@@ -78,7 +77,13 @@ function supabase(root, args) {
7877
return output;
7978
}
8079

81-
/** Refuses to run unless the CLI sees the staging ref under the staging name. */
80+
/**
81+
* Refuses to run unless the CLI can see the staging project by its ref. The
82+
* check is keyed on the ref, which never changes, not on the display name,
83+
* which anyone can rename in the dashboard (staging went from
84+
* "debate-trainer-staging" to "DebateOwl Staging"). Every CLI call passes
85+
* STAGING_REF explicitly, so production is never the target.
86+
*/
8287
export function assertStagingTarget(projects, env = process.env) {
8388
const override = env.SUPABASE_PROJECT_ID?.trim();
8489
if (override && override !== STAGING_REF) {
@@ -87,9 +92,7 @@ export function assertStagingTarget(projects, env = process.env) {
8792
if (STAGING_REF === PRODUCTION_REF) throw new RefusedError("Staging and production refs are the same.");
8893
const staging = projects.find((project) => (project.ref ?? project.id) === STAGING_REF);
8994
if (!staging) throw new RefusedError(`The Supabase account cannot see ${STAGING_REF}.`);
90-
if (staging.name !== STAGING_NAME) {
91-
throw new RefusedError(`${STAGING_REF} is named "${staging.name}", expected "${STAGING_NAME}".`);
92-
}
95+
return staging;
9396
}
9497

9598
/** What the sync has to do, from a backend-status result. */

‎src/test/scripts/staging-sync.test.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,32 @@ import { describe, expect, it } from "vitest";
55
// @ts-expect-error -- plain ESM script without type declarations
66
import { assertNoUndeclared, assertStagingTarget, contentDrift, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs";
77

8-
const staging = { ref: STAGING_REF, name: "debate-trainer-staging" };
9-
const production = { ref: PRODUCTION_REF, name: "debate-trainer" };
8+
const staging = { ref: STAGING_REF, name: "DebateOwl Staging" };
9+
const production = { ref: PRODUCTION_REF, name: "DebateOwl" };
1010

1111
describe("staging sync target guard", () => {
12-
it("accepts the staging project under its own name", () => {
13-
expect(() => assertStagingTarget([production, staging], {})).not.toThrow();
12+
it("accepts the staging project by its ref, whatever it is named", () => {
13+
expect(assertStagingTarget([production, staging], {})).toBe(staging);
14+
for (const name of ["debate-trainer-staging", "DebateOwl Staging", "Renamed again"]) {
15+
expect(() => assertStagingTarget([production, { ref: STAGING_REF, name }], {})).not.toThrow();
16+
}
17+
// Older CLI output has only `id`.
18+
expect(() => assertStagingTarget([{ id: STAGING_REF, name: "DebateOwl Staging" }], {})).not.toThrow();
1419
});
1520

1621
it("refuses when SUPABASE_PROJECT_ID points anywhere else", () => {
1722
expect(() => assertStagingTarget([production, staging], { SUPABASE_PROJECT_ID: PRODUCTION_REF }))
1823
.toThrow(/only targets staging/);
1924
});
2025

21-
it("refuses when the staging ref is missing or renamed", () => {
26+
it("refuses when SUPABASE_PROJECT_ID is set to production even if staging is visible", () => {
27+
expect(() => assertStagingTarget([staging], { SUPABASE_PROJECT_ID: ` ${PRODUCTION_REF} ` })).toThrow(/only targets staging/);
28+
expect(() => assertStagingTarget([staging], { SUPABASE_PROJECT_ID: STAGING_REF })).not.toThrow();
29+
});
30+
31+
it("refuses when the account cannot see the staging ref, even if a project is named like staging", () => {
2232
expect(() => assertStagingTarget([production], {})).toThrow(/cannot see/);
23-
expect(() => assertStagingTarget([{ ref: STAGING_REF, name: "debate-trainer" }], {})).toThrow(/expected "debate-trainer-staging"/);
33+
expect(() => assertStagingTarget([{ ref: PRODUCTION_REF, name: "DebateOwl Staging" }], {})).toThrow(/cannot see/);
2434
});
2535
});
2636

0 commit comments

Comments
 (0)