Skip to content

Commit eb5ae65

Browse files
Programcodeclaude
andcommitted
fix(ops): flag unreadable deploy dates and name the CLI's target
A function whose deploy date is missing or unreadable was compared as current, so a stale function could pass unnoticed. It is now listed as changed with "deploy date unknown". The report label read the linked ref file, but the Supabase CLI prefers SUPABASE_PROJECT_ID over it, so the label could name a different project than the one inspected. The label now follows the CLI's order. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d6c7dca commit eb5ae65

3 files changed

Lines changed: 59 additions & 6 deletions

File tree

‎docs/operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Do not enable the staging repository variable before every prerequisite is prese
6565

6666
## Backend drift check
6767

68-
`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the linked project; `npm run backend:status -- --project-ref <ref>` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role.
68+
`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 <ref>` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role.
6969

7070
The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports:
7171

‎scripts/backend-status.mjs‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ export function lastChange(root, paths) {
172172
return { sha: sha.slice(0, 7), time: Number(seconds) * 1000, subject };
173173
}
174174

175+
/** Deploy time in milliseconds, or NaN when the CLI gave none or an unreadable one. */
176+
function deployTime(updatedAt) {
177+
return typeof updatedAt === "number" ? updatedAt : Date.parse(updatedAt);
178+
}
179+
175180
export function compareBackend({ local, remoteMigrations, deployedFunctions, changeOf }) {
176181
const applied = new Set(remoteMigrations);
177182
const known = new Set(local.migrations);
@@ -188,7 +193,8 @@ export function compareBackend({ local, remoteMigrations, deployedFunctions, cha
188193
continue;
189194
}
190195
const change = changeOf(fn);
191-
if (change && change.time > remote.updated_at) stale.push({ name: fn.name, deployed: remote, change });
196+
// A deploy date we cannot read must not make a changed function look current.
197+
if (change && !(change.time <= deployTime(remote.updated_at))) stale.push({ name: fn.name, deployed: remote, change });
192198
const expected = local.verifyJwt[fn.name] ?? true;
193199
if (typeof remote.verify_jwt === "boolean" && remote.verify_jwt !== expected) {
194200
verifyJwt.push({ name: fn.name, deployed: remote.verify_jwt, expected });
@@ -210,6 +216,11 @@ function formatTime(milliseconds) {
210216
return `${new Date(milliseconds).toISOString().slice(0, 16).replace("T", " ")} UTC`;
211217
}
212218

219+
function deployedDate(updatedAt) {
220+
const time = deployTime(updatedAt);
221+
return Number.isFinite(time) ? `deployed ${formatTime(time)}` : "deploy date unknown";
222+
}
223+
213224
export function formatReport({ target, local, result, deployedCount, notes }) {
214225
const lines = [`Backend status: ${target} compared with commit ${local.commit}`, ""];
215226
const list = (title, items) => {
@@ -227,7 +238,7 @@ export function formatReport({ target, local, result, deployedCount, notes }) {
227238
list(
228239
"Changed on this commit after the last deploy",
229240
result.stale.map(({ name, deployed, change }) =>
230-
`${name}: deployed ${formatTime(deployed.updated_at)} (v${deployed.version}); changed ${formatTime(change.time)} in ${change.sha} ${change.subject}`),
241+
`${name}: ${deployedDate(deployed.updated_at)} (v${deployed.version}); changed ${formatTime(change.time)} in ${change.sha} ${change.subject}`),
231242
);
232243
list(
233244
"verify_jwt differs from supabase/config.toml",
@@ -260,8 +271,11 @@ export function backendStatus({ root, projectRef }) {
260271
if (dirty) notes.push("uncommitted changes under supabase/ are not part of this comparison.");
261272
notes.push("function change dates come from git. A function deployed from a branch before its merge shows as changed; one deployed from an out-of-date checkout looks current.");
262273

274+
// Same order the Supabase CLI uses: the flag, then SUPABASE_PROJECT_ID, then the linked ref.
263275
const linkedRefFile = join(root, "supabase/.temp/project-ref");
264-
const ref = projectRef ?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project");
276+
const ref = projectRef
277+
?? (process.env.SUPABASE_PROJECT_ID?.trim() || null)
278+
?? (existsSync(linkedRefFile) ? readFileSync(linkedRefFile, "utf8").trim() : "linked project");
265279
const label = KNOWN_PROJECTS[ref];
266280
const target = label ? `${ref} (${label})` : ref;
267281

‎src/test/scripts/backend-status.test.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,30 @@ function commit(message: string, seconds: number): void {
2222
}
2323
}
2424

25-
function runStatus(args: string[]) {
25+
function runStatus(args: string[], extraEnv: Record<string, string> = {}) {
26+
const { SUPABASE_PROJECT_ID: _ignored, ...env } = process.env;
2627
return spawnSync("node", [SCRIPT, ...args], {
2728
cwd: root,
2829
encoding: "utf8",
29-
env: { ...process.env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}` },
30+
env: { ...env, CALL_LOG: join(root, "calls.log"), PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, ...extraEnv },
3031
});
3132
}
3233

