From b498ea8730525a655974a8cd9edcdc27812a43d5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 05:51:10 +0000 Subject: [PATCH 01/11] feat(staging): sync staging from main on the VPS, seed it, document one way in - scripts/staging-sync.mjs applies migrations and deploys changed Edge Functions to brifeyypjauyusmkptkx only, then sets a staging-sync commit status and opens or closes a drift issue. - ops/staging-sync/ holds the user systemd timer that runs it every 5 minutes from a dedicated clone of main, without npm install. - scripts/staging/seed.mjs seeds invented debates, sources and saved results for Pro, trial and expired accounts plus Adam's. It is idempotent. - Protected staging E2E waits for staging-sync on its commit. - PRs that change supabase/ get a note that staging runs main's backend. - docs/staging.md is the one way in; the manual sync procedure is gone. Plan and audit: plans/2026-10-03-staging-loop.md Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/staging-e2e.yml | 25 ++ .github/workflows/staging-preview-note.yml | 34 +++ AGENTS.md | 2 +- docs/README.md | 1 + docs/operations.md | 19 +- docs/staging.md | 76 +++++ docs/testing.md | 2 + .../debatelab-staging-sync.service | 14 + ops/staging-sync/debatelab-staging-sync.timer | 10 + ops/staging-sync/install.sh | 13 + ops/staging-sync/run.sh | 47 +++ plans/2026-10-03-staging-loop.md | 71 +++++ plans/README.md | 2 +- scripts/staging-sync.mjs | 263 +++++++++++++++++ scripts/staging/fixtures.mjs | 271 ++++++++++++++++++ scripts/staging/seed.mjs | 225 +++++++++++++++ src/test/scripts/staging-sync.test.ts | 41 +++ 17 files changed, 1098 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/staging-preview-note.yml create mode 100644 docs/staging.md create mode 100644 ops/staging-sync/debatelab-staging-sync.service create mode 100644 ops/staging-sync/debatelab-staging-sync.timer create mode 100755 ops/staging-sync/install.sh create mode 100755 ops/staging-sync/run.sh create mode 100644 plans/2026-10-03-staging-loop.md create mode 100644 scripts/staging-sync.mjs create mode 100644 scripts/staging/fixtures.mjs create mode 100644 scripts/staging/seed.mjs create mode 100644 src/test/scripts/staging-sync.test.ts diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index a48364bfc..110a23744 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -10,6 +10,8 @@ permissions: # Manual dispatch queries workflow runs for the selected main commit. actions: read contents: read + # Waits for the staging-sync commit status set by the VPS sync. + statuses: read concurrency: group: protected-staging-e2e-main @@ -34,6 +36,29 @@ jobs: persist-credentials: false ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + # The VPS staging sync (docs/staging.md) sets this status once staging's + # migrations and Edge Functions match the commit. Testing before that + # would test the previous backend. + - name: Wait for staging to sync this commit + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + shell: bash + run: | + for _ in $(seq 1 40); do + state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses" \ + --jq '[.[] | select(.context == "staging-sync")][0].state // "missing"')" + case "$state" in + success) echo "Staging is synced with ${SHA}."; exit 0 ;; + failure|error) + echo "::error::Staging sync failed for ${SHA}. See the open 'Staging is out of sync with main' issue." + exit 1 ;; + esac + sleep 30 + done + echo "::error::No staging-sync status for ${SHA} after 20 minutes. Is debatelab-staging-sync.timer running on the VPS? See docs/staging.md." + exit 1 + - name: Verify selected commit passed the quality workflow if: github.event_name == 'workflow_dispatch' env: diff --git a/.github/workflows/staging-preview-note.yml b/.github/workflows/staging-preview-note.yml new file mode 100644 index 000000000..75c857123 --- /dev/null +++ b/.github/workflows/staging-preview-note.yml @@ -0,0 +1,34 @@ +name: Staging preview note + +# Staging runs main's backend (docs/staging.md). A PR that changes supabase/ +# gets a comment saying its staging preview still uses main's migrations and +# Edge Functions until it merges. + +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - "supabase/migrations/**" + - "supabase/functions/**" + +permissions: + pull-requests: write + +jobs: + note: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Comment once + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + shell: bash + run: | + marker="" + if gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq '.[].body' | grep -qF "$marker"; then + exit 0 + fi + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$marker + This PR changes \`supabase/\`. Its **debatelab-staging** preview talks to staging, which runs main's migrations and Edge Functions, so this PR's backend changes are not there yet. The VPS staging sync applies them within about 5 minutes of the merge. To try them before merging, use the \`verify-debatelab\` skill against a local stack. See docs/staging.md." diff --git a/AGENTS.md b/AGENTS.md index 3c3ffbebd..beaec9211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ The local Supabase stack still uses shared default ports and container names. In ## Deployment -The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed by that merge; they are separate authorized operations. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. +The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed to production by that merge; production deploys are separate authorized operations. Staging syncs itself from `main` within about 5 minutes through a VPS timer; [docs/staging.md](docs/staging.md) is the one place for staging access, seed data, and the sync. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. ## Normal ports diff --git a/docs/README.md b/docs/README.md index ecb0de9c4..5786cf79e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ Start with the smallest document that answers the task instead of loading every ## Operate or deploy - [Operations guide](operations.md), current. Open before changing CI, Vercel hosting, staging, CORS, migrations, Edge Function deployment, rollback, or production state. +- [Staging](staging.md), current. How to get into staging, the seeded accounts, and the VPS timer that syncs it with `main`. - [`.github/workflows/`](../.github/workflows/) is the CI source of truth. ## Plans diff --git a/docs/operations.md b/docs/operations.md index 9203a6cfa..d58fa7525 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -63,26 +63,13 @@ The staging browser suite disables trace, screenshot, and video capture. It crea Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass. -`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- `, not this page. The run uploads no artifact, so the job log is the only evidence. +`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- `, not this page. The run uploads no artifact, so the job log is the only evidence. ## Staging sync -Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Every backend change goes to staging first, passes the staging browser suite, and only then goes to production. Steps 1 and 2 are read-only. Steps 3 and 4 change staging and need Adam's authorization each time. +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. -1. Confirm the target. `supabase projects list` must show the staging ref under the staging name. Stop if a command, link, or environment variable points at the production ref. -2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. It also lists expected function secrets the project lacks (names only) and prints the Auth site URL and redirect allowlist. Where that script is not available, run its two read-only queries directly: - - ```bash - supabase migration list --project-ref brifeyypjauyusmkptkx - supabase functions list --project-ref brifeyypjauyusmkptkx - ``` - - A migration with a local version and no remote version is pending. A function whose folder changed (`git log -- supabase/functions/ supabase/functions/_shared`) after its remote `updated_at` is stale. Save both lists as the rollback baseline. -3. Apply migrations. Run `supabase db push --project-ref brifeyypjauyusmkptkx --dry-run` and check that it lists exactly the pending files from step 2. Then run it without `--dry-run`. Never use `--include-all` to paper over divergent history. -4. Deploy functions, one at a time, only the ones step 2 marked stale: `supabase functions deploy --project-ref brifeyypjauyusmkptkx --use-api`. [`supabase/config.toml`](../supabase/config.toml) supplies each function's JWT setting. Never use `--prune`. -5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings and sign in there with a staging demo code. Google and email sign-in on staging work only if the staging Auth redirect URLs include the frontend origin. Since 2026-10-02 they include tailnet previews (`https://codex-remote.tailab0ab3.ts.net:91*/**`); see [development.md](development.md#preview-on-the-tailnet). - -Production then follows [Supabase changes](#supabase-changes) with the same list of migrations and functions. +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. ## Switching the frontend to staging diff --git a/docs/staging.md b/docs/staging.md new file mode 100644 index 000000000..d9061a3c9 --- /dev/null +++ b/docs/staging.md @@ -0,0 +1,76 @@ +# Staging + +**Current.** The one place that says how to reach staging, what is on it, and how it stays in sync with `main`. + +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. + +## Get in + +**On your phone or laptop, for a PR.** Every PR gets a `debatelab-staging` Vercel preview; its link is in the Vercel comment on the PR. That build is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. + +**Sign in.** Two ways: + +- Adam: **Continue with Google** with your own account. The seed made that account an admin on Pro with three debates. +- Agents and tests: a staging demo code. The seeded Pro debater's code is `STAGING_SEED_PRO_CODE` in `~/.config/debatelab/staging-access.env` on the VPS (mode 600). Never paste it into GitHub, docs, or screenshots. + +A production account, demo code, or email code does not work on staging, and a staging one does not work on production. + +**Without a PR.** `npm run dev:preview` runs your working tree pinned to staging on the tailnet ([development.md](development.md#preview-on-the-tailnet)). Use it for uncommitted changes. + +## What is on it + +`node scripts/staging/seed.mjs` creates invented accounts and content. Every row has a fixed id, and a rerun restores the seed without duplicating it. The sync reruns the seed after each new commit on `main`. + +| Account | Plan | Content | +| --- | --- | --- | +| `seed-admin@staging.debatelab.test` | Pro, admin | none; creates the demo code | +| `seed-pro@staging.debatelab.test` | Pro | 3 debates, demo code `STAGING_SEED_PRO_CODE` | +| `seed-trial@staging.debatelab.test` | Trial, 5 days left at seeding | 1 debate | +| `seed-expired@staging.debatelab.test` | Expired trial | 1 debate | +| `STAGING_OWNER_EMAIL` (Adam's Google address) | Pro, admin | 3 debates | + +The debates cover private cars in city centres, platform liability for misinformation, and standardised testing. Each has two or three sources and two or three saved results. Between them they cover cases, attacks, brainstorms, and factoids, and the liability debate also has an Ask chat. Seeded ids start with `5eed`. The fixtures live in [`scripts/staging/fixtures.mjs`](../scripts/staging/fixtures.mjs); all of the text is made up. + +Only the trial and expired accounts lack demo codes. To try them, create a code in the staging Admin page as Adam and bind it to their email. + +Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing portal, `stripe-webhook`, and the Stripe part of account deletion fail on staging, and voice rounds fall back to the browser voice. AI requests and web research use staging's own OpenRouter and Exa keys. + +## How it stays in sync + +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: + +1. checks that commit out in a dedicated clone at `~/ops/debatelab-staging-sync/repo` (it never runs `npm install`); +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; +3. reruns the seed; +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. + +An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. + +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing. If no status arrives within 20 minutes, it fails with a pointer to this page. + +PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. + +### The credential and its blast radius + +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`. + +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. + +### Operate it + +```bash +systemctl --user list-timers debatelab-staging-sync.timer # next run +journalctl --user -u debatelab-staging-sync -n 100 --no-pager # last runs +systemctl --user start debatelab-staging-sync.service # sync now +node scripts/staging-sync.mjs --dry-run # what a sync would do, from your checkout +npm run backend:status -- --project-ref brifeyypjauyusmkptkx # read-only drift report +bash ops/staging-sync/install.sh # after changing run.sh or the units +``` + +The unit's `PATH` names `~/.nvm/versions/node/v24.20.0/bin`, where `node` and `supabase` live. If nvm drops that version, the service fails before it can report, so update the unit and rerun `install.sh`. State lives in `~/ops/debatelab-staging-sync/state/` (`last-ok`, `last-failed`); delete a file to force a rerun. + +## Rules + +- Production is never a fallback. When staging access fails, stop. +- Staging browser tests create uniquely named data and delete only what they created ([testing.md](testing.md#protected-staging)). +- `VERCEL_STAGING_BYPASS` (GitHub `staging` environment, and the VPS access file) lets CI and agents open protected `debatelab-staging` previews with the `x-vercel-protection-bypass` header. Treat it like the demo codes. diff --git a/docs/testing.md b/docs/testing.md index 0c446771b..f9c6a080c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -271,6 +271,8 @@ This is the Safari-engine smoke gate. State explicitly when it did not run. ## Protected staging +To get into staging by hand, or to see what is seeded there, read [staging.md](staging.md). The suite tests whatever backend staging runs, and in CI it waits for the `staging-sync` status on its commit first. + Staging requires all three protected values: - `STAGING_SUPABASE_URL` diff --git a/ops/staging-sync/debatelab-staging-sync.service b/ops/staging-sync/debatelab-staging-sync.service new file mode 100644 index 000000000..0eb0b8b2b --- /dev/null +++ b/ops/staging-sync/debatelab-staging-sync.service @@ -0,0 +1,14 @@ +[Unit] +Description=Sync DebateLab staging Supabase with main +Documentation=https://github.com/Programcode/DebateLab/blob/main/docs/staging.md +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=%h/ops/debatelab-staging-sync/run.sh +Environment=PATH=%h/.nvm/versions/node/v24.20.0/bin:/usr/local/bin:/usr/bin:/bin +UMask=0077 +TimeoutStartSec=30min +NoNewPrivileges=yes +PrivateTmp=yes diff --git a/ops/staging-sync/debatelab-staging-sync.timer b/ops/staging-sync/debatelab-staging-sync.timer new file mode 100644 index 000000000..21897a8f4 --- /dev/null +++ b/ops/staging-sync/debatelab-staging-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check DebateLab main for staging sync every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ops/staging-sync/install.sh b/ops/staging-sync/install.sh new file mode 100755 index 000000000..32f88dab9 --- /dev/null +++ b/ops/staging-sync/install.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Installs or updates the staging sync timer on Adam's VPS. Rerun after +# changing run.sh or the unit files. The sync scripts themselves come from main. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +base="$HOME/ops/debatelab-staging-sync" +units="$HOME/.config/systemd/user" +mkdir -p "$base" "$units" +install -m 755 "$here/run.sh" "$base/run.sh" +install -m 644 "$here/debatelab-staging-sync.service" "$here/debatelab-staging-sync.timer" "$units/" +systemctl --user daemon-reload +systemctl --user enable --now debatelab-staging-sync.timer +systemctl --user list-timers debatelab-staging-sync.timer --no-pager diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh new file mode 100755 index 000000000..7edda2dfa --- /dev/null +++ b/ops/staging-sync/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Runs on Adam's VPS from debatelab-staging-sync.timer. Keeps a dedicated clone +# of main and syncs staging whenever main moves, plus an hourly drift check. +# Never runs npm install: the sync scripts have no dependencies, so merged code +# only reaches the Supabase CLI as files to upload, never as packages to run. +set -euo pipefail + +BASE="${DEBATELAB_STAGING_SYNC_HOME:-$HOME/ops/debatelab-staging-sync}" +REPO="$BASE/repo" +STATE="$BASE/state" +mkdir -p "$STATE" + +exec 9>"$BASE/lock" +flock -n 9 || { echo "Another sync is running."; exit 0; } + +if [ ! -d "$REPO/.git" ]; then + git clone --quiet https://github.com/Programcode/DebateLab.git "$REPO" +fi +git -C "$REPO" fetch --quiet origin main +sha="$(git -C "$REPO" rev-parse origin/main)" + +# Skip when this commit already synced within the hour, or failed within the hour. +for marker in ok failed; do + if [ "$(cat "$STATE/last-$marker" 2>/dev/null)" = "$sha" ] && [ -n "$(find "$STATE/last-$marker" -mmin -60)" ]; then + exit 0 + fi +done + +git -C "$REPO" checkout --quiet --force --detach "$sha" +git -C "$REPO" clean -fdq + +if [ ! -f "$REPO/scripts/staging-sync.mjs" ]; then + echo "main ($sha) has no scripts/staging-sync.mjs yet; nothing to do." + exit 0 +fi + +seed=() +[ "$(cat "$STATE/last-ok" 2>/dev/null)" = "$sha" ] || seed=(--seed) + +if node "$REPO/scripts/staging-sync.mjs" --root "$REPO" --report "${seed[@]}"; then + echo "$sha" > "$STATE/last-ok" + rm -f "$STATE/last-failed" +else + status=$? + echo "$sha" > "$STATE/last-failed" + exit "$status" +fi diff --git a/plans/2026-10-03-staging-loop.md b/plans/2026-10-03-staging-loop.md new file mode 100644 index 000000000..b306fdd38 --- /dev/null +++ b/plans/2026-10-03-staging-loop.md @@ -0,0 +1,71 @@ +# Staging that tracks main: audit and plan + +**Status:** Plan, in progress. Written 2026-10-03 against `main` at `34d1573`. The sync, seed, and docs ship in the PR that adds this file. Previews ship in #95 and the staging spec fix in #100. The follow-ups at the end are open. + +Goal (Adam, 2026-10-02): when an agent opens a PR, Adam can try the change within seconds on his phone, against a backend that matches, with realistic data, and nothing there can touch production. + +## Audit of staging on 2026-10-02 + +Project `debate-trainer-staging` (`brifeyypjauyusmkptkx`), Free plan. + +**Backend drift.** Staging lacked 1 migration and 11 Edge Function deploys from `main`. `workspace-invites` was also undeployed and stays that way, because the Workspace is hidden and production doesn't run it. The cause was process, not tooling: `docs/operations.md` required Adam's authorization for every staging migration or deploy, so nobody did them. The backlog was cleared on 2026-10-02, and `npm run backend:status -- --project-ref brifeyypjauyusmkptkx` showed nothing pending at `34d1573`. + +**Secrets (names only).** Present: everything AI and research need. Missing: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `ELEVENLABS_API_KEY`. + +**Auth.** The Site URL was `http://localhost:8080`. The redirect allowlist held localhost, the production domain, and tailnet previews, but no hosted staging frontend. The #95 thread has since set the Site URL to `https://debatelab-staging.vercel.app` and added the `debatelab-staging` preview and production hosts. Google is enabled on staging. + +**How the frontend reached it.** There were two ways: +- An admin-only localStorage switch in Settings (#81), which clears local state across tabs on every flip. +- `npm run dev:preview` tailnet previews (#92). + +Vercel PR previews used production, and production's Auth allowlist had no `vercel.app` host, so signing in on a preview landed on the live site. + +**Data.** Nothing realistic. The newest source not written by a test was from 2026-05-27. + +**What the docs promised versus what held.** +- `operations.md` said previews use production unless toggled. True, and that was the problem. +- It said every backend change goes to staging first and passes the staging suite before production. Not true in practice: staging lagged, so production was often ahead of it. +- It said `Protected staging E2E` runs after each successful main `Quality`. It ran, but it had failed on every real run since 2026-09-23. #72 disabled **Add Source** until a Current Debate exists, and the spec never starts one. #100 fixes the spec. The lagging backend was real but did not cause these failures. +- `testing.md`'s staging contract (unique data, deletes only its own, no captures) holds. + +## Decisions + +Adam answered these on 2026-10-02: + +1. **The sync credential stays on the VPS.** A systemd timer uses the Supabase CLI token already there. Nothing new leaves the box. The cost is blast radius: that token reaches production, and the timer runs whatever `main` contains. See [docs/staging.md](../docs/staging.md#the-credential-and-its-blast-radius). +2. **Previews stay behind Vercel login,** with a Protection Bypass for Automation secret for CI and agents (`VERCEL_STAGING_BYPASS`, created 2026-10-03 on the `debatelab-staging` project). +3. **Staging is shared and equals `main`.** Per-PR database branches need the Pro plan, which is a billing change, so they are not adopted. A PR that changes `supabase/` gets a comment saying its backend reaches staging only on merge. +4. **Adam signs in with Google; agents use demo codes.** + +## What ships + +In this PR: +- `scripts/staging-sync.mjs`: refuses any ref but staging, applies migrations, deploys changed functions, rechecks, and reports. It sets a `staging-sync` commit status and manages a drift issue. +- `ops/staging-sync/`: the timer, the service, `run.sh` (a dedicated clone of `main`, no `npm install`), and `install.sh`. It has been installed on the VPS since 2026-10-03. +- `scripts/staging/seed.mjs` and `fixtures.mjs`: four seeded accounts on Pro, trial, and expired plans, plus Adam's account. Three invented debates with sources and saved results. Fixed ids, idempotent. +- `Protected staging E2E` waits for `staging-sync` success on its commit, so it never tests a stale backend. +- `staging-preview-note.yml`: the PR comment from decision 3. +- `docs/staging.md`: the one way in. `operations.md` and `testing.md` point to it. + +In #95: `VITE_SUPABASE_ENV` pins a build to a project, the `debatelab-staging` Vercel project builds every PR pinned to staging, and Edge Function CORS accepts its preview hosts on staging. + +In #100: the staging spec starts and cleans up its own Current Debate. + +## What to delete, not polish + +- **The manual staging sync procedure** in `operations.md`. The timer replaces it, so this PR deletes it. +- **The admin localStorage switch in Settings and `e2e/staging-switch.spec.ts`.** Once #95 merges, `https://debatelab-staging.vercel.app` and per-PR staging previews cover every use. The switch shares persisted stores between two projects and needs cross-tab clearing to stay safe, which is a lot of code for a convenience. Remove it in a follow-up after #95 merges (both touch `src/lib/supabase/env.ts`). Keep the staging strip that pinned builds show. +- **Stripe and ElevenLabs on staging.** Not worth provisioning. Billing is proven in Stripe test mode and voice falls back to the browser voice. If account deletion ever needs proof on staging, it needs a Stripe test-mode key, and that is a new secret Adam must approve. + +## Verification plan + +1. Before merge: the sync's dry run against staging, the production-ref refusal (unit test plus a real run with `SUPABASE_PROJECT_ID` set to production), and two seed runs with identical seeded row counts. +2. On merge of this PR: the timer picks up the merge commit within 5 minutes, sets `staging-sync` to success, and reseeds. +3. On merge of #95: its `cors.ts` change reaches staging through the timer with nobody running a deploy. This is the proof for "the next merge syncs staging by itself". After that, open a `debatelab-staging` preview on a phone-sized viewport and sign in with the seeded Pro code. +4. On merge of #100: `Protected staging E2E` passes against the synced backend. + +## Follow-ups + +- Replace the classic token with a staging-scoped Supabase token (Adam creates it; the timer reads it as `SUPABASE_ACCESS_TOKEN`). +- Delete the Settings staging switch after #95. +- Make the unit's Node path survive nvm upgrades. diff --git a/plans/README.md b/plans/README.md index 4ad4eea3f..77f8f1373 100644 --- a/plans/README.md +++ b/plans/README.md @@ -6,7 +6,7 @@ Every plan lives in this folder and opens with a `**Status:**` line; a plan list ## In progress -Nothing is in progress. +- **Staging that tracks main.** Audit of staging, the VPS sync timer, the seed, and what to delete. Plan: [2026-10-03 staging loop](2026-10-03-staging-loop.md). Previews come from #95 and the staging spec fix from #100. ## Backlog diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs new file mode 100644 index 000000000..f17429f24 --- /dev/null +++ b/scripts/staging-sync.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// Brings the staging Supabase project up to the committed tree: applies +// pending migrations and deploys new or changed Edge Functions, then checks +// that nothing is left. Staging only: the project ref is fixed below and every +// Supabase CLI call passes it explicitly, so a linked checkout or a +// SUPABASE_PROJECT_ID pointing elsewhere cannot redirect it. +// +// The VPS timer in ops/staging-sync/ runs this on every new commit on main. +// See docs/staging.md. +// +// Usage: node scripts/staging-sync.mjs [--root ] [--dry-run] [--report] [--seed] +// --root checkout to read (default: the git top level of the cwd) +// --dry-run show what would change; change nothing +// --report set the `staging-sync` commit status on GitHub and open or close +// the drift issue. The staging browser suite waits for that status. +// --seed run scripts/staging/seed.mjs after a successful sync +// +// Exit codes: 0 in sync, 1 drift remains or a step failed, 2 refused or misused. + +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { backendStatus } from "./backend-status.mjs"; + +export const STAGING_REF = "brifeyypjauyusmkptkx"; +export const STAGING_NAME = "debate-trainer-staging"; +export const PRODUCTION_REF = "uziwdtkzofptihahfooo"; +export const GITHUB_REPO = "Programcode/DebateLab"; +const STATUS_CONTEXT = "staging-sync"; +const DRIFT_ISSUE_TITLE = "Staging is out of sync with main"; + +/** Collaborative Workspace is hidden and production doesn't run it, so staging doesn't either. */ +export const SKIPPED_FUNCTIONS = new Set(["workspace-invites"]); + +class RefusedError extends Error {} + +export function parseArgs(argv) { + const options = { root: null, dryRun: false, report: false, seed: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--report") options.report = true; + else if (arg === "--seed") options.seed = true; + else if (arg === "--root" && argv[index + 1]) options.root = argv[++index]; + else throw new RefusedError(`Unknown argument: ${arg}`); + } + return options; +} + +function run(command, args, { cwd, input } = {}) { + const result = spawnSync(command, args, { cwd, input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw new Error(`${command} ${args[0]} failed: ${result.error.message}`); + return result; +} + +function quiet(text) { + return (text ?? "") + .split("\n") + .filter((line) => !/new version of Supabase CLI|recommend updating regularly|^\s*$/i.test(line)) + .join("\n") + .replace(/(postgres(?:ql)?:\/\/)[^@\s]+@/gi, "$1***@") + .trim(); +} + +function supabase(root, args) { + const result = run("supabase", [...args, "--project-ref", STAGING_REF], { cwd: root }); + const output = quiet(`${result.stdout}\n${result.stderr}`); + if (result.status !== 0) throw new Error(`supabase ${args.slice(0, 2).join(" ")} failed:\n${output}`); + return output; +} + +/** Refuses to run unless the CLI sees the staging ref under the staging name. */ +export function assertStagingTarget(projects, env = process.env) { + const override = env.SUPABASE_PROJECT_ID?.trim(); + if (override && override !== STAGING_REF) { + throw new RefusedError(`SUPABASE_PROJECT_ID is ${override}; this script only targets staging (${STAGING_REF}).`); + } + if (STAGING_REF === PRODUCTION_REF) throw new RefusedError("Staging and production refs are the same."); + const staging = projects.find((project) => (project.ref ?? project.id) === STAGING_REF); + if (!staging) throw new RefusedError(`The Supabase account cannot see ${STAGING_REF}.`); + if (staging.name !== STAGING_NAME) { + throw new RefusedError(`${STAGING_REF} is named "${staging.name}", expected "${STAGING_NAME}".`); + } +} + +/** What the sync has to do, from a backend-status result. */ +export function planSync(result) { + const deploy = new Set([ + ...result.notDeployed, + ...result.stale.map((item) => item.name), + ...result.verifyJwt.map((item) => item.name), + ]); + for (const name of SKIPPED_FUNCTIONS) deploy.delete(name); + return { + divergent: result.unknownMigrations, + migrations: result.pendingMigrations, + functions: [...deploy].sort(), + undeclared: result.notInRepo.filter((name) => !SKIPPED_FUNCTIONS.has(name)), + }; +} + +function lastJsonLine(text) { + for (const line of text.split("\n").reverse()) { + try { + return JSON.parse(line); + } catch { + // keep looking + } + } + return null; +} + +function sameList(a, b) { + return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index]); +} + +export function sync({ root, dryRun, log = console.log }) { + const before = backendStatus({ root, projectRef: STAGING_REF }); + const plan = planSync(before.result); + log(before.report, ""); + + if (plan.divergent.length > 0) { + throw new Error(`Staging has migrations this commit lacks (${plan.divergent.join(", ")}). Not pushing over divergent history; fix it by hand.`); + } + if (plan.migrations.length === 0 && plan.functions.length === 0) { + log("Staging already matches this commit."); + return { plan, applied: [], deployed: [] }; + } + + const applied = []; + if (plan.migrations.length > 0) { + const dry = supabase(root, ["db", "push", "--dry-run"]); + const wouldPush = (lastJsonLine(dry)?.migrations ?? []).map((file) => file.split("_")[0]); + if (!sameList(wouldPush, plan.migrations)) { + throw new Error(`db push would apply [${wouldPush.join(", ")}] but the pending list is [${plan.migrations.join(", ")}]. Stopping.`); + } + if (dryRun) log(`Would apply migrations: ${plan.migrations.join(", ")}`); + else { + log(`Applying migrations: ${plan.migrations.join(", ")}`); + supabase(root, ["db", "push", "--yes"]); + applied.push(...plan.migrations); + } + } + + const deployed = []; + for (const name of plan.functions) { + if (dryRun) { + log(`Would deploy ${name}`); + continue; + } + log(`Deploying ${name}`); + supabase(root, ["functions", "deploy", name, "--use-api"]); + deployed.push(name); + } + if (dryRun) return { plan, applied, deployed }; + + const after = backendStatus({ root, projectRef: STAGING_REF }); + const left = planSync(after.result); + if (left.migrations.length > 0 || left.functions.length > 0 || left.divergent.length > 0) { + log(after.report); + throw new Error(`Still pending after sync: ${[...left.migrations, ...left.functions].join(", ")}`); + } + log(`Staging matches ${after.report.split("\n")[0].split(" ").pop()}.`); + return { plan, applied, deployed }; +} + +// --- GitHub reporting ------------------------------------------------------- + +function gh(args, input) { + const result = run("gh", args, { input }); + if (result.status !== 0) throw new Error(`gh ${args.slice(0, 2).join(" ")} failed: ${result.stderr.trim()}`); + return result.stdout; +} + +function setStatus(sha, state, description) { + gh([ + "api", `repos/${GITHUB_REPO}/statuses/${sha}`, + "-f", `state=${state}`, + "-f", `context=${STATUS_CONTEXT}`, + "-f", `description=${description.slice(0, 139)}`, + ]); +} + +function openDriftIssue() { + const found = JSON.parse(gh([ + "issue", "list", "--repo", GITHUB_REPO, "--state", "open", "--search", `"${DRIFT_ISSUE_TITLE}" in:title`, + "--json", "number,title", + ])); + return found.find((issue) => issue.title === DRIFT_ISSUE_TITLE)?.number ?? null; +} + +export function report({ sha, ok, summary, detail }) { + setStatus(sha, ok ? "success" : "failure", summary); + const issue = openDriftIssue(); + if (ok) { + if (issue) gh(["issue", "close", String(issue), "--repo", GITHUB_REPO, "--comment", `Staging synced at ${sha}. ${summary}`]); + return; + } + const body = `Staging sync failed at ${sha}.\n\n${summary}\n\n\`\`\`\n${detail.slice(-6000)}\n\`\`\`\n\nLogs: \`journalctl --user -u debatelab-staging-sync\` on the VPS. See docs/staging.md.`; + if (!issue) { + gh(["issue", "create", "--repo", GITHUB_REPO, "--title", DRIFT_ISSUE_TITLE, "--body-file", "-"], body); + return; + } + // One comment per failing commit, not one per retry. + const comments = gh(["issue", "view", String(issue), "--repo", GITHUB_REPO, "--json", "comments", "--jq", ".comments[].body"]); + if (!comments.includes(sha)) gh(["issue", "comment", String(issue), "--repo", GITHUB_REPO, "--body-file", "-"], body); +} + +function main() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exit(2); + } + const root = options.root + ?? run("git", ["rev-parse", "--show-toplevel"]).stdout.trim(); + const sha = run("git", ["rev-parse", "HEAD"], { cwd: root }).stdout.trim(); + + try { + const listed = run("supabase", ["projects", "list", "--output-format", "json"], { cwd: root }); + if (listed.status !== 0) throw new RefusedError(`supabase projects list failed: ${quiet(listed.stderr)}`); + const parsed = JSON.parse(listed.stdout); + assertStagingTarget(Array.isArray(parsed) ? parsed : parsed.projects ?? []); + const dirty = run("git", ["status", "--porcelain", "--", "supabase"], { cwd: root }).stdout.trim(); + if (dirty) throw new RefusedError("Uncommitted changes under supabase/. Sync only from a clean commit."); + } catch (error) { + console.error(`Refused: ${error.message}`); + process.exit(2); + } + + const lines = []; + const log = (...parts) => { + const text = parts.join("\n"); + lines.push(text); + console.log(text); + }; + try { + const { applied, deployed } = sync({ root, dryRun: options.dryRun, log }); + if (options.seed && !options.dryRun) { + const seeded = run("node", [join(root, "scripts/staging/seed.mjs")], { cwd: root }); + log(quiet(seeded.stdout), quiet(seeded.stderr)); + if (seeded.status !== 0) throw new Error("Seeding staging failed."); + } + const summary = applied.length + deployed.length === 0 + ? `In sync with ${sha.slice(0, 7)}.` + : `Applied ${applied.length} migration(s), deployed ${deployed.length} function(s) at ${sha.slice(0, 7)}.`; + if (options.report && !options.dryRun) report({ sha, ok: true, summary, detail: lines.join("\n") }); + } catch (error) { + console.error(error.message); + if (options.report && !options.dryRun) { + try { + report({ sha, ok: false, summary: error.message.split("\n")[0], detail: `${lines.join("\n")}\n${error.message}` }); + } catch (reportError) { + console.error(`Reporting failed too: ${reportError.message}`); + } + } + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/staging/fixtures.mjs b/scripts/staging/fixtures.mjs new file mode 100644 index 000000000..af4aaa491 --- /dev/null +++ b/scripts/staging/fixtures.mjs @@ -0,0 +1,271 @@ +// Invented content for the staging seed (scripts/staging/seed.mjs). Every +// person, publication, figure and URL here is made up. Row ids are fixed so a +// rerun updates rows instead of adding copies. Output shapes follow what the +// client saves and renders: GenerateOutput, AttackOutput, BrainstormOutput +// (src/lib/ai/schemas/) and FactoidsOutput (src/lib/ai/schemas/factoids.ts). + +const id = (n) => `5eed0000-0000-4000-8000-${String(n).padStart(12, "0")}`; + +const contention = (n, claim, reasoning, evidence, impact) => ({ + id: String(n), claim, reasoning, evidence, needsSource: false, impact, +}); + +const idea = (n, side, title, description, strength = "strong") => ({ + id: `i${n}`, title, description, side, strength, tradeoffs: [], relatedTo: [], +}); + +const factoid = (n, shortClaim, keyFigure, context, publisher, url, relevanceScore) => ({ + id: `f${n}`, + shortClaim, + keyFigure, + context, + limitations: "Single fictional study; treat as illustrative.", + citation: { title: shortClaim, publisher, date: "2026-03-14", url, verified: "verified" }, + howToUse: "Use it as the impact link in your second contention.", + relevanceScore, +}); + +const source = (n, title, publication, url, paragraphs) => ({ + id: id(n), + title, + type: "url", + origin: url, + folder: "General", + is_active: true, + metadata: { url, sourceType: "news", qualityLabel: "moderate", capturedVia: "staging-seed" }, + content: [`${title}\n${publication}`, ...paragraphs].join("\n\n"), +}); + +export const DEBATES = [ + { + id: id(1), + motion: "This House would ban private cars from city centres", + settings: { format: "bp", role: "r1" }, + instructions: "Opening Government. Keep the case on public health and street space; avoid the climate framing, the judges have heard it all season.", + sources: [ + source(101, "Car-free cores: what three mid-sized cities learned", "The Northfield Ledger (fictional)", "https://example.com/northfield/car-free-cores", [ + "When the fictional city of Ardmoor closed its historic core to private cars in 2021, shop owners predicted ruin. Three years on, the city's own footfall counters show weekday visits up 18 percent, with the strongest growth in cafes and small grocers.", + "The picture is not uniformly positive. Deliveries now cluster in a two-hour morning window, and disability advocates say the shuttle service that replaced door-to-door access runs too rarely after 7 pm.", + "Planners in Ardmoor and its neighbours agree on one lesson: the ban worked where frequent buses and protected cycle lanes arrived before the closure, not after it.", + ]), + source(102, "Air quality after the Ardmoor closure", "Institute for Urban Health Studies (fictional)", "https://example.org/iuhs/ardmoor-air", [ + "Nitrogen dioxide readings at four central monitoring stations fell by an average of 31 percent in the first year after private cars were excluded, against 6 percent at comparable stations outside the zone.", + "Hospital admissions for childhood asthma in the central postcode area fell from 112 to 87 over the same period. The authors caution that the sample is small and that admissions were already trending down.", + ]), + source(103, "The hidden cost of car-free zones", "Metro Business Review (fictional)", "https://example.com/mbr/hidden-cost", [ + "Retail landlords on the edge of car-free districts report vacancy rates two to three points higher than before the policy, as larger stores relocate to out-of-town parks with free parking.", + "Tradespeople are the loudest critics. A plumber carrying 40 kilograms of tools cannot take the tram, and permit schemes for commercial vans have become a paperwork burden.", + ]), + ], + results: { + cases: [{ + id: id(201), + title: "Car-free centres: OG case", + side: "pro", + output: { + caseStructure: { + thesis: "City centres belong to the people who use them on foot, and private cars are the single largest obstacle to that.", + framework: "Weigh public health and equal access to shared space against the convenience of a minority of drivers.", + keyDefinitions: ["City centre: the historic commercial core, roughly the area inside the inner ring road.", "Private car: privately owned passenger vehicles; buses, taxis, emergency and delivery vehicles are exempt."], + burdenOfProof: "Show that the health and access gains outweigh the cost to drivers and businesses, with a workable transition.", + }, + contentions: [ + contention(1, "Removing cars cuts air pollution where the most people breathe it.", "Central streets concentrate both traffic and pedestrians, so exposure falls fastest there.", "Ardmoor's central stations recorded a 31 percent drop in nitrogen dioxide in year one (Institute for Urban Health Studies, fictional).", "Fewer childhood asthma admissions and better long-term lung health."), + contention(2, "Street space is a public good that cars monopolise.", "A parked car occupies roughly 12 square metres for most of the day while moving almost nobody.", "Ardmoor turned 40 percent of its former parking into seating, cycle lanes and market space.", "Space is redistributed to the majority who arrive on foot, by bike or by bus."), + contention(3, "Car-free cores revive local commerce.", "Pedestrians linger and make more frequent small purchases than drivers.", "Weekday footfall in Ardmoor rose 18 percent after the closure.", "Small businesses gain at the expense of out-of-town retail parks."), + ], + predictedOpponentCase: { + likelyArguments: ["Disabled and elderly residents lose door-to-door access.", "Tradespeople and businesses face higher costs.", "Displacement just moves congestion to the ring road."], + strategicWeaknesses: ["Opposition must defend the status quo of pollution in the densest areas.", "Access concerns are answerable with exemptions and shuttles."], + keyClashes: ["Who bears the cost of the transition.", "Whether exemptions make the ban unworkable."], + }, + counters: [{ + targetArgument: "Accessibility", + counterpoint: "Blue-badge holders, taxis and on-demand shuttles are exempt in our model.", + rebuttal: "Today's car-dominated streets are already hostile to wheelchair users; wider pavements help them most.", + }], + metadata: { side: "pro", format: "bp", generatedAt: "2026-09-02T09:14:00.000Z" }, + }, + }], + attacks: [{ + id: id(211), + title: "Stress test: the commerce contention", + side: "pro", + output: { + overallAssessment: { + score: 64, + summary: "The health line is solid, but the commerce contention leans on one city and invites an easy counterexample.", + strengthAreas: ["Clear framework", "Concrete pollution evidence"], + }, + weaknesses: [{ + id: "w1", + severity: "major", + type: "evidential", + description: "Footfall is not the same as spending.", + whyWeak: "Opposition can grant more visitors and still argue that revenue moved to retail parks.", + howOpponentAttacks: "Cite the vacancy rates on the zone's edge from Metro Business Review.", + improvedVersion: "Pair footfall with turnover data for independent shops, and concede that large-format retail moves out.", + }, { + id: "w2", + severity: "minor", + type: "logical", + description: "The space argument assumes freed space is used well.", + whyWeak: "Empty plazas are a common failure mode.", + howOpponentAttacks: "Ask what happens to the space in winter.", + improvedVersion: "Commit to a mechanism: market licences and seating funded from the old parking revenue.", + }], + fallaciesDetected: [{ name: "Hasty generalisation", location: "Contention 3", explanation: "One fictional city's footfall is extended to every city centre." }], + crossExQuestions: [{ question: "Where does the plumber park?", purpose: "Expose the commercial access gap.", expectedTrap: "A promise of permits without a cap on the paperwork." }], + weighingAnalysis: { + currentWeighing: "Health is asserted to outweigh business cost without comparing magnitudes.", + missingComparisons: ["Hospital costs avoided versus revenue lost."], + improvementSuggestions: ["Weigh on reversibility: lost health is permanent, lost trade can return."], + }, + }, + }], + factoids: [{ + id: id(221), + title: "Evidence: pollution and footfall", + side: "pro", + output: { + factoids: [ + factoid(1, "Central NO2 fell sharply after the Ardmoor closure", "31% drop in year one", "Average across four central monitoring stations, against 6% outside the zone.", "Institute for Urban Health Studies (fictional)", "https://example.org/iuhs/ardmoor-air", 92), + factoid(2, "Weekday footfall rose in the car-free core", "+18% weekday visits", "City footfall counters, three years after the closure.", "The Northfield Ledger (fictional)", "https://example.com/northfield/car-free-cores", 81), + ], + searchSuggestions: ["turnover of independent shops in car-free zones"], + warnings: [], + verificationStatus: "complete", + }, + }], + brainstorms: [], + }, + chats: [], + }, + { + id: id(2), + motion: "This House believes that social media companies should be legally liable for misinformation on their platforms", + settings: { format: "policy", role: "general" }, + instructions: "We are Opposition. The judge is a law student, so expect questions on mechanism and free expression.", + sources: [ + source(111, "Liability regimes compared", "Comparative Media Law Quarterly (fictional)", "https://example.org/cmlq/liability-regimes", [ + "Of the twelve fictional jurisdictions surveyed, the four that introduced platform liability for false content saw takedown volumes rise fivefold within a year. Independent audits found that roughly a third of removed posts were lawful opinion or satire.", + "Smaller platforms responded differently from large ones: two exited the regulated markets entirely, citing compliance costs equal to a quarter of their revenue.", + ]), + source(112, "Who decides what is false?", "The Civic Review (fictional)", "https://example.com/civic-review/who-decides", [ + "Fact-checking bodies disagree with each other more often than the public assumes. In a fictional panel study, three established fact-checkers reached the same verdict on only 61 percent of contested health claims.", + "When liability attaches to the platform, the cheapest compliance strategy is to remove anything contested, the author argues.", + ]), + ], + results: { + cases: [{ + id: id(231), + title: "Opposition case: liability chills speech", + side: "con", + output: { + caseStructure: { + thesis: "Making platforms liable for misinformation hands private companies a legal incentive to silence anything contested.", + framework: "Protect the conditions for open public debate; weigh harms by who decides what counts as false.", + keyDefinitions: ["Legally liable: exposed to civil damages or regulatory fines for user-posted false content."], + burdenOfProof: "Show that liability causes more harm to legitimate speech than it prevents in misinformation.", + }, + contentions: [ + contention(1, "Liability drives over-removal.", "Platforms face fines for leaving content up but nothing for taking it down.", "Takedowns rose fivefold under liability regimes, with a third of removals lawful (Comparative Media Law Quarterly, fictional).", "Lawful dissent, satire and minority views disappear first."), + contention(2, "Truth is contested, and liability freezes one answer.", "Today's misinformation is sometimes tomorrow's consensus.", "Fact-checkers agreed on only 61 percent of contested health claims (The Civic Review, fictional).", "Science and politics lose the ability to correct themselves in public."), + ], + predictedOpponentCase: { + likelyArguments: ["Misinformation causes real deaths, for example vaccine hesitancy.", "Platforms profit from engagement with false content."], + strategicWeaknesses: ["Government must define misinformation precisely enough to litigate."], + keyClashes: ["Over-removal versus under-removal.", "Who holds the definitional power."], + }, + counters: [{ + targetArgument: "Platforms profit from falsehood", + counterpoint: "Fix the incentive directly with transparency and algorithmic audits.", + rebuttal: "Liability punishes the speech, not the amplification model.", + }], + metadata: { side: "con", format: "policy", generatedAt: "2026-09-08T16:40:00.000Z" }, + }, + }], + brainstorms: [{ + id: id(241), + title: "Brainstorm: platform liability", + side: "both", + output: { + needsMoreInfo: false, + topFive: [ + { rank: 1, idea: idea(1, "con", "Collateral censorship", "Intermediaries remove more than the law requires because the costs of error are one-sided."), whyTop: "It is the core mechanism and hard for Government to deny." }, + { rank: 2, idea: idea(2, "pro", "Engagement profits from falsehood", "Ranking systems reward outrage, and outrage correlates with falsehood."), whyTop: "Government's strongest moral claim; prep a response." }, + { rank: 3, idea: idea(3, "con", "Market exit of small platforms", "Compliance costs entrench the largest incumbents."), whyTop: "Turns Government's anti-big-tech instinct against them." }, + { rank: 4, idea: idea(4, "pro", "Notice-and-action is already normal", "Copyright law already makes platforms act on notice.", "moderate"), whyTop: "Government's precedent argument." }, + { rank: 5, idea: idea(5, "con", "Definitional capture", "Whoever writes the definition of misinformation gains a censorship tool.", "moderate"), whyTop: "Strong in front of a law-trained judge." }, + ], + nextSteps: ["Find a comparative study on over-removal.", "Prepare an answer to the copyright precedent."], + warningsAndTradeoffs: ["Don't defend misinformation itself; attack the mechanism."], + }, + }], + attacks: [], + factoids: [], + }, + chats: [{ + id: id(251), + title: "How to answer the vaccine example", + side: "con", + messages: [ + { id: id(252), role: "user", content: "Government will open with vaccine misinformation causing deaths. How do we answer without sounding callous?" }, + { id: id(253), role: "assistant", content: "Grant the harm early and move the clash to mechanism. Say: 'We agree vaccine falsehoods kill. The question is whether fining platforms reduces them or just removes everything near the topic, including doctors raising legitimate side-effect questions.' Then use the over-removal figure from your first contention." }, + { id: id(254), role: "user", content: "And if they say transparency rules are too weak?" }, + { id: id(255), role: "assistant", content: "Ask what evidence shows liability outperforms transparency. Their burden is comparative: they need liability to beat the best alternative, not the status quo." }, + ], + }], + }, + { + id: id(3), + motion: "This House believes that standardised testing does more harm than good", + settings: { format: "classroom", role: "general" }, + instructions: "Practice debate for the Year 10 club. Keep language accessible.", + sources: [ + source(121, "Teaching to the test: a five-year classroom study", "Journal of School Practice (fictional)", "https://example.org/jsp/teaching-to-the-test", [ + "Across 48 fictional secondary schools, teachers in high-stakes testing years spent an average of 11 weeks per year on direct exam preparation, compared with 4 weeks in years without national tests.", + "Pupils' scores on the national test rose, but their performance on an unseen problem-solving task did not change.", + ]), + source(122, "Tests as a safeguard for disadvantaged pupils", "Education Equity Forum (fictional)", "https://example.com/eef/tests-as-safeguard", [ + "Where schools dropped standardised tests in favour of teacher assessment, the attainment gap between pupils on free school meals and their peers appeared to narrow, but blind re-marking showed teacher grades favoured pupils whose behaviour was rated as good.", + "Without a common yardstick, the author argues, under-performing schools serving poorer areas are harder to identify and support.", + ]), + ], + results: { + brainstorms: [{ + id: id(261), + title: "Brainstorm: standardised testing", + side: "pro", + output: { + needsMoreInfo: false, + topFive: [ + { rank: 1, idea: idea(11, "pro", "Teaching to the test narrows learning", "Weeks of exam drill crowd out curiosity and problem solving."), whyTop: "Concrete, relatable to the Year 10 audience." }, + { rank: 2, idea: idea(12, "pro", "Test anxiety harms wellbeing", "High stakes create stress that falls hardest on younger pupils.", "moderate"), whyTop: "Emotional weight for a classroom judge." }, + { rank: 3, idea: idea(13, "con", "Tests protect disadvantaged pupils", "A common yardstick exposes schools that fail poorer pupils."), whyTop: "The strongest opposition line; prepare for it." }, + { rank: 4, idea: idea(14, "pro", "Scores measure the school's catchment, not its teaching", "Results track family income more than teaching quality.", "moderate"), whyTop: "Undermines the accountability argument." }, + { rank: 5, idea: idea(15, "con", "Teacher assessment is biased", "Unblinded grades reflect behaviour and background.", "moderate"), whyTop: "Opposition's answer to idea 4." }, + ], + nextSteps: ["Find wellbeing data for exam years."], + warningsAndTradeoffs: ["Don't claim tests have no value; argue the harms outweigh it."], + }, + }], + factoids: [{ + id: id(271), + title: "Evidence: exam preparation time", + side: "pro", + output: { + factoids: [ + factoid(3, "Exam years consume far more prep time", "11 weeks vs 4 weeks", "Average direct exam preparation per year across 48 schools.", "Journal of School Practice (fictional)", "https://example.org/jsp/teaching-to-the-test", 88), + ], + searchSuggestions: ["pupil wellbeing surveys in national test years"], + warnings: ["Opposition will cite the teacher-assessment bias finding."], + verificationStatus: "complete", + }, + }], + cases: [], + attacks: [], + }, + chats: [], + }, +]; diff --git a/scripts/staging/seed.mjs b/scripts/staging/seed.mjs new file mode 100644 index 000000000..d31a17936 --- /dev/null +++ b/scripts/staging/seed.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// Seeds the staging Supabase project with invented but realistic data: a few +// accounts on different plans, debates with sources and saved results, and a +// demo code for the Pro account. Safe to rerun: rows have fixed ids per owner +// and are upserted, so a rerun restores the seed without duplicating it. +// +// Staging only. The project URL is fixed, and the service-role key is fetched +// with the Supabase CLI and checked to belong to staging before any write. +// +// Reads and writes ~/.config/debatelab/staging-access.env (mode 600): +// STAGING_OWNER_EMAIL Adam's Google account; gets admin, Pro and the debates +// STAGING_SEED_PRO_CODE demo code for the seeded Pro account (written here) +// +// Usage: node scripts/staging/seed.mjs +// See docs/staging.md. + +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DEBATES } from "./fixtures.mjs"; + +export const STAGING_REF = "brifeyypjauyusmkptkx"; +const URL_BASE = `https://${STAGING_REF}.supabase.co`; +const ACCESS_FILE = process.env.STAGING_ACCESS_FILE ?? join(homedir(), ".config/debatelab/staging-access.env"); +const DOMAIN = "staging.debatelab.test"; +const PRO_CODE_LABEL = "Staging seed: Pro debater"; + +const DAY = 24 * 60 * 60 * 1000; + +/** Seeded accounts. `prefix` keeps each owner's copy of the fixtures apart. */ +export function seedAccounts(ownerEmail, now = Date.now()) { + const accounts = [ + { key: "admin", email: `seed-admin@${DOMAIN}`, role: "admin", tier: "pro", prefix: null }, + { key: "pro", email: `seed-pro@${DOMAIN}`, role: "user", tier: "pro", prefix: "5eed0001", debates: "all" }, + { key: "trial", email: `seed-trial@${DOMAIN}`, role: "user", tier: "trial", trialEndsAt: new Date(now + 5 * DAY).toISOString(), prefix: "5eed0002", debates: [2] }, + { key: "expired", email: `seed-expired@${DOMAIN}`, role: "user", tier: "expired", trialEndsAt: new Date(now - 10 * DAY).toISOString(), prefix: "5eed0003", debates: [0] }, + ]; + if (ownerEmail) accounts.push({ key: "owner", email: ownerEmail.toLowerCase(), role: "admin", tier: "pro", prefix: "5eed0004", debates: "all" }); + return accounts; +} + +/** Gives one owner their own copy of the fixture ids. */ +export function ownedCopy(value, prefix) { + return JSON.parse(JSON.stringify(value).replaceAll("5eed0000-", `${prefix}-`)); +} + +function readAccessFile() { + if (!existsSync(ACCESS_FILE)) return {}; + return Object.fromEntries(readFileSync(ACCESS_FILE, "utf8").split("\n") + .map((line) => /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())) + .filter(Boolean) + .map(([, key, value]) => [key, value])); +} + +function writeAccessFile(values) { + mkdirSync(dirname(ACCESS_FILE), { recursive: true, mode: 0o700 }); + const body = [ + "# Staging access for DebateLab. Staging only; never commit or paste these. See docs/staging.md.", + ...Object.entries(values).map(([key, value]) => `${key}=${value}`), + "", + ].join("\n"); + writeFileSync(ACCESS_FILE, body, { mode: 0o600 }); + chmodSync(ACCESS_FILE, 0o600); +} + +function jwtClaims(token) { + try { + return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); + } catch { + return {}; + } +} + +/** Fetches staging's anon and service-role keys and proves they belong to staging. */ +function stagingKeys() { + const result = spawnSync("supabase", ["projects", "api-keys", "--project-ref", STAGING_REF, "--output-format", "json"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`supabase projects api-keys failed: ${result.stderr.split("\n").find((line) => line.trim()) ?? ""}`); + const parsed = JSON.parse(result.stdout); + const keys = Array.isArray(parsed) ? parsed : parsed.keys ?? []; + const find = (name) => keys.find((key) => key.name === name)?.api_key; + const anon = find("anon"); + const service = find("service_role"); + for (const [token, role] of [[anon, "anon"], [service, "service_role"]]) { + const claims = jwtClaims(token ?? ""); + if (claims.ref !== STAGING_REF || claims.role !== role) throw new Error(`Refusing: the ${role} key is not staging's.`); + } + return { anon, service }; +} + +function client({ anon, service }) { + async function request(path, { method = "GET", body, token = service, prefer, apikey = service } = {}) { + const response = await fetch(`${URL_BASE}${path}`, { + method, + headers: { + apikey, + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(prefer ? { Prefer: prefer } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`${method} ${path.split("?")[0]} answered ${response.status}: ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; + } + const upsert = (table, rows, onConflict = "id") => rows.length === 0 ? null : request( + `/rest/v1/${table}?on_conflict=${onConflict}`, + { method: "POST", body: rows, prefer: "resolution=merge-duplicates,return=minimal" }, + ); + return { request, upsert, anon }; +} + +async function listUsers(api) { + const users = []; + for (let page = 1; ; page += 1) { + const { users: batch } = await api.request(`/auth/v1/admin/users?page=${page}&per_page=200`); + users.push(...batch); + if (batch.length < 200) return users; + } +} + +async function ensureUser(api, existing, email, appMetadata) { + const found = existing.find((user) => user.email?.toLowerCase() === email); + if (found) { + if (appMetadata && Object.entries(appMetadata).some(([key, value]) => found.app_metadata?.[key] !== value)) { + return api.request(`/auth/v1/admin/users/${found.id}`, { + method: "PUT", + body: { app_metadata: { ...found.app_metadata, ...appMetadata } }, + }); + } + return found; + } + return api.request("/auth/v1/admin/users", { + method: "POST", + body: { email, email_confirm: true, app_metadata: appMetadata ?? {}, user_metadata: { seeded: true } }, + }); +} + +/** A short-lived session for a seeded account, minted with the service role. */ +async function sessionFor(api, email) { + const link = await api.request("/auth/v1/admin/generate_link", { method: "POST", body: { type: "magiclink", email } }); + const tokenHash = link.hashed_token ?? link.properties?.hashed_token; + const session = await api.request("/auth/v1/verify", { + method: "POST", + body: { type: "magiclink", token_hash: tokenHash }, + token: api.anon, + apikey: api.anon, + }); + return session.access_token; +} + +/** Makes sure the seeded Pro account has a demo code we know. */ +async function ensureProCode(api, access, adminEmail, proEmail) { + const rows = await api.request(`/rest/v1/demo_access_codes?select=id,disabled_at&email=eq.${encodeURIComponent(proEmail)}`); + const row = rows[0]; + if (row && !row.disabled_at && access.STAGING_SEED_PRO_CODE) return row.id; + // The raw code is shown only once. If we lost it, replace the row. + if (row) await api.request(`/rest/v1/demo_access_codes?id=eq.${row.id}`, { method: "DELETE" }); + const token = await sessionFor(api, adminEmail); + const created = await api.request("/functions/v1/admin?action=create-demo-code", { + method: "POST", + token, + apikey: api.anon, + body: { label: PRO_CODE_LABEL, email: proEmail }, + }); + access.STAGING_SEED_PRO_CODE = created.code; + writeAccessFile(access); + return created.demoCode.id; +} + +async function seedContent(api, userId, prefix, debateIndexes) { + const debates = (debateIndexes === "all" ? DEBATES : debateIndexes.map((index) => DEBATES[index])) + .map((debate) => ownedCopy(debate, prefix)); + const owned = (row) => ({ ...row, user_id: userId }); + await api.upsert("debates", debates.map(({ id, motion, settings, instructions }) => owned({ id, motion, settings, instructions }))); + for (const debate of debates) { + const inDebate = (row) => owned({ ...row, debate_id: debate.id }); + await api.upsert("sources", debate.sources.map(inDebate)); + for (const table of ["cases", "attacks", "brainstorms", "factoids"]) { + await api.upsert(table, debate.results[table].map(({ id, title, side, output }) => inDebate({ id, title, side, input: {}, output }))); + } + await api.upsert("chats", debate.chats.map(({ id, title, side }) => inDebate({ id, title, side }))); + await api.upsert("messages", debate.chats.flatMap((chat) => chat.messages.map((message) => ({ ...message, chat_id: chat.id, output: null })))); + } + if (debates[0]) await api.upsert("current_debates", [{ user_id: userId, debate_id: debates[0].id }], "user_id"); + return debates.length; +} + +export async function seed({ log = console.log } = {}) { + const access = readAccessFile(); + const api = client(stagingKeys()); + const accounts = seedAccounts(access.STAGING_OWNER_EMAIL); + const byKey = Object.fromEntries(accounts.map((account) => [account.key, account])); + let users = await listUsers(api); + + const admin = await ensureUser(api, users, byKey.admin.email); + await api.upsert("user_roles", [{ user_id: admin.id, role: "admin" }], "user_id"); + const codeId = await ensureProCode(api, access, byKey.admin.email, byKey.pro.email); + users = await listUsers(api); + + for (const account of accounts) { + const metadata = account.key === "pro" ? { demo_access_code_id: codeId } : undefined; + const user = await ensureUser(api, users, account.email, metadata); + await api.upsert("user_roles", [{ user_id: user.id, role: account.role }], "user_id"); + await api.upsert("user_settings", [{ + user_id: user.id, + subscription_tier: account.tier, + trial_ends_at: account.trialEndsAt ?? null, + daily_limit_override: account.tier === "pro" ? 200 : null, + grace_period_ends_at: account.tier === "pro" ? "9999-12-31T23:59:59.999Z" : null, + }], "user_id"); + const count = account.prefix ? await seedContent(api, user.id, account.prefix, account.debates) : 0; + log(`${account.key}: ${account.tier}${account.role === "admin" ? ", admin" : ""}, ${count} debate(s)`); + } + if (!access.STAGING_OWNER_EMAIL) log(`No STAGING_OWNER_EMAIL in ${ACCESS_FILE}; skipped the owner account.`); + log(`Seeded staging. Access details: ${ACCESS_FILE}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + seed().catch((error) => { + console.error(`Seeding failed: ${error.message}`); + process.exit(1); + }); +} diff --git a/src/test/scripts/staging-sync.test.ts b/src/test/scripts/staging-sync.test.ts new file mode 100644 index 000000000..64c74b834 --- /dev/null +++ b/src/test/scripts/staging-sync.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +// @ts-expect-error -- plain ESM script without type declarations +import { assertStagingTarget, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; + +const staging = { ref: STAGING_REF, name: "debate-trainer-staging" }; +const production = { ref: PRODUCTION_REF, name: "debate-trainer" }; + +describe("staging sync target guard", () => { + it("accepts the staging project under its own name", () => { + expect(() => assertStagingTarget([production, staging], {})).not.toThrow(); + }); + + it("refuses when SUPABASE_PROJECT_ID points anywhere else", () => { + expect(() => assertStagingTarget([production, staging], { SUPABASE_PROJECT_ID: PRODUCTION_REF })) + .toThrow(/only targets staging/); + }); + + it("refuses when the staging ref is missing or renamed", () => { + expect(() => assertStagingTarget([production], {})).toThrow(/cannot see/); + expect(() => assertStagingTarget([{ ref: STAGING_REF, name: "debate-trainer" }], {})).toThrow(/expected "debate-trainer-staging"/); + }); +}); + +describe("staging sync plan", () => { + it("deploys missing, stale and wrong-JWT functions once each, and skips the hidden workspace function", () => { + const plan = planSync({ + unknownMigrations: [], + pendingMigrations: ["20261002000000"], + notDeployed: ["ai", "workspace-invites"], + stale: [{ name: "ai" }, { name: "share" }], + verifyJwt: [{ name: "account" }], + notInRepo: ["workspace-invites", "old-fn"], + }); + expect(plan).toEqual({ + divergent: [], + migrations: ["20261002000000"], + functions: ["account", "ai", "share"], + undeclared: ["old-fn"], + }); + }); +}); From 762932867805c9201351506b2131eae5445696b3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 05:53:33 +0000 Subject: [PATCH 02/11] fix(staging-sync): find the drift issue without search and dedupe on its body The search index lagged during a drill and a retry opened a second issue. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/staging-sync.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index f17429f24..3912aa818 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -182,9 +182,9 @@ function setStatus(sha, state, description) { } function openDriftIssue() { + // Not --search: the search index lags, and a retry would open a duplicate. const found = JSON.parse(gh([ - "issue", "list", "--repo", GITHUB_REPO, "--state", "open", "--search", `"${DRIFT_ISSUE_TITLE}" in:title`, - "--json", "number,title", + "issue", "list", "--repo", GITHUB_REPO, "--state", "open", "--limit", "200", "--json", "number,title", ])); return found.find((issue) => issue.title === DRIFT_ISSUE_TITLE)?.number ?? null; } @@ -202,8 +202,8 @@ export function report({ sha, ok, summary, detail }) { return; } // One comment per failing commit, not one per retry. - const comments = gh(["issue", "view", String(issue), "--repo", GITHUB_REPO, "--json", "comments", "--jq", ".comments[].body"]); - if (!comments.includes(sha)) gh(["issue", "comment", String(issue), "--repo", GITHUB_REPO, "--body-file", "-"], body); + const seen = gh(["issue", "view", String(issue), "--repo", GITHUB_REPO, "--json", "body,comments", "--jq", ".body, .comments[].body"]); + if (!seen.includes(sha)) gh(["issue", "comment", String(issue), "--repo", GITHUB_REPO, "--body-file", "-"], body); } function main() { From 7be01e33e7a670c0db2c08fd3f407d2c571ce709 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:09:57 +0000 Subject: [PATCH 03/11] fix(staging): address review of the sync, gate and seed - Pass --agent no to every Supabase CLI call and ask for JSON from the migration dry run. Outside an agent the CLI printed text, so the timer would have refused every migration. - Fail the sync while a function is deployed on staging but gone from the commit, instead of reporting success. - Split staging E2E into a gate job that waits for staging-sync on its commit and skips commits main has moved past, so it never tests another commit's backend. - seed.mjs --session signs agents in as trial or expired accounts, which demo codes would turn into Pro; --owner-tier switches Adam's plan. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/staging-e2e.yml | 44 +++++++++++++----- docs/staging.md | 9 ++-- plans/2026-10-03-staging-loop.md | 3 +- scripts/staging-sync.mjs | 22 ++++++++- scripts/staging/seed.mjs | 67 +++++++++++++++++++++------ src/test/scripts/staging-sync.test.ts | 4 +- 6 files changed, 117 insertions(+), 32 deletions(-) diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 110a23744..f87e05b52 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -18,7 +18,12 @@ concurrency: cancel-in-progress: false jobs: - staging-e2e: + # Staging is one shared backend that the VPS sync (docs/staging.md) moves to + # each new main commit and marks with a staging-sync commit status. Test a + # commit only once staging runs it, and skip a commit that main has already + # moved past: staging runs the newer backend, and the newer commit's run + # tests it. + staging-sync-gate: if: >- ( (github.event_name == 'workflow_run' && @@ -28,28 +33,31 @@ jobs: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ) && vars.STAGING_E2E_ENABLED == 'true' runs-on: ubuntu-latest - environment: staging + timeout-minutes: 25 + outputs: + current: ${{ steps.wait.outputs.current }} steps: - - name: Checkout code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - - # The VPS staging sync (docs/staging.md) sets this status once staging's - # migrations and Edge Functions match the commit. Testing before that - # would test the previous backend. - name: Wait for staging to sync this commit + id: wait env: GH_TOKEN: ${{ github.token }} SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} shell: bash run: | for _ in $(seq 1 40); do + main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + if [ "$main" != "$SHA" ]; then + echo "::notice::main moved on to ${main}; staging runs that backend, and its own run tests it. Skipping ${SHA}." + echo "current=false" >> "$GITHUB_OUTPUT" + exit 0 + fi state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses" \ --jq '[.[] | select(.context == "staging-sync")][0].state // "missing"')" case "$state" in - success) echo "Staging is synced with ${SHA}."; exit 0 ;; + success) + echo "Staging is synced with ${SHA}." + echo "current=true" >> "$GITHUB_OUTPUT" + exit 0 ;; failure|error) echo "::error::Staging sync failed for ${SHA}. See the open 'Staging is out of sync with main' issue." exit 1 ;; @@ -59,6 +67,18 @@ jobs: echo "::error::No staging-sync status for ${SHA} after 20 minutes. Is debatelab-staging-sync.timer running on the VPS? See docs/staging.md." exit 1 + staging-e2e: + needs: staging-sync-gate + if: needs.staging-sync-gate.outputs.current == 'true' + runs-on: ubuntu-latest + environment: staging + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + - name: Verify selected commit passed the quality workflow if: github.event_name == 'workflow_dispatch' env: diff --git a/docs/staging.md b/docs/staging.md index d9061a3c9..26197eb5a 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -31,7 +31,10 @@ A production account, demo code, or email code does not work on staging, and a s The debates cover private cars in city centres, platform liability for misinformation, and standardised testing. Each has two or three sources and two or three saved results. Between them they cover cases, attacks, brainstorms, and factoids, and the liability debate also has an Ask chat. Seeded ids start with `5eed`. The fixtures live in [`scripts/staging/fixtures.mjs`](../scripts/staging/fixtures.mjs); all of the text is made up. -Only the trial and expired accounts lack demo codes. To try them, create a code in the staging Admin page as Adam and bind it to their email. +A demo code turns its account into lifetime Pro when used, so the trial and expired accounts have none. To try those plans: + +- Adam, on any device: `node scripts/staging/seed.mjs --owner-tier trial` (or `expired`) on the VPS, then reload. The next seed run, at the latest the next merge to `main`, puts the account back on Pro, or run `--owner-tier pro`. +- Agents: `node scripts/staging/seed.mjs --session trial > "$tmp/session.json"` prints a session for any seeded account (`admin`, `pro`, `trial`, `expired`, `owner`) without sending mail. Put it in the `debate-auth-staging` localStorage key before the app loads, as [`e2e/staging/auth.setup.ts`](../e2e/staging/auth.setup.ts) does. It holds live tokens: keep it in a mode-600 file and delete it afterwards. Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing portal, `stripe-webhook`, and the Stripe part of account deletion fail on staging, and voice rounds fall back to the browser voice. AI requests and web research use staging's own OpenRouter and Exa keys. @@ -44,9 +47,9 @@ A user systemd timer on Adam's VPS, `debatelab-staging-sync.timer`, runs [`ops/s 3. reruns the seed; 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. -An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. +An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. It never deletes a function: one that is deployed on staging but gone from the commit fails the sync until someone runs `supabase functions delete --project-ref brifeyypjauyusmkptkx`. `workspace-invites` is the exception, because it is hidden and never deployed. -`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing. If no status arrives within 20 minutes, it fails with a pointer to this page. +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. diff --git a/plans/2026-10-03-staging-loop.md b/plans/2026-10-03-staging-loop.md index b306fdd38..c1560e5ce 100644 --- a/plans/2026-10-03-staging-loop.md +++ b/plans/2026-10-03-staging-loop.md @@ -43,7 +43,8 @@ In this PR: - `scripts/staging-sync.mjs`: refuses any ref but staging, applies migrations, deploys changed functions, rechecks, and reports. It sets a `staging-sync` commit status and manages a drift issue. - `ops/staging-sync/`: the timer, the service, `run.sh` (a dedicated clone of `main`, no `npm install`), and `install.sh`. It has been installed on the VPS since 2026-10-03. - `scripts/staging/seed.mjs` and `fixtures.mjs`: four seeded accounts on Pro, trial, and expired plans, plus Adam's account. Three invented debates with sources and saved results. Fixed ids, idempotent. -- `Protected staging E2E` waits for `staging-sync` success on its commit, so it never tests a stale backend. +- `seed.mjs --session ` mints a session for agents to sign in as any seeded account, because a demo code turns its account into Pro. `--owner-tier` puts Adam's account on trial or expired until the next seed. +- `Protected staging E2E` gains a gate job that waits for `staging-sync` success on its commit and skips commits `main` has already moved past, so it never tests a stale or newer backend. - `staging-preview-note.yml`: the PR comment from decision 3. - `docs/staging.md`: the one way in. `operations.md` and `testing.md` point to it. diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index 3912aa818..c6bcad5fd 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -62,8 +62,13 @@ function quiet(text) { .trim(); } +/** + * `--agent no` keeps the CLI's output the same under an agent (where it + * detects one and switches to JSON) and under the systemd timer (where it + * doesn't), so what an agent tests is what the timer runs. + */ function supabase(root, args) { - const result = run("supabase", [...args, "--project-ref", STAGING_REF], { cwd: root }); + const result = run("supabase", [...args, "--agent", "no", "--project-ref", STAGING_REF], { cwd: root }); const output = quiet(`${result.stdout}\n${result.stderr}`); if (result.status !== 0) throw new Error(`supabase ${args.slice(0, 2).join(" ")} failed:\n${output}`); return output; @@ -99,6 +104,17 @@ export function planSync(result) { }; } +/** + * A function deployed on staging but gone from the commit stays callable. The + * sync never deletes functions, so it reports them as drift until someone + * removes them by hand (`supabase functions delete --project-ref ...`). + */ +export function assertNoUndeclared(plan) { + if (plan.undeclared.length > 0) { + throw new Error(`Deployed on staging but not in this commit: ${plan.undeclared.join(", ")}. Delete them by hand; see docs/staging.md.`); + } +} + function lastJsonLine(text) { for (const line of text.split("\n").reverse()) { try { @@ -123,13 +139,14 @@ export function sync({ root, dryRun, log = console.log }) { throw new Error(`Staging has migrations this commit lacks (${plan.divergent.join(", ")}). Not pushing over divergent history; fix it by hand.`); } if (plan.migrations.length === 0 && plan.functions.length === 0) { + assertNoUndeclared(plan); log("Staging already matches this commit."); return { plan, applied: [], deployed: [] }; } const applied = []; if (plan.migrations.length > 0) { - const dry = supabase(root, ["db", "push", "--dry-run"]); + const dry = supabase(root, ["db", "push", "--dry-run", "--output-format", "json"]); const wouldPush = (lastJsonLine(dry)?.migrations ?? []).map((file) => file.split("_")[0]); if (!sameList(wouldPush, plan.migrations)) { throw new Error(`db push would apply [${wouldPush.join(", ")}] but the pending list is [${plan.migrations.join(", ")}]. Stopping.`); @@ -160,6 +177,7 @@ export function sync({ root, dryRun, log = console.log }) { log(after.report); throw new Error(`Still pending after sync: ${[...left.migrations, ...left.functions].join(", ")}`); } + assertNoUndeclared(left); log(`Staging matches ${after.report.split("\n")[0].split(" ").pop()}.`); return { plan, applied, deployed }; } diff --git a/scripts/staging/seed.mjs b/scripts/staging/seed.mjs index d31a17936..c5356de08 100644 --- a/scripts/staging/seed.mjs +++ b/scripts/staging/seed.mjs @@ -11,7 +11,14 @@ // STAGING_OWNER_EMAIL Adam's Google account; gets admin, Pro and the debates // STAGING_SEED_PRO_CODE demo code for the seeded Pro account (written here) // -// Usage: node scripts/staging/seed.mjs +// Usage: +// node scripts/staging/seed.mjs seed or restore everything +// node scripts/staging/seed.mjs --session print a session for a seeded +// account (admin, pro, trial, expired, owner) as JSON for the browser's +// `debate-auth-staging` localStorage key. Demo codes turn an account into +// Pro, so this is how agents sign in as trial or expired. +// node scripts/staging/seed.mjs --owner-tier +// put Adam's account on another plan until the next seed run // See docs/staging.md. import { spawnSync } from "node:child_process"; @@ -137,7 +144,7 @@ async function ensureUser(api, existing, email, appMetadata) { }); } -/** A short-lived session for a seeded account, minted with the service role. */ +/** A session for a seeded account, minted with the service role without sending mail. */ async function sessionFor(api, email) { const link = await api.request("/auth/v1/admin/generate_link", { method: "POST", body: { type: "magiclink", email } }); const tokenHash = link.hashed_token ?? link.properties?.hashed_token; @@ -147,7 +154,7 @@ async function sessionFor(api, email) { token: api.anon, apikey: api.anon, }); - return session.access_token; + return session; } /** Makes sure the seeded Pro account has a demo code we know. */ @@ -157,7 +164,7 @@ async function ensureProCode(api, access, adminEmail, proEmail) { if (row && !row.disabled_at && access.STAGING_SEED_PRO_CODE) return row.id; // The raw code is shown only once. If we lost it, replace the row. if (row) await api.request(`/rest/v1/demo_access_codes?id=eq.${row.id}`, { method: "DELETE" }); - const token = await sessionFor(api, adminEmail); + const { access_token: token } = await sessionFor(api, adminEmail); const created = await api.request("/functions/v1/admin?action=create-demo-code", { method: "POST", token, @@ -203,13 +210,7 @@ export async function seed({ log = console.log } = {}) { const metadata = account.key === "pro" ? { demo_access_code_id: codeId } : undefined; const user = await ensureUser(api, users, account.email, metadata); await api.upsert("user_roles", [{ user_id: user.id, role: account.role }], "user_id"); - await api.upsert("user_settings", [{ - user_id: user.id, - subscription_tier: account.tier, - trial_ends_at: account.trialEndsAt ?? null, - daily_limit_override: account.tier === "pro" ? 200 : null, - grace_period_ends_at: account.tier === "pro" ? "9999-12-31T23:59:59.999Z" : null, - }], "user_id"); + await api.upsert("user_settings", [{ user_id: user.id, ...tierSettings(account) }], "user_id"); const count = account.prefix ? await seedContent(api, user.id, account.prefix, account.debates) : 0; log(`${account.key}: ${account.tier}${account.role === "admin" ? ", admin" : ""}, ${count} debate(s)`); } @@ -217,9 +218,49 @@ export async function seed({ log = console.log } = {}) { log(`Seeded staging. Access details: ${ACCESS_FILE}`); } +/** Plan fields for a seeded account, as the seed writes them. */ +function tierSettings(account) { + return { + subscription_tier: account.tier, + trial_ends_at: account.trialEndsAt ?? null, + daily_limit_override: account.tier === "pro" ? 200 : null, + grace_period_ends_at: account.tier === "pro" ? "9999-12-31T23:59:59.999Z" : null, + }; +} + +async function printSession(key) { + const account = seedAccounts(readAccessFile().STAGING_OWNER_EMAIL).find((candidate) => candidate.key === key); + if (!account) throw new Error(`Unknown account "${key}". Use admin, pro, trial, expired or owner.`); + const api = client(stagingKeys()); + if (!(await listUsers(api)).some((user) => user.email?.toLowerCase() === account.email)) { + throw new Error(`${account.email} is not on staging yet. Run the seed first.`); + } + process.stdout.write(`${JSON.stringify(await sessionFor(api, account.email))}\n`); +} + +async function setOwnerTier(tier) { + const access = readAccessFile(); + const owner = seedAccounts(access.STAGING_OWNER_EMAIL).find((account) => account.key === "owner"); + if (!owner) throw new Error(`No STAGING_OWNER_EMAIL in ${ACCESS_FILE}.`); + const template = seedAccounts(null).find((account) => account.tier === tier); + if (!template) throw new Error(`Unknown plan "${tier}". Use pro, trial or expired.`); + const api = client(stagingKeys()); + const user = (await listUsers(api)).find((candidate) => candidate.email?.toLowerCase() === owner.email); + if (!user) throw new Error("The owner account is not on staging yet. Sign in with Google once, or run the seed."); + await api.upsert("user_settings", [{ user_id: user.id, ...tierSettings(template) }], "user_id"); + console.log(`Owner account is on ${tier} until the next seed run.`); +} + +function main(argv) { + if (argv[0] === "--session" && argv[1]) return printSession(argv[1]); + if (argv[0] === "--owner-tier" && argv[1]) return setOwnerTier(argv[1]); + if (argv.length > 0) throw new Error(`Unknown arguments: ${argv.join(" ")}`); + return seed(); +} + if (import.meta.url === `file://${process.argv[1]}`) { - seed().catch((error) => { - console.error(`Seeding failed: ${error.message}`); + Promise.resolve().then(() => main(process.argv.slice(2))).catch((error) => { + console.error(`scripts/staging/seed.mjs failed: ${error.message}`); process.exit(1); }); } diff --git a/src/test/scripts/staging-sync.test.ts b/src/test/scripts/staging-sync.test.ts index 64c74b834..dd47353ef 100644 --- a/src/test/scripts/staging-sync.test.ts +++ b/src/test/scripts/staging-sync.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; // @ts-expect-error -- plain ESM script without type declarations -import { assertStagingTarget, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; +import { assertNoUndeclared, assertStagingTarget, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; const staging = { ref: STAGING_REF, name: "debate-trainer-staging" }; const production = { ref: PRODUCTION_REF, name: "debate-trainer" }; @@ -37,5 +37,7 @@ describe("staging sync plan", () => { functions: ["account", "ai", "share"], undeclared: ["old-fn"], }); + // A function removed from main but still deployed fails the sync instead of passing quietly. + expect(() => assertNoUndeclared(plan)).toThrow(/old-fn/); }); }); From 34d2ec03299fb62f055a8281c6d7dc78679c7e1e Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:16:44 +0000 Subject: [PATCH 04/11] fix(staging): order seeded chat messages; hold the sync during an older run - Give each seeded chat message its own created_at. Chats load ordered by that column alone, and a bulk insert gave every message the same time. - run.sh holds a new commit's sync while Protected staging E2E is still testing an older commit, so no run passes against a backend that changed under it. The browser job now times out after 30 minutes, which bounds the hold. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/staging-e2e.yml | 2 ++ docs/staging.md | 2 +- ops/staging-sync/run.sh | 10 ++++++++++ scripts/staging/seed.mjs | 9 ++++++++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index f87e05b52..9f95572e3 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -71,6 +71,8 @@ jobs: needs: staging-sync-gate if: needs.staging-sync-gate.outputs.current == 'true' runs-on: ubuntu-latest + # Bounds how long the VPS sync holds off for this run (ops/staging-sync/run.sh). + timeout-minutes: 30 environment: staging steps: - name: Checkout code diff --git a/docs/staging.md b/docs/staging.md index 26197eb5a..81336d2f4 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -49,7 +49,7 @@ A user systemd timer on Adam's VPS, `debatelab-staging-sync.timer`, runs [`ops/s An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. It never deletes a function: one that is deployed on staging but gone from the commit fails the sync until someone runs `supabase functions delete --project-ref brifeyypjauyusmkptkx`. `workspace-invites` is the exception, because it is hidden and never deployed. -`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds a new commit's sync while a run for an older commit is still testing. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 7edda2dfa..06acef326 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -26,6 +26,16 @@ for marker in ok failed; do fi done +# Don't change the backend under a browser run that is testing an older +# commit; sync once it finishes (the job times out after 30 minutes). A run +# for this commit is waiting for this sync, so it doesn't count. +testing="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --limit 20 --json status,headSha \ + --jq "[.[] | select(.status != \"completed\" and .headSha != \"$sha\")] | length")" || testing=0 +if [ "$testing" != "0" ]; then + echo "Protected staging E2E is testing an older commit; syncing $sha after it finishes." + exit 0 +fi + git -C "$REPO" checkout --quiet --force --detach "$sha" git -C "$REPO" clean -fdq diff --git a/scripts/staging/seed.mjs b/scripts/staging/seed.mjs index c5356de08..d9cf52340 100644 --- a/scripts/staging/seed.mjs +++ b/scripts/staging/seed.mjs @@ -188,7 +188,14 @@ async function seedContent(api, userId, prefix, debateIndexes) { await api.upsert(table, debate.results[table].map(({ id, title, side, output }) => inDebate({ id, title, side, input: {}, output }))); } await api.upsert("chats", debate.chats.map(({ id, title, side }) => inDebate({ id, title, side }))); - await api.upsert("messages", debate.chats.flatMap((chat) => chat.messages.map((message) => ({ ...message, chat_id: chat.id, output: null })))); + // Chats load ordered by created_at alone, so give each message its own time. + const base = Date.parse("2026-09-28T18:00:00Z"); + await api.upsert("messages", debate.chats.flatMap((chat) => chat.messages.map((message, index) => ({ + ...message, + chat_id: chat.id, + output: null, + created_at: new Date(base + index * 60_000).toISOString(), + })))); } if (debates[0]) await api.upsert("current_debates", [{ user_id: userId, debate_id: debates[0].id }], "user_id"); return debates.length; From de705f84bcdc1081d2ba0fa1624df5b27c8ad0d7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:23:01 +0000 Subject: [PATCH 05/11] fix(staging-sync): fail closed when GitHub can't say whether E2E runs; report refusals A failed run-list query no longer counts as no active run. With --report, a refusal before syncing (expired login, wrong target) now sets a failed staging-sync status and opens the drift issue instead of failing silently. Co-Authored-By: Claude Opus 5.5 (1M context) --- ops/staging-sync/run.sh | 5 ++++- scripts/staging-sync.mjs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 06acef326..2c3054674 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -30,7 +30,10 @@ done # commit; sync once it finishes (the job times out after 30 minutes). A run # for this commit is waiting for this sync, so it doesn't count. testing="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --limit 20 --json status,headSha \ - --jq "[.[] | select(.status != \"completed\" and .headSha != \"$sha\")] | length")" || testing=0 + --jq "[.[] | select(.status != \"completed\" and .headSha != \"$sha\")] | length")" || { + echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 + exit 1 +} if [ "$testing" != "0" ]; then echo "Protected staging E2E is testing an older commit; syncing $sha after it finishes." exit 0 diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index c6bcad5fd..7d2853e7f 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -245,6 +245,13 @@ function main() { if (dirty) throw new RefusedError("Uncommitted changes under supabase/. Sync only from a clean commit."); } catch (error) { console.error(`Refused: ${error.message}`); + if (options.report && !options.dryRun) { + try { + report({ sha, ok: false, summary: `Refused: ${error.message.split("\n")[0]}`, detail: error.message }); + } catch (reportError) { + console.error(`Reporting failed too: ${reportError.message}`); + } + } process.exit(2); } From 9de4b72cf92b2281e82a926da3d15ab36f98fa47 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:34:48 +0000 Subject: [PATCH 06/11] fix(staging): see every active E2E run; recheck staging on job reruns run.sh queried the last 20 runs, so a busy PR stream could hide an older run that was still testing. It now asks per active status. The browser job checks that main and staging still run its commit on every attempt, because re-running failed jobs skips the gate. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/staging-e2e.yml | 15 +++++++++++++++ ops/staging-sync/run.sh | 14 +++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 9f95572e3..4531f3694 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -75,6 +75,21 @@ jobs: timeout-minutes: 30 environment: staging steps: + # "Re-run failed jobs" skips the gate, so check again on every attempt. + - name: Confirm staging still runs this commit + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + shell: bash + run: | + main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses" \ + --jq '[.[] | select(.context == "staging-sync")][0].state // "missing"')" + if [ "$main" != "$SHA" ] || [ "$state" != "success" ]; then + echo "::error::Staging no longer runs ${SHA} (main is ${main}, staging-sync is ${state}). Rerun the newest main commit's run instead." + exit 1 + fi + - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 2c3054674..526ccb064 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -29,11 +29,15 @@ done # Don't change the backend under a browser run that is testing an older # commit; sync once it finishes (the job times out after 30 minutes). A run # for this commit is waiting for this sync, so it doesn't count. -testing="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --limit 20 --json status,headSha \ - --jq "[.[] | select(.status != \"completed\" and .headSha != \"$sha\")] | length")" || { - echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 - exit 1 -} +testing=0 +for state in queued in_progress waiting pending requested; do + count="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status "$state" --limit 100 \ + --json headSha --jq "[.[] | select(.headSha != \"$sha\")] | length")" || { + echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 + exit 1 + } + testing=$((testing + count)) +done if [ "$testing" != "0" ]; then echo "Protected staging E2E is testing an older commit; syncing $sha after it finishes." exit 0 From 097aeb3e31692b15b76639583d6900e0b5d434c5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:51:13 +0000 Subject: [PATCH 07/11] fix(staging-sync): hold drift repairs while this commit's browser job runs Runs for the synced commit were exempt, so an hourly drift repair could redeploy under them. Only runs still waiting in the gate are exempt now. Co-Authored-By: Claude Opus 5.5 (1M context) --- ops/staging-sync/run.sh | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 526ccb064..9aa98bc4a 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -26,20 +26,28 @@ for marker in ok failed; do fi done -# Don't change the backend under a browser run that is testing an older -# commit; sync once it finishes (the job times out after 30 minutes). A run -# for this commit is waiting for this sync, so it doesn't count. -testing=0 +# Don't change the backend under a browser run. A run for an older commit +# holds the sync until it finishes or skips (the browser job times out after +# 30 minutes). A run for this commit holds it only while its browser job is +# testing: before that it is waiting for this very sync. +fail_query() { + echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 + exit 1 +} +busy=0 for state in queued in_progress waiting pending requested; do - count="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status "$state" --limit 100 \ - --json headSha --jq "[.[] | select(.headSha != \"$sha\")] | length")" || { - echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 - exit 1 - } - testing=$((testing + count)) + runs="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status "$state" --limit 100 \ + --json databaseId,headSha --jq '.[] | "\(.databaseId) \(.headSha)"')" || fail_query + while read -r id head; do + [ -n "$id" ] || continue + if [ "$head" != "$sha" ]; then busy=1; continue; fi + testing="$(gh run view "$id" --repo Programcode/DebateLab --json jobs \ + --jq '[.jobs[] | select(.name == "staging-e2e" and .status == "in_progress")] | length')" || fail_query + [ "$testing" = "0" ] || busy=1 + done <<< "$runs" done -if [ "$testing" != "0" ]; then - echo "Protected staging E2E is testing an older commit; syncing $sha after it finishes." +if [ "$busy" != "0" ]; then + echo "Protected staging E2E is testing; syncing $sha after it finishes." exit 0 fi From e4a7788fda36c409a7207bb49221aefae99ab3bf Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 06:56:52 +0000 Subject: [PATCH 08/11] docs(staging): how to run the staging suite locally Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/staging.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/staging.md b/docs/staging.md index 81336d2f4..4956e2829 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -76,4 +76,5 @@ The unit's `PATH` names `~/.nvm/versions/node/v24.20.0/bin`, where `node` and `s - Production is never a fallback. When staging access fails, stop. - Staging browser tests create uniquely named data and delete only what they created ([testing.md](testing.md#protected-staging)). +- To run the staging browser suite locally, use the seeded Pro code as `STAGING_DEMO_CODE`, and rerun the seed afterwards to restore that account's Current Debate. The CI code exists only as a GitHub secret. The copy in `~/.config/debatelab/staging-e2e.env` expired on 2026-09-10. - `VERCEL_STAGING_BYPASS` (GitHub `staging` environment, and the VPS access file) lets CI and agents open protected `debatelab-staging` previews with the `x-vercel-protection-bypass` header. Treat it like the demo codes. From 634007d566c3cadc6feef8727e7c1fcb7bfb9dbd Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 07:07:56 +0000 Subject: [PATCH 09/11] fix(staging-sync): compare deployed function source; hold only for running browser jobs - Deploy dates only say when a function was deployed. The sync now downloads what staging runs and compares it byte for byte with the commit, so an older checkout deployed after a merge no longer passes as current. Proven against a checkout from before #95: dates said nothing was pending, and the content check flagged the 13 functions that run the newer cors.ts. - run.sh holds a sync only while a browser job is running. A queued or gated run isn't using staging, and the job timeout doesn't cover queue time. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/staging.md | 4 +- ops/staging-sync/run.sh | 24 +++++------ scripts/staging-sync.mjs | 61 +++++++++++++++++++++++++-- src/test/scripts/staging-sync.test.ts | 28 +++++++++++- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/docs/staging.md b/docs/staging.md index 4956e2829..adae12056 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -43,13 +43,13 @@ Staging deliberately has no Stripe or ElevenLabs secrets. Checkout, the billing 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: 1. checks that commit out in a dedicated clone at `~/ops/debatelab-staging-sync/repo` (it never runs `npm install`); -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; +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; 3. reruns the seed; 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. An already-synced commit is rechecked hourly, so drift from a hand deploy also shows up. A failed commit is retried hourly. The script stops instead of pushing when staging has a migration the commit lacks. It never deletes a function: one that is deployed on staging but gone from the commit fails the sync until someone runs `supabase functions delete --project-ref brifeyypjauyusmkptkx`. `workspace-invites` is the exception, because it is hidden and never deployed. -`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds a new commit's sync while a run for an older commit is still testing. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. +`Protected staging E2E` waits for a successful `staging-sync` status on its commit before testing, and the timer holds any sync while a browser job is running. If `main` moves past the commit while it waits, the run skips: staging then runs the newer backend, and the newer commit's run tests it. If no status arrives within 20 minutes, it fails with a pointer to this page. PRs that change `supabase/migrations/` or `supabase/functions/` get a comment saying their staging preview still runs `main`'s backend. Those changes reach staging only after merge. To try them before then, use the `verify-debatelab` skill against a local stack. diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 9aa98bc4a..0f582951e 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -26,25 +26,21 @@ for marker in ok failed; do fi done -# Don't change the backend under a browser run. A run for an older commit -# holds the sync until it finishes or skips (the browser job times out after -# 30 minutes). A run for this commit holds it only while its browser job is -# testing: before that it is waiting for this very sync. +# Don't change the backend while a browser job is testing it. Only a running +# browser job counts: a queued run, or one still waiting in its gate for this +# very sync, isn't using staging yet, and an older run's gate skips once main +# moves on. The browser job times out after 30 minutes. fail_query() { echo "Could not ask GitHub whether staging E2E is running; not syncing blind." >&2 exit 1 } busy=0 -for state in queued in_progress waiting pending requested; do - runs="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status "$state" --limit 100 \ - --json databaseId,headSha --jq '.[] | "\(.databaseId) \(.headSha)"')" || fail_query - while read -r id head; do - [ -n "$id" ] || continue - if [ "$head" != "$sha" ]; then busy=1; continue; fi - testing="$(gh run view "$id" --repo Programcode/DebateLab --json jobs \ - --jq '[.jobs[] | select(.name == "staging-e2e" and .status == "in_progress")] | length')" || fail_query - [ "$testing" = "0" ] || busy=1 - done <<< "$runs" +runs="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status in_progress --limit 100 \ + --json databaseId --jq '.[].databaseId')" || fail_query +for id in $runs; do + testing="$(gh run view "$id" --repo Programcode/DebateLab --json jobs \ + --jq '[.jobs[] | select(.name == "staging-e2e" and .status == "in_progress")] | length')" || fail_query + [ "$testing" = "0" ] || busy=1 done if [ "$busy" != "0" ]; then echo "Protected staging E2E is testing; syncing $sha after it finishes." diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index 7d2853e7f..b314750c0 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -18,8 +18,10 @@ // Exit codes: 0 in sync, 1 drift remains or a step failed, 2 refused or misused. import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { backendStatus } from "./backend-status.mjs"; export const STAGING_REF = "brifeyypjauyusmkptkx"; @@ -104,6 +106,59 @@ export function planSync(result) { }; } +/** Edge Functions in the checkout: folders under supabase/functions with an index.ts. */ +function repoFunctions(root) { + const base = join(root, "supabase/functions"); + return readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && existsSync(join(base, entry.name, "index.ts"))) + .map((entry) => entry.name) + .filter((name) => !SKIPPED_FUNCTIONS.has(name)); +} + +function filesUnder(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? filesUnder(join(dir, entry.name)) : [join(dir, entry.name)]); +} + +/** The source files staging runs for one function, keyed by path under supabase/functions. */ +function downloadDeployed(root, name) { + const dir = mkdtempSync(join(tmpdir(), "staging-fn-")); + try { + // The CLI writes supabase/.temp into its cwd, so download into a scratch dir. + supabase(dir, ["functions", "download", name, "--use-api"]); + const base = join(dir, "supabase/functions"); + return new Map(filesUnder(base).map((file) => [relative(base, file), readFileSync(file)])); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Functions whose deployed source differs from the commit. Deploy dates only + * say when something was deployed, not what, so an older checkout deployed + * after a merge would otherwise pass as current. + */ +export function contentDrift(root, names, download = (name) => downloadDeployed(root, name)) { + const base = join(root, "supabase/functions"); + return names.filter((name) => { + const deployed = download(name); + if (deployed.size === 0 || ![...deployed.keys()].includes(`${name}/index.ts`)) return true; + return [...deployed].some(([path, bytes]) => { + const local = join(base, path); + return !existsSync(local) || !readFileSync(local).equals(bytes); + }); + }); +} + +/** planSync plus a content check of every deployed function the date check passed. */ +function planWithContent(root, result, log) { + const plan = planSync(result); + const pending = new Set([...plan.functions, ...result.notDeployed]); + const drifted = contentDrift(root, repoFunctions(root).filter((name) => !pending.has(name))); + if (drifted.length > 0) log(`Deployed source differs from this commit: ${drifted.join(", ")}`); + return { ...plan, functions: [...new Set([...plan.functions, ...drifted])].sort() }; +} + /** * A function deployed on staging but gone from the commit stays callable. The * sync never deletes functions, so it reports them as drift until someone @@ -132,8 +187,8 @@ function sameList(a, b) { export function sync({ root, dryRun, log = console.log }) { const before = backendStatus({ root, projectRef: STAGING_REF }); - const plan = planSync(before.result); log(before.report, ""); + const plan = planWithContent(root, before.result, log); if (plan.divergent.length > 0) { throw new Error(`Staging has migrations this commit lacks (${plan.divergent.join(", ")}). Not pushing over divergent history; fix it by hand.`); @@ -172,7 +227,7 @@ export function sync({ root, dryRun, log = console.log }) { if (dryRun) return { plan, applied, deployed }; const after = backendStatus({ root, projectRef: STAGING_REF }); - const left = planSync(after.result); + const left = planWithContent(root, after.result, log); if (left.migrations.length > 0 || left.functions.length > 0 || left.divergent.length > 0) { log(after.report); throw new Error(`Still pending after sync: ${[...left.migrations, ...left.functions].join(", ")}`); diff --git a/src/test/scripts/staging-sync.test.ts b/src/test/scripts/staging-sync.test.ts index dd47353ef..42ca4bcb5 100644 --- a/src/test/scripts/staging-sync.test.ts +++ b/src/test/scripts/staging-sync.test.ts @@ -1,6 +1,9 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; // @ts-expect-error -- plain ESM script without type declarations -import { assertNoUndeclared, assertStagingTarget, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; +import { assertNoUndeclared, assertStagingTarget, contentDrift, planSync, PRODUCTION_REF, STAGING_REF } from "../../../scripts/staging-sync.mjs"; const staging = { ref: STAGING_REF, name: "debate-trainer-staging" }; const production = { ref: PRODUCTION_REF, name: "debate-trainer" }; @@ -41,3 +44,26 @@ describe("staging sync plan", () => { expect(() => assertNoUndeclared(plan)).toThrow(/old-fn/); }); }); + +describe("staging sync content check", () => { + const root = mkdtempSync(join(tmpdir(), "staging-sync-test-")); + for (const [path, text] of [["ai/index.ts", "ai v2"], ["_shared/cors.ts", "cors v2"], ["share/index.ts", "share"]]) { + mkdirSync(join(root, "supabase/functions", path, ".."), { recursive: true }); + writeFileSync(join(root, "supabase/functions", path), text); + } + const deployed: Record> = { + ai: { "ai/index.ts": "ai v2", "_shared/cors.ts": "cors v1" }, // older shared code deployed later + share: { "share/index.ts": "share" }, + gone: { "gone/index.ts": "x", "gone/extra.ts": "y" }, + empty: {}, + }; + const download = (name: string) => new Map(Object.entries(deployed[name]).map(([path, text]) => [path, Buffer.from(text)])); + + it("flags a function whose deployed source differs, even in a shared file", () => { + expect(contentDrift(root, ["ai", "share"], download)).toEqual(["ai"]); + }); + + it("flags files the commit lacks and downloads with no entry point", () => { + expect(contentDrift(root, ["gone", "empty"], download)).toEqual(["gone", "empty"]); + }); +}); From 1e2bb61111a760c5eea5545d9f9a0eb9c495f1a1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 07:14:40 +0000 Subject: [PATCH 10/11] fix(staging-sync): mark the commit pending before changing staging A browser job could start between run.sh's check and a drift repair while the old success status still stood. The sync now sets staging-sync to pending before any change, so new browser jobs refuse to test. It then checks once more for a running job, and if one started it restores the status, changes nothing and exits 3, which run.sh retries on the next tick. Co-Authored-By: Claude Opus 5.5 (1M context) --- ops/staging-sync/run.sh | 2 ++ scripts/staging-sync.mjs | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index 0f582951e..fa1d12958 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -63,6 +63,8 @@ if node "$REPO/scripts/staging-sync.mjs" --root "$REPO" --report "${seed[@]}"; t rm -f "$STATE/last-failed" else status=$? + # 3: a browser job started while syncing; nothing changed, retry next tick. + [ "$status" -eq 3 ] && exit 0 echo "$sha" > "$STATE/last-failed" exit "$status" fi diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index b314750c0..a17ace8e1 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -15,7 +15,8 @@ // the drift issue. The staging browser suite waits for that status. // --seed run scripts/staging/seed.mjs after a successful sync // -// Exit codes: 0 in sync, 1 drift remains or a step failed, 2 refused or misused. +// Exit codes: 0 in sync, 1 drift remains or a step failed, 2 refused or misused, +// 3 deferred because a staging browser job is running (try again later). import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; @@ -35,6 +36,7 @@ const DRIFT_ISSUE_TITLE = "Staging is out of sync with main"; export const SKIPPED_FUNCTIONS = new Set(["workspace-invites"]); class RefusedError extends Error {} +class DeferredError extends Error {} export function parseArgs(argv) { const options = { root: null, dryRun: false, report: false, seed: false }; @@ -185,7 +187,7 @@ function sameList(a, b) { return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index]); } -export function sync({ root, dryRun, log = console.log }) { +export function sync({ root, dryRun, log = console.log, beforeChange = () => {} }) { const before = backendStatus({ root, projectRef: STAGING_REF }); log(before.report, ""); const plan = planWithContent(root, before.result, log); @@ -199,6 +201,7 @@ export function sync({ root, dryRun, log = console.log }) { return { plan, applied: [], deployed: [] }; } + if (!dryRun) beforeChange(); const applied = []; if (plan.migrations.length > 0) { const dry = supabase(root, ["db", "push", "--dry-run", "--output-format", "json"]); @@ -254,6 +257,31 @@ function setStatus(sha, state, description) { ]); } +function currentStatus(sha) { + const statuses = JSON.parse(gh(["api", `repos/${GITHUB_REPO}/commits/${sha}/statuses`])); + return statuses.find((status) => status.context === STATUS_CONTEXT) ?? null; +} + +function browserJobRunning() { + const runs = JSON.parse(gh(["run", "list", "--repo", GITHUB_REPO, "--workflow", "staging-e2e.yml", "--status", "in_progress", "--limit", "100", "--json", "databaseId"])); + return runs.some(({ databaseId }) => JSON.parse(gh(["run", "view", String(databaseId), "--repo", GITHUB_REPO, "--json", "jobs"])) + .jobs.some((job) => job.name === "staging-e2e" && job.status === "in_progress")); +} + +/** + * Marks the commit pending before staging changes, so browser jobs that start + * from now on refuse to test (they need success), then checks once more that + * none started since run.sh looked. If one did, it changes nothing. + */ +export function claimStaging(sha) { + const previous = currentStatus(sha); + setStatus(sha, "pending", "Syncing staging; browser tests wait for this."); + if (browserJobRunning()) { + if (previous) setStatus(sha, previous.state, previous.description ?? ""); + throw new DeferredError("A staging browser job started; syncing on a later run."); + } +} + function openDriftIssue() { // Not --search: the search index lags, and a retry would open a duplicate. const found = JSON.parse(gh([ @@ -317,7 +345,8 @@ function main() { console.log(text); }; try { - const { applied, deployed } = sync({ root, dryRun: options.dryRun, log }); + const beforeChange = options.report ? () => claimStaging(sha) : undefined; + const { applied, deployed } = sync({ root, dryRun: options.dryRun, log, beforeChange }); if (options.seed && !options.dryRun) { const seeded = run("node", [join(root, "scripts/staging/seed.mjs")], { cwd: root }); log(quiet(seeded.stdout), quiet(seeded.stderr)); @@ -329,6 +358,7 @@ function main() { if (options.report && !options.dryRun) report({ sha, ok: true, summary, detail: lines.join("\n") }); } catch (error) { console.error(error.message); + if (error instanceof DeferredError) process.exit(3); if (options.report && !options.dryRun) { try { report({ sha, ok: false, summary: error.message.split("\n")[0], detail: `${lines.join("\n")}\n${error.message}` }); From 164cf593fb64e0dcccf1b4f66280f64a2e22044f Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 3 Oct 2026 07:24:07 +0000 Subject: [PATCH 11/11] fix(ci): read all PR comments before looking for the staging note marker grep -q exiting early sent gh SIGPIPE, and under pipefail the step read that as no marker and posted a duplicate note. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/staging-preview-note.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/staging-preview-note.yml b/.github/workflows/staging-preview-note.yml index 75c857123..1adf68fba 100644 --- a/.github/workflows/staging-preview-note.yml +++ b/.github/workflows/staging-preview-note.yml @@ -27,7 +27,10 @@ jobs: shell: bash run: | marker="" - if gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq '.[].body' | grep -qF "$marker"; then + # Read every comment first: grep -q exiting early would SIGPIPE gh, and + # under pipefail that reads as "no marker" and posts a duplicate. + bodies="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq '.[].body')" + if grep -qF "$marker" <<< "$bodies"; then exit 0 fi gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$marker