Skip to content

Commit d3615ba

Browse files
authored
Merge branch 'staging' into feat/provider-prefix-routing
2 parents d13f269 + 86f1275 commit d3615ba

547 files changed

Lines changed: 45197 additions & 17757 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!-- promote-warning -->
2+
> [!CAUTION]
3+
> **DO NOT USE THE MERGE BUTTON ON THIS PULL REQUEST.**
4+
> **THE BUTTON CREATES A NEW COMMIT AND BREAKS THE RELEASE MODEL.**
5+
> **COMMENT `/promote` TO COMPLETE THE PROMOTION.**
6+
7+
<!-- promotion-target: REPLACE_WITH_STAGING_SHA -->
8+
<!-- Replace the value above with the staging commit this release was
9+
validated against (git rev-parse origin/staging). Bare /promote promotes
10+
exactly that sha — commits that land on staging afterwards do not ride
11+
along — and /promote <sha> overrides it. An unfilled placeholder fails
12+
the promotion checks; deleting the whole line promotes the staging tip. -->
13+
14+
## Summary
15+
16+
<!-- One sentence: what this release ships. -->
17+
18+
## Commits in this promotion
19+
20+
<!-- Paste: git log --oneline origin/main..origin/staging -->
21+
22+
## Validation
23+
24+
<!-- Staging Deploy is green on the pinned target. Note anything soak-tested on staging.oddish.app. -->

.github/docker/ci-base.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# `ghcr.io/abundant-ai/oddish-ci-base:latest` by
55
# `.github/workflows/ci-base-image.yml`. Consumers reference it via the
66
# `container:` field on a job — see `pr-preview.yml`, `modal-deploy.yml`
7-
# and `supabase-db-migrations.yml`.
7+
# and `staging-deploy.yml`.
88
#
99
# Contents:
1010
# - Python 3.13 (deadsnakes), uv, gh, jq, git, build tools

.github/scripts/preview/bootstrap_preview_db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
ODDISH_DIR = REPO_ROOT / "oddish"
2525
BACKEND_DIR = REPO_ROOT / "backend"
2626
SCRIPT_DIR = Path(__file__).resolve().parent
27-
# Prod migration order: oddish first, then backend (supabase-db-migrations.yml).
27+
# Prod migration order: oddish first, then backend (modal-deploy.yml).
2828
STACKS = (ODDISH_DIR, BACKEND_DIR)
2929
ALEMBIC_VERSION_TABLES = ("alembic_version_oddish", "alembic_version_backend")
3030

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Delete Supabase preview branches created more than MAX_AGE_DAYS ago. The
3+
# project caps active branch projects at 50 and a PR that is abandoned or
4+
# force-closed never runs the teardown that frees its branch, so the pool drains
5+
# until new PRs cannot provision a preview database at all. Deletion is
6+
# recoverable: the PR's next preview run rebuilds the branch from the prod
7+
# schema snapshot.
8+
set -euo pipefail
9+
10+
: "${SUPABASE_ACCESS_TOKEN:?}"
11+
: "${SUPABASE_PROJECT_REF:?}"
12+
13+
MAX_AGE_DAYS="${MAX_AGE_DAYS:-7}"
14+
DRY_RUN="${DRY_RUN:-false}"
15+
16+
case "$MAX_AGE_DAYS" in
17+
'' | *[!0-9]*)
18+
echo "MAX_AGE_DAYS must be a whole number of days, got '$MAX_AGE_DAYS'" >&2
19+
exit 1
20+
;;
21+
esac
22+
23+
cutoff=$(($(date +%s) - MAX_AGE_DAYS * 86400))
24+
25+
# jq aborts on a branch whose created_at it cannot parse, which fails the whole
26+
# script before any delete. That is deliberate: an unreadable listing must never
27+
# be read as "nothing is stale".
28+
stale=$(supabase branches list --project-ref "$SUPABASE_PROJECT_REF" -o json \
29+
| jq -r --argjson cutoff "$cutoff" '
30+
def parse_supabase_time:
31+
sub("\\+00:00$"; "Z")
32+
| sub("\\.[0-9]+Z$"; "Z")
33+
| fromdateiso8601;
34+
35+
.[] | select(.persistent != true)
36+
| select(.name | test("^pr-[0-9]+$"))
37+
| select((.created_at | parse_supabase_time) < $cutoff)
38+
| [.id, .name, .created_at] | @tsv')
39+
40+
if [ -z "$stale" ]; then
41+
echo "no preview branches older than $MAX_AGE_DAYS days"
42+
exit 0
43+
fi
44+
45+
failed=0
46+
while IFS=$'\t' read -r id name created_at; do
47+
if [ "$DRY_RUN" = "true" ]; then
48+
echo "would delete $name ($id, created $created_at)"
49+
continue
50+
fi
51+
echo "deleting $name ($id, created $created_at)"
52+
# </dev/null so the interactive CLI cannot swallow the list this loop reads.
53+
supabase branches delete "$id" --project-ref "$SUPABASE_PROJECT_REF" </dev/null || failed=1
54+
done <<<"$stale"
55+
56+
exit "$failed"
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""Create the production Vercel deployment for an exact commit and wait for it.
2+
3+
Why the trigger lives here instead of Vercel's git integration: see the header
4+
of .github/workflows/modal-deploy.yml. frontend/vercel.json disables git
5+
deploys for main, and the Production Deploy workflow runs this script after
6+
the backend job, so a new frontend never talks to an old backend. The
7+
deployment is created from the connected repository at VERCEL_GIT_COMMIT_SHA —
8+
the same build pipeline and project settings as the git-triggered deploys it
9+
replaces; only the trigger moved.
10+
11+
Exits non-zero unless the deployment reaches READY, the production alias is
12+
assigned, and this deployment currently owns production. With git deploys off,
13+
a red job here is the only signal that production is still serving the
14+
previous frontend — the polling half of this script is load-bearing, not
15+
ceremony. Superseded runs refuse to act at all: a re-run of an old workflow
16+
run must neither report green for a commit production no longer serves nor
17+
rebuild that old commit and hand the domain back to it.
18+
19+
Inputs (env vars):
20+
VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID,
21+
VERCEL_GIT_BRANCH, VERCEL_GIT_COMMIT_SHA,
22+
GITHUB_STEP_SUMMARY (optional)
23+
"""
24+
25+
import json
26+
import os
27+
import subprocess
28+
import time
29+
import urllib.error
30+
import urllib.parse
31+
import urllib.request
32+
33+
# The frontend job's timeout-minutes in modal-deploy.yml must stay comfortably
34+
# above READY_TIMEOUT_S + ALIAS_TIMEOUT_S plus per-request overhead, so a slow
35+
# build fails here with a diagnostic instead of as an opaque runner kill.
36+
READY_TIMEOUT_S = 25 * 60
37+
ALIAS_TIMEOUT_S = 3 * 60
38+
POLL_INTERVAL_S = 15
39+
REQUEST_TIMEOUT_S = 30
40+
# Transient API failures tolerated in a row while polling (~100 GETs over 25
41+
# minutes will see the odd 5xx/429); sustained failure still fails the job.
42+
MAX_CONSECUTIVE_POLL_FAILURES = 8
43+
# BLOCKED (spend limit / abuse review) is terminal for this run: waiting the
44+
# full budget on it would only delay the red job.
45+
FAILURE_STATES = {"ERROR", "CANCELED", "DELETED", "BLOCKED"}
46+
47+
48+
class VercelApiError(Exception):
49+
pass
50+
51+
52+
def api(method, path, body=None, query=None):
53+
params = {"teamId": os.environ["VERCEL_ORG_ID"], **(query or {})}
54+
request = urllib.request.Request(
55+
f"https://api.vercel.com{path}?{urllib.parse.urlencode(params)}",
56+
data=None if body is None else json.dumps(body).encode(),
57+
method=method,
58+
headers={
59+
"Authorization": f"Bearer {os.environ['VERCEL_TOKEN']}",
60+
"Content-Type": "application/json",
61+
},
62+
)
63+
try:
64+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_S) as response:
65+
return json.load(response)
66+
except urllib.error.HTTPError as error:
67+
detail = error.read().decode(errors="replace")
68+
raise VercelApiError(
69+
f"{method} {path} failed with {error.code}: {detail}"
70+
) from error
71+
except (urllib.error.URLError, OSError, ValueError) as error:
72+
raise VercelApiError(f"{method} {path} failed: {error}") from error
73+
74+
75+
def main():
76+
branch = os.environ["VERCEL_GIT_BRANCH"]
77+
commit_sha = os.environ["VERCEL_GIT_COMMIT_SHA"]
78+
79+
# Refuse superseded runs before touching Vercel. main is fast-forward
80+
# only, so a run whose sha is no longer the branch tip means a newer
81+
# promotion exists: going further would either report green for a commit
82+
# production no longer serves, or — via the forceNew retry below —
83+
# rebuild the old sha and Vercel would hand the production domain back
84+
# to it. Uses the checkout's persisted credentials.
85+
try:
86+
tip = subprocess.run(
87+
["git", "ls-remote", "origin", f"refs/heads/{branch}"],
88+
capture_output=True,
89+
text=True,
90+
check=True,
91+
).stdout.split()
92+
except subprocess.CalledProcessError as error:
93+
raise SystemExit(
94+
f"Could not resolve the {branch} tip to rule out a superseded "
95+
f"run: {error.stderr.strip()}"
96+
) from error
97+
if not tip or tip[0] != commit_sha:
98+
raise SystemExit(
99+
f"{branch} has moved to {tip[0] if tip else '<unknown>'}; this "
100+
f"run's {commit_sha} is superseded and must not touch production. "
101+
"The newer push's own Production Deploy run ships it."
102+
)
103+
104+
project = api("GET", f"/v9/projects/{os.environ['VERCEL_PROJECT_ID']}")
105+
link = project.get("link") or {}
106+
if link.get("type") != "github" or not link.get("repoId"):
107+
raise SystemExit(
108+
"The Vercel project is not linked to a GitHub repository, so a "
109+
"production deployment cannot be created from a commit."
110+
)
111+
112+
def create(force_new):
113+
return api(
114+
"POST",
115+
"/v13/deployments",
116+
body={
117+
"name": project["name"],
118+
"target": "production",
119+
"gitSource": {
120+
"type": "github",
121+
"repoId": link["repoId"],
122+
"ref": branch,
123+
"sha": commit_sha,
124+
},
125+
},
126+
query={"forceNew": "1"} if force_new else None,
127+
)
128+
129+
def unusable(deployment):
130+
"""A deduplicated deployment this run must not adopt.
131+
132+
Covers every state the polls below would reject, so a retry never
133+
replays a foregone failure: a non-production deployment (every
134+
promoted sha was once the staging tip, so a preview build of it
135+
usually exists, and a preview must not stand in for the production
136+
deploy), a build that already failed, a broken alias, and READY
137+
without the alias attached — a deployment this run just created is
138+
never READY yet, so that combination always means a stale prior
139+
build whose alias never landed.
140+
"""
141+
return (
142+
deployment.get("target") != "production"
143+
or deployment.get("readyState") in FAILURE_STATES
144+
or bool(deployment.get("aliasError"))
145+
or (
146+
deployment.get("readyState") == "READY"
147+
and not deployment.get("aliasAssigned")
148+
)
149+
)
150+
151+
# Without forceNew, Vercel deduplicates on the deployed sha and may hand
152+
# back an existing deployment instead of building. Usually that is right:
153+
# re-invoking for a sha whose production build exists returns it instead
154+
# of building twice. An unusable dedup result gets one forced fresh build.
155+
deployment = create(force_new=False)
156+
if unusable(deployment):
157+
print(
158+
"Deduplicated deployment is unusable "
159+
f"(target={deployment.get('target')!r}, "
160+
f"readyState={deployment.get('readyState')!r}, "
161+
f"aliasAssigned={deployment.get('aliasAssigned')!r}, "
162+
f"aliasError={deployment.get('aliasError')!r}); "
163+
"forcing a fresh production build"
164+
)
165+
deployment = create(force_new=True)
166+
if deployment.get("target") != "production":
167+
raise SystemExit(
168+
f"Vercel returned a non-production deployment "
169+
f"(target={deployment.get('target')!r}) for {commit_sha} even "
170+
"with forceNew; refusing to treat it as the production deploy."
171+
)
172+
deployment_id = deployment["id"]
173+
url = "https://" + deployment["url"]
174+
print(f"Production deployment {deployment_id} for {commit_sha}: {url}")
175+
176+
def poll(condition, timeout_s, describe):
177+
deadline = time.monotonic() + timeout_s
178+
failures = 0
179+
while True:
180+
try:
181+
current = api("GET", f"/v13/deployments/{deployment_id}")
182+
failures = 0
183+
except VercelApiError as error:
184+
failures += 1
185+
if failures >= MAX_CONSECUTIVE_POLL_FAILURES:
186+
raise SystemExit(
187+
f"Polling deployment {deployment_id} failed "
188+
f"{failures} times in a row: {error}"
189+
) from error
190+
else:
191+
state = current.get("readyState", "UNKNOWN")
192+
if state in FAILURE_STATES:
193+
raise SystemExit(f"Deployment {deployment_id} ended {state}.")
194+
if current.get("aliasError"):
195+
raise SystemExit(
196+
f"Production alias failed: {current['aliasError']}"
197+
)
198+
if condition(current):
199+
return current
200+
print(f"Deployment {deployment_id}: {state}, waiting for {describe}")
201+
if time.monotonic() >= deadline:
202+
raise SystemExit(
203+
f"Deployment {deployment_id} still not {describe} after "
204+
f"{timeout_s // 60} minutes."
205+
)
206+
time.sleep(POLL_INTERVAL_S)
207+
208+
poll(lambda d: d.get("readyState") == "READY", READY_TIMEOUT_S, "READY")
209+
# READY means the build succeeded; the production domain flips when the
210+
# alias is assigned, and that flip is what "the frontend shipped" means.
211+
final = poll(
212+
lambda d: bool(d.get("aliasAssigned")), ALIAS_TIMEOUT_S, "alias assignment"
213+
)
214+
215+
# aliasAssigned is historical: it stays truthy on a deployment after a
216+
# newer one takes the domain, so it proves assignment happened, not that
217+
# this deployment owns production NOW. Confirm current ownership from the
218+
# project's production target. Only a positively confirmed different
219+
# owner fails; an absent field keeps the poll verdict (older API shapes).
220+
# A few retries absorb propagation lag right after the alias flip.
221+
owner = None
222+
for _ in range(6):
223+
target = (
224+
api("GET", f"/v9/projects/{os.environ['VERCEL_PROJECT_ID']}").get(
225+
"targets"
226+
)
227+
or {}
228+
).get("production") or {}
229+
owner = target.get("id") or target.get("uid")
230+
if owner in (None, deployment_id):
231+
break
232+
time.sleep(POLL_INTERVAL_S)
233+
if owner not in (None, deployment_id):
234+
raise SystemExit(
235+
f"Production is owned by deployment {owner}, not {deployment_id}; "
236+
"a newer deploy took the domain while this run was waiting."
237+
)
238+
239+
aliases = final.get("alias") or []
240+
production_url = f"https://{aliases[0]}" if aliases else url
241+
print(f"Production frontend is live: {production_url}")
242+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
243+
if summary:
244+
with open(summary, "a") as f:
245+
f.write(
246+
"## Vercel production deployment\n\n"
247+
f"- Production: {production_url}\n"
248+
f"- Deployment: {url}\n"
249+
f"- Commit: `{commit_sha}`\n"
250+
)
251+
252+
253+
if __name__ == "__main__":
254+
try:
255+
main()
256+
except VercelApiError as error:
257+
raise SystemExit(f"Vercel API error: {error}")

0 commit comments

Comments
 (0)