release: staging control plane, /api/health, and the backend_tag fix - #113
Merged
Merged
Conversation
…nical copy (#105) Groundwork for a deployed sofra staging at staging.sofrapiwas.com. A separate build job rather than an extra tag, because NEXT_PUBLIC_SITE_URL is a BUILD arg — one image cannot serve two hosts. `:staging` is develop-only, so rolling staging can never deploy main's build output to it, and `:migrate-staging` gives the staging database develop's migrations rather than main's. The robots change is the part that is not bookkeeping. `robots.ts` was allow-all and explicitly welcomed GPTBot, ClaudeBot, PerplexityBot and friends — correct for the canonical site, actively harmful on a public twin of it. A crawlable staging copy competes with the real marketing site for exactly the citations the AEO work exists to win, on content that is by definition ahead of what we decided to publish; and while the control plane is auth-gated, its login and signup pages are not. Keyed on the deployment's own base URL, not a separate flag, so it is self-correcting: anything that is not the canonical host is noindex without anyone remembering to set something. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`build-image.yml` triggers on `push: branches: [main]` only, so the `build-staging` job I added — gated on `refs/heads/develop` — was unreachable. The gate looked right and was dead code: merging #105 published no `:staging` image at all, which is how I found it. `develop` added to the trigger, matching the frontend and backend workflows. `build-push` now skips on develop so a develop merge does not also publish a `:sha` and `migrate-<sha>` nothing pulls; the ref gates inside each meta step still keep `:latest` and `:migrate` main-only. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…lop (#107) `:migrate-staging` was tagged on `build-push`'s meta-migrate step. That job skips on develop, so the tag was dead code and the image never existed — `docker pull ghcr.io/piwas-21/sofra:migrate-staging` on the box answered "not found", which is how I found it. Moved into `build-staging`, which is the job that actually runs on develop. Staging needs its own migrate image rather than reusing main's: the image carries prisma/migrations, so main's copy would apply main's schema to a database that tracks develop. Second-order fallout from gating build-push off develop in the previous commit. Both were only visible by trying to use the artifact. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) * test(e2e): a live suite against the deployed staging control plane The existing suite is already unmocked — real build, real Postgres, a real Mollie first payment on the test key. What it cannot see is anything that only exists once something is deployed: the box .env reaching the container with the RIGHT values, the migrate one-off having run, Caddy routing the host, and the image that was PUBLISHED rather than the one `next build` produces locally. Every one of those has bitten in the last two days. Two bit while standing this environment up — a `:staging` bake whose job could never fire (#106) and a `:migrate-staging` tag that was dead code (#107) — both invisible until something tried to pull the artifact. Six assertions, read-only by construction (no account, no payment, no row written): the entry points serve; staging is noindex in both robots.txt and a header while production is still crawlable; signing in proves the staging DB and AuthSecret are real; the Mollie TEST key is wired in; and provisioning is deliberately DISARMED, asserted positively so nobody "fixes" the empty PROVISION_GITHUB_TOKEN and hands a test environment write access to production infra. Gated on E2E_REMOTE so it never runs against the local `next start`, where every assertion would be vacuous or wrong. One login per run — lib/auth.ts allows 10 per email per 15 minutes, and a 429 there renders as "invalid credentials", which reads like a broken password rather than a suite that ate its own budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(e2e): harden the live staging suite after review Ten review findings; the two that mattered most were bugs I had shipped into the working tree rather than in the new spec. 1. `STAGING_ADMIN={email: …}` in .env killed `npm run test:e2e:full`. scripts/e2e-suite.sh sources .env with `set -a && . ./.env`, and bash reads the unquoted brace value as an assignment plus the command `a@b.com,` — fatal under `set -euo pipefail`, reported as an email address. The repo's primary quality gate was dead on this clone. Value quoted, parser now unquotes, and .env.example documents why the quotes are load-bearing. 2. E2E_REMOTE skipped the webServer but gated nothing else, so a whole-directory remote run would have driven self-serve-signup and billing-mollie — which write rows and create real payments — against the shared staging environment. Now enforced in playwright.config.ts BOTH ways: remote runs this spec only, local runs everything but this spec. The local half is not symmetry for its own sake; without it the spec joined test:e2e:full and asserted deployment facts against localhost. Assertions that could not fail, or failed in the wrong direction: - the /admin heading check passed on the failure it claimed to catch — global-error.tsx is the app's only error boundary and renders an <h1>, and waitForURL is status-blind, so an unmigrated table would have gone green. Now anchored on the page's own heading plus an explicit refusal of the error boundary. - the Mollie check was absence-only, and `goto` returns 200 after a redirect, so an expired session bouncing to /login reported the key as configured. Positive anchor added. - the test claimed to prove a `test_` key; mollieConfigured() only reports that SOME key is set and nothing surfaces the prefix. Renamed to what it establishes, and the gap — along with "this cannot tell you the image is current" — is now stated in the docstring. - both matched strings exist in all six locales with OPPOSITE failure directions, so NEXT_LOCALE is pinned to en. Also: the production-host compare is now host-based and runs before the first navigation, so a mistyped E2E_BASE_URL cannot burn the owner's production login budget; credential parsing rejects every wrong-but-truthy shape instead of timing out as "staging is down"; a missing credential is an error rather than a skip that exits 0; and the beforeAll gets its own timeout, since it previously shared the 30s test budget and could not reach the waitForURL value it passed. Verified: test:e2e:full 23 passed, test:e2e:staging 6 passed, remote --list confirms 1 file not 5, and pointing the suite at production fails in beforeAll before any login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Records both halves in §7 so the next session in this repo does not have to rediscover them: what test:e2e:staging covers (only deploy-time facts), what it deliberately does NOT prove (image currency, test_ vs live_ key), the .env quoting requirement, and why the remote/local split is enforced in the config rather than as a test.skip in each spec. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#110) * feat(health): /api/health with build identity, and an indexing monitor Two gaps the deployed-staging suite had to admit to in its own docstring. /api/health reports the commit its image was built from (Dockerfile ARG BUILD_SHA, supplied by build-image.yml for both the prod and staging bakes). Without it nothing could tell a current deployment from a months-old one: pages render, login works and env vars are wired just as happily on a stale image, so a green suite could certify an environment nobody had rolled since the change under test was written. The staging suite now judges currency against this clone rather than a wall-clock threshold — an unknown commit fails, a diverged line fails, and a legitimately-behind-but-linear deployment is PRINTED rather than silently assumed current. The endpoint is unauthenticated because it is the Docker HEALTHCHECK target and a monitoring endpoint, and that is safe only because it touches nothing: no database, no session, no env beyond the two public build stamps. A local spec pins the payload to exactly four keys so this stays true; it fails the moment anything else is added. Liveness stays separate from readiness on purpose — pinging Postgres from a public unauthenticated route is a DoS lever and would down a container on a blip the app itself rides out. The Docker HEALTHCHECK now probes this instead of rendering /en. The production-robots assertion moves out of the staging suite into a daily indexing-monitor workflow. It was the wrong home twice: it only ran when someone happened to touch staging, while the failure it guards is a slow burn — a staging twin quietly accruing index coverage for weeks — and it turned a PRODUCTION fault red inside a suite named for staging. Daily, not uptime.yml's 5 minutes: robots changes only on deploy, so that cadence would re-alert 288 times on one regression. Verified: test:e2e:full 26 passed; the leak guard fails when DATABASE_URL is added to the payload; the monitor's checks run clean against the live properties. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(health): address review — currency check, prod de-index gap, CI wiring Two HIGH findings, both real. The currency check compared against local HEAD, which fails on the NORMAL workflow: pr-merge-gate.sh squashes every feature PR, so right after your change ships, local HEAD holds the unsquashed commits while the image was built from the squash commit — neither contains the other, so it cried "DIVERGED" exactly when the deployment was correct. Any unrelated PR landing on develop did the same, and a check that is red by default gets ignored, costing the very signal this was built to add. Now judged by reachability from origin/develop (with a best-effort fetch, since the deployment can legitimately be newer than this clone), which is stable whatever branch you are on. Also swapped `cat-file -e` for a real ancestry test: it succeeds for any object still in the local store, so a pre-squash or force-pushed commit passed while being unreachable from any branch — the exact case the failure message claimed to catch. The monitor never checked PRODUCTION's X-Robots-Tag. The Caddyfile's prod and staging blocks are near-identical and ~15 lines apart, so copying the staging noindex header up is a plausible edit — it would deindex the live site while robots.txt still said Allow, and every existing check would pass. Mutation-verified: pointed at a host that really does serve noindex, the new check fires. Also: an empty git result folded to zero through Number(""), so any rev-list failure read as "0 behind, 0 ahead" — a green check that compared nothing. Nothing in CI proved BUILD_SHA was wired at all (the Playwright job never touches the Dockerfile, so version is always the "unknown" fallback there), so both image builds now assert the baked value matches github.sha. Dockerfile stamps moved below the COPY layers, where a per-build-changing ENV no longer busts the runner-stage cache. Monitor fixes: the 000000 HTTP code on connection failure, a bounded and ::-stripped echo of a remote body, and the scope note that staging.fooderist.com is the RUMI frontend despite uptime.yml's label. Reverted the Docker HEALTHCHECK to /en. Nothing declares depends_on service_healthy, so the probe's only consumer is a human reading docker ps, and /api/health is dependency-free by design — it answers 200 while every page 500s on an i18n regression. Cheaper but strictly less informative was a bad trade. Corrected two claims of my own that were wrong: robots.txt is BAKED (NEXT_PUBLIC_SITE_URL is a build arg), not decided at runtime, so a wrong posture cannot be fixed by editing the box .env; and this repo and its GHCR images are PUBLIC, so the route's docblock now reasons from that rather than a private-repo assumption, and names the patch-gap oracle it leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Records both in §7 so neither is rediscovered: what /api/health does and pointedly does not mean (ok != database up), why the payload is pinned to four keys, why the Docker HEALTHCHECK still probes /en, and that robots.txt is BAKED so a wrong indexing posture takes a rebuild rather than a box .env edit. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#112) `backend_tag` was derived from `box`, and every self-serve tenant lands on `box: staging` because that is where the control plane runs. So every paying customer was given `:staging` — the develop build — by a default nobody reads. The cost is larger than "unreleased code", which is why this becomes a constant rather than staying a judgement call: `:staging` is republished on every merge to backend `develop`, the staging box's deploy then re-pulls and recreates every tenant pinned to it, and the backend runs a bare `MigrateAsync()` on boot. So a customer's database had develop's EF migrations applied on every merge — and that is not undone by re-pointing the image, so the rollback is not symmetric. Generated entries now always say `latest`. A develop-tracking showcase (`demo`) is a hand-edit at the merge checkpoint, which is the one place a human already reads the entry — the PR-body checklist names `backend_tag`, the field actually visible in Files changed, instead of branching on the box. Decision + blast radius: workspace docs/plans/SOFRA-ONBOARDING-PLAN.md §2b. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Release
develop→main8 commits. Merge ≠ deploy in this repo — see Rollout.
What ships
backend_tagfor generated tenant entries is now the constantlatest(released code), not derived frombox. Every self-serve tenant lands onbox: staging, so the old derivation silently gave paying customers the develop build and applied develop's EF migrations to their database on every backend develop merge. Depends on deploy#82 being released first so the box'sprovision-tenant.shno longer advises undoing it.staging.sofrapiwas.com), plusrobotsrefusing to index a non-canonical copy, and two CI fixes: the bake could never fire, and the migrate image was built in a job that does not run on develop.E2E_REMOTEinterlock that keeps every mutating spec off shared staging./api/healthreporting the commit the image was baked from, and the daily indexing monitor.Rollout (manual, in this order)
Verification after rollout
/api/healthis the point of #110: it is the only thing that distinguishes a rolled box from a months-old image, since everything else about a stale one looks healthy. Check the SHA, not juststatus: ok.Risk
The
backend_tagchange affects only newly generated registry entries — no live tenant is touched, anddemokeeps its hand-written:staging. The staging control plane is a separate compose profile with its own database; prod'ssofraservice is unaffected by it.🤖 Generated with Claude Code