Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/scripts/preview/prune_stale_supabase_branches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Delete Supabase preview branches created more than MAX_AGE_DAYS ago. The
# project caps active branch projects at 50 and a PR that is abandoned or
# force-closed never runs the teardown that frees its branch, so the pool drains
# until new PRs cannot provision a preview database at all. Deletion is
# recoverable: the PR's next preview run rebuilds the branch from the prod
# schema snapshot.
set -euo pipefail

: "${SUPABASE_ACCESS_TOKEN:?}"
: "${SUPABASE_PROJECT_REF:?}"

MAX_AGE_DAYS="${MAX_AGE_DAYS:-7}"
DRY_RUN="${DRY_RUN:-false}"

case "$MAX_AGE_DAYS" in
'' | *[!0-9]*)
echo "MAX_AGE_DAYS must be a whole number of days, got '$MAX_AGE_DAYS'" >&2
exit 1
;;
esac

cutoff=$(($(date +%s) - MAX_AGE_DAYS * 86400))

# jq aborts on a branch whose created_at it cannot parse, which fails the whole
# script before any delete. That is deliberate: an unreadable listing must never
# be read as "nothing is stale".
stale=$(supabase branches list --project-ref "$SUPABASE_PROJECT_REF" -o json \
| jq -r --argjson cutoff "$cutoff" '
.[] | select(.persistent != true)
| select(.name | test("^pr-[0-9]+$"))
| select((.created_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) < $cutoff)
| [.id, .name, .created_at] | @tsv')

if [ -z "$stale" ]; then
echo "no preview branches older than $MAX_AGE_DAYS days"
exit 0
fi

failed=0
while IFS=$'\t' read -r id name created_at; do
if [ "$DRY_RUN" = "true" ]; then
echo "would delete $name ($id, created $created_at)"
continue
fi
echo "deleting $name ($id, created $created_at)"
# </dev/null so the interactive CLI cannot swallow the list this loop reads.
supabase branches delete "$id" --project-ref "$SUPABASE_PROJECT_REF" </dev/null || failed=1
done <<<"$stale"

exit "$failed"
48 changes: 48 additions & 0 deletions .github/workflows/preview-prune.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Preview Prune

# Sweeps Supabase preview branches older than a week so the 50-active-branch
# ceiling never blocks a new PR's "Prepare preview database" job. Runs on its
# own schedule rather than at the point of failure, so a PR that hits the
# ceiling is a bug in this sweep, not something to recover from inline.

on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
inputs:
max_age_days:
description: "Delete preview branches created more than this many days ago"
default: "7"
dry_run:
description: "Log what would be deleted without deleting it"
type: boolean
default: false

concurrency:
group: preview-prune
cancel-in-progress: false

jobs:
prune:
name: Prune stale preview branches
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: read
container:
image: ghcr.io/abundant-ai/oddish-ci-base:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ vars.SUPABASE_PROJECT_REF }}
MAX_AGE_DAYS: ${{ inputs.max_age_days || '7' }}
DRY_RUN: ${{ inputs.dry_run || false }}
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Prune stale Supabase preview branches
run: "$GITHUB_WORKSPACE/.github/scripts/preview/prune_stale_supabase_branches.sh"
166 changes: 166 additions & 0 deletions backend/tests/test_pr_preview_workflow.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest
Expand All @@ -11,10 +13,12 @@
REPO = Path(__file__).resolve().parents[2]
WORKFLOW = REPO / ".github/workflows/pr-preview.yml"
RESET_WORKFLOW = REPO / ".github/workflows/preview-reset.yml"
PRUNE_WORKFLOW = REPO / ".github/workflows/preview-prune.yml"
PREVIEW = REPO / ".github/scripts/preview"
PREPARE = PREVIEW / "prepare_preview_database.sh"
COMPUTE_PLAN = PREVIEW / "compute_deployment_plan.sh"
DEPLOY = PREVIEW / "deploy_preview_backend.sh"
PRUNE = PREVIEW / "prune_stale_supabase_branches.sh"
MODAL_APP = REPO / "backend/modal_app.py"

URL_FRAGMENT = "abundant-ai-preview--oddish-pr-{0}-api.modal.run"
Expand Down Expand Up @@ -437,3 +441,165 @@ def test_reset_reuses_preview_scripts():
)
assert prepare_step["env"]["DEPLOY_BACKEND"] == "true"
assert prepare_step["env"]["RUN_MIGRATIONS"] == "true"


def _prune_wf():
return yaml.safe_load(PRUNE_WORKFLOW.read_text())


def test_prune_runs_on_a_schedule():
on = _on(_prune_wf())
assert on["schedule"], "prune must run unattended, not only on dispatch"
assert "workflow_dispatch" in on
assert on["workflow_dispatch"]["inputs"]["max_age_days"]["default"] == "7"


