Skip to content

release: staging control plane, /api/health, and the backend_tag fix - #113

Merged
mahmutkaya merged 8 commits into
mainfrom
develop
Jul 31, 2026
Merged

mahmutkaya merged 8 commits into
mainfrom
develop

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Release developmain

8 commits. Merge ≠ deploy in this repo — see Rollout.

What ships

Rollout (manual, in this order)

# 1. No migrations in this release — nothing to run first. Confirm:
#    git diff origin/main origin/develop --stat -- prisma/migrations   ->  empty
# 2. Roll the box (deploy/.ssh/staging.sh):
cd /opt/rumi/deploy && docker compose -f docker-compose.prod.yml pull sofra && docker compose -f docker-compose.prod.yml up -d sofra

Verification after rollout

curl -s https://sofrapiwas.com/api/health          # version == this release's SHA
npm run test:e2e:staging                            # read-only, against staging.sofrapiwas.com

/api/health is 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 just status: ok.

Risk

The backend_tag change affects only newly generated registry entries — no live tenant is touched, and demo keeps its hand-written :staging. The staging control plane is a separate compose profile with its own database; prod's sofra service is unaffected by it.

🤖 Generated with Claude Code

mahmutkaya and others added 8 commits July 31, 2026 10:53
…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>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@sonarqubecloud

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit c3b6403 into main Jul 31, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant