Skip to content

Commit dbf6771

Browse files
kartikey-bhandre-fudot-agiclaude
authored
release: promote staging (opencode closed-internet egress) (#1078)
* Repin Harbor to 504c2518 (#1076) * fix(ci): staging workflow coordination and sync guard fixes (#1075) * fix(ci): coordinate staging deploy with the database bootstrap * fix(ci): sync guard runs on main pushes and gives safe recovery advice * fix(ci): deploy staging after the bootstrap mirror The shared staging-environment concurrency group serializes staging-deploy with staging-db-bootstrap but does not order them. When the deploy wins the lock on a bootstrap-triggering push, it migrates the pre-mirror branch and nothing deploys after the mirror. The bootstrap now dispatches its own Staging Deploy at the end, so the post-mirror deploy no longer depends on which run started first. It is skipped after a password reset, where the stale STAGING_DATABASE_URL secret would fail the deploy at migrations. Pass --ref staging in the printed deploy recipe so the runbook does not depend on the repository default branch staying staging. State the supersede rule where it matters: only the staging tip is guaranteed a deploy run, so the promotion preflight now explains that a commit staging moved past may never have deployed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz * fix(ci): publish the staging DB secret before the mirror The password-reset recovery recipe reads the new URL from the oddish-staging-db Modal secret, but that secret was published after the multi-hour mirror. An operator who acted on the printed warning got the pre-reset URL, and a failure between the reset and the publish left the new password with no durable copy at all, because the Supabase API redacts it. Publish the secret immediately after the branch step resolves the URL. Every later step reads steps.branch.outputs.db_url directly, so none of them depend on the old position. Add a credential check to Staging Deploy. A deploy queued in the shared group still runs after a password reset and still fails, which is correct, since staging is not deployed at that commit until a human refreshes the secret. The check names that cause instead of leaving a bare Alembic error, and it fails rather than skips so the promotion gate stays honest. Record that GitHub holds one pending run per concurrency group across both workflows, so a queued bootstrap can be cancelled by a newer queued deploy and needs dispatching again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz * fix(ci): stop the recovery recipe from blanking the staging secret The printed recipe piped modal run through grep straight into gh secret set. gh reads stdin verbatim and never checks it for emptiness, and the value is sealed client-side so the API cannot check it either. A modal run that failed or emitted no URL therefore replaced STAGING_DATABASE_URL with an empty value, and the pipeline still exited clean because the status came from gh. The recipe now captures the URL, checks it is non-empty, and only then writes the secret and dispatches the deploy. It uses a guard rather than pipefail, because the recipe is pasted into an operator's own shell and must not change that shell's options. Correct the emit script docstring to match: modal run writes its own progress output around the URL, so the line has to be selected and checked before any secret is written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz * fix(ci): stop restarting staging compute that is already correct The compute-return step applied the ci_large addon on every bootstrap run. It never read the current tier, so its claim of being a no-op described the intended end state and not the code. Applying an addon restarts the branch, which put a restart of unknown duration in front of the deploy this run dispatches, on every run rather than only after manual tier drift. Read the current tier and send nothing when it already matches. Only a tier that positively reads as ci_large skips the request. A failed read falls through and applies, which is the behaviour this step has always had. Retry the staging deploy credential check instead of probing once. A real tier change still restarts the branch, and this project has seen connections refused for seconds after health reports green, so a single attempt blamed a stale credential for a database that was merely coming back. The check now retries for 300s and its failure message names both causes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz * fix(ci): keep the live staging secret off a mid-teardown database Publishing oddish-staging-db right after the password reset gave the secret the live app mounts a working credential while the schema was being replaced and the tables reloaded. A cold start could then authenticate against a half-built database, where before the reset left the app failing closed. Split the two jobs that publish was doing. A recovery copy of the URL goes to oddish-staging-db-pending immediately, which nothing mounts, so the new password stays recoverable if a later step dies. The live secret is published only after the mirror completes. The emit script now takes a secret name from STAGING_DB_SECRET, and the printed recipe reads the pending copy. Run the branch sync guard on staging pushes too. Rewriting staging can drop main's ancestry without any push to main, and that path waited for the daily cron. Fail the staging deploy credential check immediately on a rejected password. Retrying a deterministic rejection held the shared concurrency lock for five minutes and delayed the recovery deploy. Refusals, timeouts and a starting database still retry to the deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PtexagKz2Si6SVLZoFKrz --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(ci): preview deployment gate fixes (#1074) * fix(ci): create the preview deployment record as the workflow * fix(ci): record the preview deployment against the head commit * feat(ci): skip preview provisioning for promotion pull requests * fix(ci): promotion short-circuit requires the repository's own staging branch * Enable opencode on closed-internet trials (egress allowlist) (#1030) * Enable opencode on closed-internet trials via egress allowlist Stock opencode self-installs (nvm/Node/opencode-ai) at trial start and declares no Oddish egress, so on allow_internet=false trials Harbor's Modal firewall blackholes both its install and its model calls: the trial dies at DNS during agent setup ("curl: (6) Could not resolve host: raw.githubusercontent.com") before the model is ever reached. Add an OddishOpenCode wrapper implementing required_outbound_domains (install-bootstrap hosts + model transport host via outbound_hosts_for_model, which already resolves OpenRouter -> openrouter.ai), and route `-a opencode` through it in _build_agent_config. Mirrors the AzureCompatibleCodex fix (#416). Adds routing + egress unit tests and a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4FaAPiaXnXCZzDJ8F1bvw * opencode egress: forward per-trial kwargs to host resolution Address Cursor Bugbot (medium): required_outbound_domains accepted kwargs but never forwarded them to outbound_hosts_for_model, so a transport host pinned via kwargs["extra_env"] (e.g. a custom OPENROUTER_BASE_URL) was not allowlisted and a closed-internet trial overriding its base URL would still be firewalled after install. Pass agent_kwargs=kwargs, mirroring AzureCompatibleCodex. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4FaAPiaXnXCZzDJ8F1bvw * Register opencode install hosts in _AGENT_RUNTIME_HOSTS — the enforced egress path required_outbound_domains has no consumer in oddish or harbor (verified at harbor 504c2518 and end-to-end on the PR preview backend: a closed-internet opencode trial still died at nvm DNS with only the hook declared). The allowlist actually enforced on closed-internet Modal trials is built by _inject_restricted_agent_model_hosts as outbound_hosts_for_model (which already resolves openrouter/<model> -> openrouter.ai) ∪ agent_runtime_hosts, which reads the static _AGENT_RUNTIME_HOSTS registry. Register OPENCODE_INSTALL_HOSTS there, keyed by both the stock agent name and the OddishOpenCode wrapper class name (wrapper routing nulls the name). The hook stays for interface parity, documented as declarative-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQug9XcmqY2aL5pmjJdRUN * Route opencode install+model hosts through the environment baseline, mirroring claude-code The agent-runtime-hosts registration could never work: opencode installs during agent SETUP, which runs under the environment baseline — the agent-phase allowlist only applies around agent.run() (verified e2e on the preview: trials -1060/-1061 still died at nvm DNS with the registry entry deployed). Mirror the claude-code installer arm instead: merge OPENCODE_INSTALL_HOSTS + outbound_hosts_for_model into env_config.extra_allowed_hosts, which harbor folds into the environment baseline spanning install and run. This also covers legacy closed tasks ([environment] allow_internet=false -> no-network baseline, no dynamic restricted agent phase — the GDM SWE-Marathon sample shape), where no other channel grants even the model host. On modern swe-marathon-shaped tasks the public baseline ignores the extras, so no install hosts leak into agent run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQug9XcmqY2aL5pmjJdRUN --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Pratyush Shukla <ps4534@nyu.edu> --------- Co-authored-by: Andre Fu <39042250+andre-fu@users.noreply.github.com> Co-authored-by: Pratyush Shukla <ps4534@nyu.edu> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent da3b501 commit dbf6771

17 files changed

Lines changed: 567 additions & 31 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Print the staging database URL from the oddish-staging-db Modal secret.
2+
3+
The Supabase API redacts branch passwords, and the Modal CLI cannot read a
4+
secret's value back, so a Modal function that mounts the secret is the one
5+
way to recover the live URL after a password reset. Run from backend/:
6+
7+
uv run modal run --env staging ../.github/scripts/staging/emit_staging_db_url.py
8+
9+
The URL is the only thing this script prints, but `modal run` writes its own
10+
progress output around it. Select the URL line and check it is non-empty
11+
before writing any secret: `gh secret set` stores whatever reaches its stdin,
12+
including nothing. The bootstrap job summary prints a recipe that does both,
13+
then dispatches the Staging Deploy workflow.
14+
15+
Set STAGING_DB_SECRET to read a different secret. The bootstrap writes the
16+
new password to oddish-staging-db-pending as soon as it resets one, and only
17+
promotes it to oddish-staging-db once the branch holds a complete database,
18+
so a recipe printed mid-run reads the pending copy.
19+
"""
20+
21+
import os
22+
23+
import modal
24+
25+
SECRET_NAME = os.environ.get("STAGING_DB_SECRET", "oddish-staging-db")
26+
27+
app = modal.App("emit-staging-db-url")
28+
image = modal.Image.debian_slim()
29+
30+
31+
@app.function(
32+
image=image,
33+
secrets=[modal.Secret.from_name(SECRET_NAME, environment_name="staging")],
34+
)
35+
def emit() -> str:
36+
import os
37+
38+
return os.environ["ODDISH_DATABASE_URL"]
39+
40+
41+
@app.local_entrypoint()
42+
def main() -> None:
43+
print(emit.remote())

.github/scripts/staging/refresh_staging_db.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ async def _run_mirror() -> None:
147147
flush=True)
148148
src_url = _session_pooler(_plain(os.environ["SOURCE_DB_URL"]))
149149
holder = await _src_connect(src_url)
150-
dst = await _connect_with_retry(_plain(os.environ["TARGET_DB_URL"]))
150+
# Same 6543->5432 rewrite as the source: the load holds one long
151+
# destination transaction, which the transaction pooler reaps.
152+
dst = await _connect_with_retry(_session_pooler(_plain(os.environ["TARGET_DB_URL"])))
151153
try:
152154
src_tables, dst_tables = await _tables(holder), await _tables(dst)
153155
include = (src_tables & dst_tables) - EXCLUDE

.github/workflows/pr-preview.yml

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ concurrency:
1515
jobs:
1616
detect-changes:
1717
name: Detect preview changes
18-
if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
18+
# Promotion pull requests (staging -> main) provision nothing: the staging
19+
# environment already serves the exact commit under review, so a per-PR
20+
# copy duplicates it. The required gate below still reports for them.
21+
if: >-
22+
github.event.action != 'closed' &&
23+
github.event.pull_request.head.repo.full_name == github.repository &&
24+
github.event.pull_request.head.ref != 'staging'
1925
runs-on: ubuntu-latest
2026
permissions:
2127
contents: read
@@ -253,11 +259,44 @@ jobs:
253259
- update-vercel-preview
254260
if: always() && github.event.action != 'closed'
255261
runs-on: ubuntu-latest
256-
environment:
257-
name: Preview
258-
url: ${{ needs.update-vercel-preview.outputs.preview_alias_url || needs.update-vercel-preview.outputs.preview_url }}
262+
# No job-level `environment:` key here: GitHub attributes that deployment
263+
# record to the person who triggered the run, so pull requests showed a
264+
# human as the deployer. The steps below create the same Preview record
265+
# through the API with the workflow token, so the deployer is
266+
# github-actions. The record still satisfies the staging ruleset's
267+
# required-deployments rule (same environment, same commit).
268+
permissions:
269+
deployments: write
270+
env:
271+
GH_TOKEN: ${{ github.token }}
272+
# Same-repository condition included: a fork branch named `staging`
273+
# must not short-circuit the gate to success.
274+
PROMOTION_PR: ${{ github.event.pull_request.head.ref == 'staging' && github.event.pull_request.head.repo.full_name == github.repository }}
275+
DEPLOY_URL: ${{ (github.event.pull_request.head.ref == 'staging' && github.event.pull_request.head.repo.full_name == github.repository) && 'https://staging.oddish.app' || needs.update-vercel-preview.outputs.preview_alias_url || needs.update-vercel-preview.outputs.preview_url }}
259276
steps:
277+
- name: Create the Preview deployment record
278+
id: deployment
279+
# Fork PRs get a read-only token; skip so the verify step below can
280+
# fail with its clearer same-repository message.
281+
if: github.event.pull_request.head.repo.full_name == github.repository
282+
env:
283+
# The head commit, not GITHUB_SHA: on pull_request events GITHUB_SHA
284+
# is the temporary merge commit, and the required-deployments rule
285+
# and the pull request page key on the head commit.
286+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
287+
run: |
288+
set -euo pipefail
289+
deployment_id=$(jq -n --arg ref "$HEAD_SHA" \
290+
'{ref: $ref, environment: "Preview", auto_merge: false,
291+
required_contexts: [], transient_environment: true,
292+
description: "PR preview gate"}' \
293+
| gh api "repos/$GITHUB_REPOSITORY/deployments" --input - --jq '.id')
294+
gh api "repos/$GITHUB_REPOSITORY/deployments/$deployment_id/statuses" \
295+
-f state=in_progress >/dev/null
296+
echo "id=$deployment_id" >> "$GITHUB_OUTPUT"
297+
260298
- name: Verify preview deployment
299+
id: verify
261300
env:
262301
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
263302
DETECT_RESULT: ${{ needs.detect-changes.result }}
@@ -287,6 +326,12 @@ jobs:
287326
exit 1
288327
}
289328
329+
if [ "$PROMOTION_PR" = "true" ]; then
330+
echo "- Promotion pull request: staging is the preview environment." >> "$GITHUB_STEP_SUMMARY"
331+
echo "- https://staging.oddish.app runs this commit (see Staging Deploy)." >> "$GITHUB_STEP_SUMMARY"
332+
exit 0
333+
fi
334+
290335
preview_url="${PREVIEW_ALIAS_URL:-${PREVIEW_URL:-}}"
291336
backend_required=false
292337
db_required=false
@@ -381,6 +426,22 @@ jobs:
381426
fi
382427
} >> "$GITHUB_STEP_SUMMARY"
383428
429+
- name: Publish the deployment result
430+
if: always() && steps.deployment.outputs.id != ''
431+
env:
432+
DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }}
433+
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
434+
run: |
435+
set -euo pipefail
436+
state=failure
437+
if [ "$VERIFY_OUTCOME" = "success" ]; then state=success; fi
438+
jq -n --arg state "$state" --arg url "${DEPLOY_URL:-}" \
439+
'{state: $state}
440+
+ (if $url != "" then {environment_url: $url} else {} end)' \
441+
| gh api "repos/$GITHUB_REPOSITORY/deployments/$DEPLOYMENT_ID/statuses" \
442+
--input - >/dev/null
443+
echo "deployment $DEPLOYMENT_ID marked $state"
444+
384445
post-preview-links:
385446
name: Post preview links
386447
needs:
@@ -419,7 +480,10 @@ jobs:
419480

420481
stop-preview:
421482
name: Stop preview
422-
if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
483+
if: >-
484+
github.event.action == 'closed' &&
485+
github.event.pull_request.head.repo.full_name == github.repository &&
486+
github.event.pull_request.head.ref != 'staging'
423487
runs-on: ubuntu-latest
424488
permissions:
425489
contents: read

.github/workflows/promote.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ jobs:
4444
gh run list --repo "$GITHUB_REPOSITORY" --workflow "Staging Deploy" \
4545
--branch staging --commit "$target" --json conclusion -q '.[0].conclusion' \
4646
| grep -qx success \
47-
|| { echo "::error::Staging Deploy is not green on $target"; exit 1; }
47+
|| {
48+
echo "::error::Staging Deploy is not green on $target"
49+
echo "::notice::A queued deploy is superseded when a newer commit lands (GitHub keeps one pending run per concurrency group), so a commit that staging moved past may never have deployed. A dispatched deploy always runs the tip of staging, not an older commit, so promote the staging tip instead of this sha."
50+
exit 1
51+
}
4852
else
4953
echo "::warning::Staging Deploy workflow not found — skipping deploy-green precondition (bootstrap)"
5054
fi

.github/workflows/staging-db-bootstrap.yml

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ on:
1313
workflow_dispatch: {}
1414

1515
concurrency:
16-
group: staging-db-bootstrap
16+
# Shared with staging-deploy.yml so the two never touch the branch at once.
17+
# GitHub holds one pending run per group across both workflows, so a
18+
# bootstrap still queued behind a running deploy is cancelled as soon as the
19+
# next staging push queues a deploy of its own. Nothing is lost --
20+
# STAGING_DB_BOOTSTRAP_DONE stays unset and no mirror has started -- but a
21+
# re-mirror cancelled this way has to be dispatched again. Watch a dispatched
22+
# run until it takes the lock.
23+
group: staging-environment
1724
cancel-in-progress: false
1825

1926
jobs:
@@ -144,6 +151,7 @@ jobs:
144151
echo "stored staging credential works — reusing it (no password reset)"
145152
echo "db_url=$stored_url" >> "$GITHUB_OUTPUT"
146153
echo "branch_ref=$branch_ref" >> "$GITHUB_OUTPUT"
154+
echo "password_reset=false" >> "$GITHUB_OUTPUT"
147155
exit 0
148156
fi
149157
echo "stored staging credential did not authenticate; resetting the password"
@@ -197,21 +205,52 @@ jobs:
197205
# The password changed, so the secret staging-deploy.yml reads is
198206
# now stale. This job cannot rewrite a GitHub secret (403), so a
199207
# human must refresh it or every later deploy fails at migrations.
208+
# The recipe captures the URL and checks it before writing, because
209+
# `gh secret set` stores whatever reaches its stdin and never checks
210+
# it for emptiness: piping a failed `modal run` straight in would
211+
# replace the credential with an empty value.
200212
{
201213
echo "## Staging database password was reset"
202214
echo
203-
echo "Refresh the deploy credential before the next staging deploy:"
215+
echo "The new URL is published to the oddish-staging-db-pending Modal"
216+
echo "secret by the next step of this run, and the recipe below reads it"
217+
echo "from there. Wait for that step to go green, then refresh the deploy"
218+
echo "credential. Running this during the mirror is fine: it only writes"
219+
echo "the GitHub secret, and the deploy it dispatches queues until this"
220+
echo "run finishes."
204221
echo
205222
echo '```bash'
206-
echo "cd backend && URL=\$(uv run modal run --env staging <emit-url script>)"
207-
echo "printf '%s' \"\$URL\" | gh secret set STAGING_DATABASE_URL --env staging"
223+
echo "cd backend"
224+
echo 'url=$(STAGING_DB_SECRET=oddish-staging-db-pending uv run modal run --env staging ../.github/scripts/staging/emit_staging_db_url.py | grep -E "^postgresql" | tail -1)'
225+
echo 'if [ -z "$url" ]; then'
226+
echo ' echo "no URL in the modal output — secret left unchanged"'
227+
echo 'else'
228+
echo " printf '%s' \"\$url\" | gh secret set STAGING_DATABASE_URL --repo $GITHUB_REPOSITORY --env staging"
229+
echo " gh workflow run 'Staging Deploy' --ref staging --repo $GITHUB_REPOSITORY"
230+
echo 'fi'
208231
echo '```'
209232
} >> "$GITHUB_STEP_SUMMARY"
210233
echo "::warning::staging DB password was reset — refresh the STAGING_DATABASE_URL secret on the staging environment (see job summary)"
211234
echo "db_url=$db_url" >> "$GITHUB_OUTPUT"
212235
echo "branch_ref=$branch_ref" >> "$GITHUB_OUTPUT"
236+
echo "password_reset=true" >> "$GITHUB_OUTPUT"
213237
echo "branch ready: ref=$branch_ref host=$db_host status=$status"
214238
239+
# A recovery copy of the URL, written before any destructive work. The
240+
# Supabase API redacts branch passwords, so without this a run that dies
241+
# between the reset and the end of the mirror leaves the new password
242+
# with no durable copy anywhere. Deliberately NOT oddish-staging-db:
243+
# that is the secret the live app mounts, and giving it a working
244+
# credential now would let a cold start authenticate against a database
245+
# that is mid-teardown. Nothing mounts this name.
246+
- name: Publish the recovery copy of the branch URL
247+
env:
248+
MODAL_ENVIRONMENT: staging
249+
BRANCH_DB_URL: ${{ steps.branch.outputs.db_url }}
250+
run: |
251+
uv run modal secret create --env "$MODAL_ENVIRONMENT" --force oddish-staging-db-pending \
252+
"ODDISH_DATABASE_URL=$BRANCH_DB_URL"
253+
215254
- name: Bootstrap schema (prod snapshot + Alembic pointers + upgrade head)
216255
env:
217256
ODDISH_DATABASE_URL: ${{ steps.branch.outputs.db_url }}
@@ -330,7 +369,7 @@ jobs:
330369
exit 1
331370
fi
332371
333-
- name: Scale branch compute up for the bulk load
372+
- name: Ensure branch compute for the bulk load
334373
shell: bash
335374
env:
336375
BRANCH_REF: ${{ steps.branch.outputs.branch_ref }}
@@ -427,6 +466,12 @@ jobs:
427466
TARGET_DB_URL: ${{ steps.branch.outputs.db_url }}
428467
run: uv run python "$GITHUB_WORKSPACE/.github/scripts/staging/refresh_staging_db.py"
429468

469+
# Only now, once the branch holds a complete database. This is the
470+
# secret the live app mounts, so until this point it keeps whatever
471+
# credential it had -- after a password reset that one is dead, and the
472+
# app failing closed is the wanted behaviour while the schema is being
473+
# replaced and the tables are being reloaded. The durable copy of the
474+
# new password lives in oddish-staging-db-pending from the start.
430475
- name: Publish oddish-staging-db Modal secret
431476
env:
432477
MODAL_ENVIRONMENT: staging
@@ -448,21 +493,39 @@ jobs:
448493
working-directory: ${{ github.workspace }}
449494
run: gh variable set STAGING_DB_BOOTSTRAP_DONE --body "true" --repo "$GITHUB_REPOSITORY"
450495

451-
- name: Return branch compute to the staging tier
496+
- name: Ensure branch compute is on the staging tier
452497
shell: bash
453498
continue-on-error: true
454499
env:
455500
BRANCH_REF: ${{ steps.branch.outputs.branch_ref }}
456501
STAGING_COMPUTE_VARIANT: "ci_large"
457502
run: |
458503
set -euo pipefail
459-
# Staging runs a fixed compute tier, not prod's -- this step only
460-
# undoes the temporary bump from "Scale branch compute up for the
461-
# bulk load" above. Must not fail the job -- the mirror already
462-
# succeeded and the guard variable is already flipped by the
463-
# previous step.
504+
# Staging runs a fixed compute tier, not prod's. The bulk-load step
505+
# ("Ensure branch compute for the bulk load" above) targets the same
506+
# tier, so normally there is nothing to change here; this step exists
507+
# to correct drift if someone moved the tier by hand. Must not fail
508+
# the job -- the mirror already succeeded and the guard variable is
509+
# already flipped by the previous step.
464510
branch_addons_url="https://api.supabase.com/v1/projects/${BRANCH_REF}/billing/addons"
465511
512+
# Send no request when the tier already matches. Applying an addon
513+
# restarts the instance, and the deploy this run dispatches would
514+
# then meet a database still coming back. Only a positively read
515+
# matching tier skips: if the read fails, fall through and apply,
516+
# which is what this step has always done.
517+
current_variant=""
518+
if http_code=$(curl -sS -o /tmp/addons_now.json -w '%{http_code}' \
519+
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
520+
"$branch_addons_url") && [[ "$http_code" == 2* ]]; then
521+
current_variant=$(jq -r '.selected_addons[]? | select(.type=="compute_instance") | .variant.id // empty' /tmp/addons_now.json)
522+
fi
523+
if [ "$current_variant" = "$STAGING_COMPUTE_VARIANT" ]; then
524+
echo "branch compute is already ${STAGING_COMPUTE_VARIANT}; leaving it alone"
525+
exit 0
526+
fi
527+
528+
echo "returning branch compute from ${current_variant:-unknown} to ${STAGING_COMPUTE_VARIANT}"
466529
body=$(jq -n --arg addon_type "compute_instance" --arg addon_variant "$STAGING_COMPUTE_VARIANT" \
467530
'{addon_type: $addon_type, addon_variant: $addon_variant}')
468531
http_code=$(curl -sS -o /tmp/addon_apply_down.json -w '%{http_code}' \
@@ -478,6 +541,31 @@ jobs:
478541
fi
479542
echo "compute return to ${STAGING_COMPUTE_VARIANT} accepted (HTTP $http_code)"
480543
544+
- name: Deploy staging against the mirrored branch
545+
shell: bash
546+
# The shared concurrency group serializes this workflow with
547+
# staging-deploy.yml but does not order them: on a push that triggers
548+
# both, a deploy that wins the lock migrates the pre-mirror branch and
549+
# nothing deploys after the mirror. Dispatching here drops that
550+
# dependency on who went first -- the run queues behind this one in
551+
# the same group and deploys once the mirror and secret publish land.
552+
#
553+
# Skipped after a password reset, where STAGING_DATABASE_URL is stale
554+
# until a human refreshes it and this deploy could only fail at
555+
# migrations. It does not spare the tip a red run: a push-triggered
556+
# deploy queued in the same group still fails that way, and correctly
557+
# so, because staging really is not deployed at that commit until the
558+
# secret is refreshed. Skipping here only avoids a second run already
559+
# known to be doomed; staging-deploy.yml names the cause when it hits
560+
# one. success() is spelled out because a custom `if` drops the
561+
# implicit one, and a half-finished mirror must not dispatch.
562+
# Best-effort like "Mark bootstrap done": a token that cannot dispatch
563+
# must not fail a run whose data work is already complete.
564+
if: success() && steps.branch.outputs.password_reset != 'true'
565+
continue-on-error: true
566+
working-directory: ${{ github.workspace }}
567+
run: gh workflow run 'Staging Deploy' --ref staging --repo "$GITHUB_REPOSITORY"
568+
481569
- name: Summary
482570
shell: bash
483571
run: |

0 commit comments

Comments
 (0)