def test_prune_workflow_invokes_the_script():
job = _prune_wf()["jobs"]["prune"]
steps = job["steps"]
assert any("prune_stale_supabase_branches.sh" in s.get("run", "") for s in steps)
for key in ("SUPABASE_ACCESS_TOKEN", "SUPABASE_PROJECT_REF", "MAX_AGE_DAYS"):
assert key in job["env"]
# Dispatch inputs reach the script through env, never interpolated into a
# run: body where they would be shell injection.
assert not any("${{" in s.get("run", "") for s in steps)


def test_prune_script_is_executable():
# The workflow runs it by path, so a lost exec bit is a broken cron.
assert os.access(PRUNE, os.X_OK)


def _branch(name, days_old, *, persistent=False, created_at=None):
if created_at is None:
stamp = datetime.now(timezone.utc) - timedelta(days=days_old)
created_at = stamp.strftime("%Y-%m-%dT%H:%M:%SZ")
return {
"id": f"id-{name}",
"name": name,
"persistent": persistent,
"created_at": created_at,
}


def _run_prune(branches, *, env=None, fail_delete=""):
tmp = Path(tempfile.mkdtemp())
bins = tmp / "bin"
bins.mkdir()
listing = tmp / "branches.json"
listing.write_text(json.dumps(branches))
deleted = tmp / "deleted"
deleted.write_text("")
fake = bins / "supabase"
# `delete` drains stdin the way the real interactive CLI does, so a script
# that fed it the branch list would only ever delete the first branch.
fake.write_text(
"#!/usr/bin/env bash\n"
'case "$2" in\n'
f' list) cat "{listing}" ;;\n'
" delete)\n"
" cat >/dev/null\n"
f' echo "$3" >> "{deleted}"\n'
f' [ "$3" = "{fail_delete}" ] && exit 1\n'
" ;;\n"
"esac\n"
"exit 0\n"
)
fake.chmod(0o755)
proc = subprocess.run(
["bash", str(PRUNE)],
env={
**os.environ,
"PATH": f"{bins}:{os.environ['PATH']}",
"SUPABASE_ACCESS_TOKEN": "token",
"SUPABASE_PROJECT_REF": "ref",
**(env or {}),
},
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
)
return proc, deleted.read_text().split()


@needs_bash
def test_prune_deletes_only_stale_pr_branches():
proc, deleted = _run_prune(
[
_branch("pr-1", 10),
_branch("pr-2", 2),
_branch("pr-3", 30, persistent=True),
_branch("main", 99, persistent=True),
_branch("staging-preview", 99),
]
)
assert proc.returncode == 0, proc.stderr
assert deleted == ["id-pr-1"]


@needs_bash
def test_prune_honours_max_age_days():
proc, deleted = _run_prune(
[_branch("pr-1", 10), _branch("pr-2", 2)],
env={"MAX_AGE_DAYS": "1"},
)
assert proc.returncode == 0, proc.stderr
assert sorted(deleted) == ["id-pr-1", "id-pr-2"]


@needs_bash
def test_prune_dry_run_deletes_nothing():
proc, deleted = _run_prune([_branch("pr-1", 10)], env={"DRY_RUN": "true"})
assert proc.returncode == 0, proc.stderr
assert deleted == []
assert "would delete pr-1" in proc.stdout


@needs_bash
def test_prune_deletes_every_stale_branch():
# Regression: the delete CLI must not consume the loop's branch list.
proc, deleted = _run_prune([_branch(f"pr-{n}", 10) for n in (1, 2, 3)])
assert proc.returncode == 0, proc.stderr
assert sorted(deleted) == ["id-pr-1", "id-pr-2", "id-pr-3"]


@needs_bash
def test_prune_accepts_fractional_second_timestamps():
stamp = datetime.now(timezone.utc) - timedelta(days=10)
proc, deleted = _run_prune(
[_branch("pr-1", 0, created_at=stamp.strftime("%Y-%m-%dT%H:%M:%S.123456Z"))]
)
assert proc.returncode == 0, proc.stderr
assert deleted == ["id-pr-1"]


@needs_bash
def test_prune_fails_closed_on_unreadable_timestamp():
proc, deleted = _run_prune(
[_branch("pr-1", 10), _branch("pr-2", 0, created_at="whenever")]
)
assert proc.returncode != 0
assert deleted == []


@needs_bash
def test_prune_reports_a_failed_delete_and_keeps_going():
proc, deleted = _run_prune(
[_branch("pr-1", 10), _branch("pr-2", 10)], fail_delete="id-pr-1"
)
assert proc.returncode == 1
assert sorted(deleted) == ["id-pr-1", "id-pr-2"]


@needs_bash
def test_prune_rejects_a_non_numeric_age():
proc, deleted = _run_prune([_branch("pr-1", 10)], env={"MAX_AGE_DAYS": "7 days"})
assert proc.returncode == 1
assert deleted == []


@needs_bash
def test_prune_is_quiet_when_nothing_is_stale():
proc, deleted = _run_prune([_branch("pr-1", 2)])
assert proc.returncode == 0, proc.stderr
assert deleted == []
Loading