34+
async function fakeFunctionsList(functions: unknown[]): Promise<void> {
35+
const migrations = { migrations: [{ local: "20260101000000", remote: "20260101000000", time: "" }] };
36+
await writeFile(
37+
join(root, "bin/supabase"),
38+
[
39+
"#!/usr/bin/env bash",
40+
'printf "%s\\n" "$*" >> "$CALL_LOG"',
41+
`if [ "$1 $2" = "migration list" ]; then echo '${JSON.stringify(migrations)}'; exit 0; fi`,
42+
`if [ "$1 $2" = "functions list" ]; then echo '${JSON.stringify({ functions })}'; exit 0; fi`,
43+
"exit 99",
44+
"",
45+
].join("\n"),
46+
);
47+
}
48+
3349
beforeEach(async () => {
3450
root = await mkdtemp(join(tmpdir(), "debatelab-backend-status-"));
3551
temporaryRoots.push(root);
@@ -119,6 +135,29 @@ it("passes --project-ref to both commands and names known projects", async () =>
119135
]);
120136
});
121137

138+
it("names the project chosen by SUPABASE_PROJECT_ID, which the CLI prefers over the linked ref", async () => {
139+
await write("supabase/.temp/project-ref", "uziwdtkzofptihahfooo\n");
140+
141+
const result = runStatus([], { SUPABASE_PROJECT_ID: STAGING_REF });
142+
143+
expect(result.stdout).toContain(`Backend status: ${STAGING_REF} (staging) compared with commit`);
144+
});
145+
146+
it("counts a function whose deploy date is missing or unreadable as changed", async () => {
147+
await fakeFunctionsList([
148+
{ slug: "alpha", version: 4, verify_jwt: false },
149+
{ slug: "beta", version: 2, updated_at: "not a date", verify_jwt: true },
150+
{ slug: "gamma", version: 3, updated_at: 9_000_000, verify_jwt: true },
151+
]);
152+
153+
const result = runStatus([]);
154+
155+
expect(result.status).toBe(0);
156+
expect(result.stdout).toMatch(/Changed on this commit after the last deploy:\n {4}alpha: deploy date unknown \(v4\); changed 1970-01-01 00:33 UTC in [0-9a-f]{7} change shared helper\n {4}beta: deploy date unknown \(v2\); changed 1970-01-01 00:16 UTC in [0-9a-f]{7} initial\n/);
157+
expect(result.stdout).not.toMatch(/ {4}gamma:/);
158+
expect(result.stdout).toContain("3 pending items.");
159+
});
160+
122161
it("fails --check only when a migration on this commit is not applied", async () => {
123162
expect(runStatus(["--check"]).status).toBe(1);
124163

0 commit comments

Comments
 (0)