Skip to content

infra(app): deploy the frontend through a smoked candidate revision - #11207

Merged
MarkusNeusinger merged 6 commits into
mainfrom
infra/app-candidate-chain
Sep 2, 2026
Merged

infra(app): deploy the frontend through a smoked candidate revision#11207
MarkusNeusinger merged 6 commits into
mainfrom
infra/app-candidate-chain

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Why

api/cloudbuild.yaml already deploys through a candidate revision (--no-traffic → smoke → update-traffic, #10821). app/cloudbuild.yaml did not — it deployed straight onto live traffic.

That is backwards, because the app service is the one carrying the whole crawler path in app/nginx.conf: the $is_bot map, the location = bypasses for robots/llms/sitemap/og, the @seo_proxy upstream. That is the very file whose breakage served every bot an HTTP 502 for four weeks (2026-06-12 → 2026-07-09, a proxy_ssl_verify_depth default against a 4-deep Let's Encrypt chain) while humans, Plausible and CI all saw a healthy site. Until now a typo in it went live unchecked and the daily bot-serving-check.yml was the only net — a night later.

The sibling repo kurrentschrift carries the same chain in its app/cloudbuild.yaml; transferred here per the sibling rule.

Every changed line

Header comment (new). Records the shared candidate-rollout pattern (and the two steps where the app and API chains differ) and why this service earns the same care as the API.

build-image step — unchanged args, gains id: 'build-image'. Both tags are still built; only the push of :latest moves.

Push step — was push --all-tags …/anyplot-app, now pushes only …:$BUILD_ID, with id: 'push-image' / waitFor: ['build-image']. A --all-tags push before the rollout means :latest names an image that may never have served a request. :latest gets its own step at the bottom, behind promote.

deploy step — every existing flag (--memory 512Mi, --cpu 1, --timeout 60, --min-instances 0, --max-instances 3, --port 8080, gen2, --cpu-throttling, --concurrency 15, --allow-unauthenticated) is unchanged, including the scale-to-zero decision from #10812. Three flags are added:

  • --no-traffic — the revision goes up serving nobody.
  • --tag=candidate — gives it a stable tag URL to probe.
  • --revision-suffix=b$BUILD_ID — a deterministic revision name so promote can target exactly the revision this build smoked, never a concurrent build's newer one. The b prefix is required: Cloud Run wants the suffix to start with a lowercase letter and $BUILD_ID is a UUID that usually starts with a digit.
    Plus id: 'deploy' / waitFor: ['push-image'].

smoke step (new). Resolves the candidate tag's URL and asserts the tag points at this build's revision — before and again after the probes. candidate is a shared tag, so a concurrent build could move it mid-smoke and this build would then have smoked someone else's revision while promoting its own; a competing build only ever tags its OWN revision and never ours back, so seeing our revision at both ends means every probe in between hit it. Between them, six probes, every one retried (--retry 5 --retry-delay 5 --retry-all-errors) because at min-instances 0 the candidate is always cold and the crawler probes additionally wait on the first upstream call to api.anyplot.ai:

UA path assertion what it proves
browser / <div id="root"> humans still get the SPA shell
Googlebot / <link rel="canonical" href="https://anyplot.ai/" /> the $is_bot@seo_proxy hop ran
Googlebot /scatter-basic …href="https://anyplot.ai/scatter-basic" /> the request URI reached the upstream
Googlebot /robots.txt User-agent: Bytespider served by the location = bypass, not the proxy
Googlebot /llms.txt # anyplot same
Googlebot /llms.txt content-type carries charset=utf-8 its em dashes don't decode as Latin-1 mojibake

The canonical link is the prerender marker. anyplot has no committed prerender files and no marker comment, but the SPA shell (app/index.html) carries no <link rel="canonical"> at all, while every api/routers/seo.py page emits href="https://anyplot.ai{route}" — so one grep -F proves both "this is the prerendered page" and "it is the right route". Verified live on all twelve current bot routes (see below).

promote step (new). gcloud run services update-traffic --to-revisions=anyplot-app-b$BUILD_ID=100, never --to-latest (which could promote a concurrent build's unsmoked revision).

push-latest step (new). :latest moves only after this build promoted, so the tag can no longer name an image that was never rolled out (which the old --all-tags push before the deploy did). It is not a cross-build guarantee — two overlapping deploys still race for the tag — which is fine, because neither pipeline reads :latest: both deploy :$BUILD_ID.

timeout: '1200s' (new). deploy → cold-start smoke → promote does not fit the default 10 min with a retrying probe. Matches api/cloudbuild.yaml.

images: and options: are unchanged.

Evidence

The smoke script was expanded through the Cloud Build substitution rules (${_VAR}/$BUILTIN replaced, $$ → literal $ applied last so an escape can never be re-read as a substitution), then syntax-checked and executed:

  • bash -n on the expanded script: clean.
  • A scan of the whole file for substitutions Cloud Build cannot resolve (after removing $$ escapes): none. This matters — $request_uri appears in a comment inside the script and had to be written $$request_uri; an unescaped one fails the build before a single step runs, and YAML comments (which is where $is_bot sits) are stripped before substitution and are safe.
  • The expanded assertions, run against the live Cloud Run origin of anyplot-app (reads only, no deploy, no writes):
smoke against candidate https://anyplot-app-r3tvmejsmq-ez.a.run.app (live-origin)
OK: /
OK: /
OK: /scatter-basic
OK: /robots.txt
OK: /llms.txt
smoke OK
  • Negative control — the same script with the deep-route probe sent as a browser UA (i.e. served the SPA shell, which is exactly the regression this smoke exists to catch):
a crawler did not get the prerendered page for this route (/scatter-basic is missing: <link rel="canonical" href="https://anyplot.ai/scatter-basic" />)
EXIT=1
  • The canonical marker was confirmed against every static bot route at the origin — /, /plots, /specs, /libraries, /legal, /mcp, /about, /palette, /map, /stats, /scatter-basic, /scatter-basic/python/matplotlib — all 200, all with href="https://anyplot.ai{route}", none with <div id="root">.

What the tag check does not prove

status.traffic is the control-plane tag assignment, not proof of which revision answered a given request. candidate is reused across builds, so a probe issued while a re-assignment is still propagating can reach the previous revision. Closing that needs either a build-unique tag (tag URLs then accumulate on the service without bound) or a build id the app serves in its own response (an app/Dockerfile + Vite change). Neither is taken here; both the smoke step's comment and agentic/docs/project-guide.md now say so. The residual is narrow and its worst case is mild — it needs the previous candidate to pass every probe too, so the failure would be "promoted a revision we believed we smoked", not "shipped a page we know is broken", and it is still strictly better than the no-smoke chain this replaces.

Decisions taken here (routine, flagged for override)

  • Each probe fetches to a file instead of piping into grep -q. grep -q exits at the first match and SIGPIPEs curl, so the pipe form (which api/cloudbuild.yaml and the kurrentschrift original both use) passes only because bash -ceu carries no pipefail. Adding pipefail — a normal hardening instinct — would red every deploy. I hit exactly that while testing. The file form is correct under any shell options, checks curl's own exit status, and lets each failure name which probe failed and what it expected. This is the one place the file deliberately does not mirror api/cloudbuild.yaml byte for byte; happy to align if you'd rather have strict parity.
  • The canonical link rather than a title as the crawler marker. A title is copy and goes stale (anyplot's bot-serving-check.yml has a comment recording ten red nights from exactly that); the canonical is generated from the route and cannot drift.
  • A deep route in addition to the home page. Both go through the same location / mechanism, so the deep route is not about coverage of the map — it is the only probe that would notice $request_uri failing to reach the upstream.
  • api/cloudbuild.yaml untouched. Its push-latest still waits on build-image rather than promote, so the API's :latest can still name an image that never served. Left for the author / the parallel api/ work rather than taken here — see below.

Findings for the author

  1. api/cloudbuild.yaml's push-latest sits behind build-image, not promote. Same one-line fix as here (waitFor: ['promote']); I stayed out of that file to avoid colliding with the parallel work in api/.
  2. api/cloudbuild.yaml has the same shared-tag race this PR just closed — it resolves candidate once before its probes and never re-checks, so two overlapping API builds can promote a revision that was not the one smoked. The fix is the four lines added here; same reason for not applying it in that file. agentic/docs/project-guide.md now records the asymmetry explicitly rather than claiming the guarantee for both pipelines.
  3. This PR does not run the chain — a Cloud Build run is a production deploy. The assertions are proven against the live origin, but the first real exercise of --no-traffic/--tag=candidate/update-traffic on the anyplot-app service happens on the first deploy after merge. Worth watching that one build.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke

app/cloudbuild.yaml deployed straight onto live traffic while
api/cloudbuild.yaml already had the candidate chain. This service is the
one that carries the whole crawler path in app/nginx.conf — the $is_bot
map, the `location =` bypasses, the @seo_proxy upstream — and that is
the file whose breakage served every bot an HTTP 502 for four weeks in
2026 while humans, Plausible and CI all saw a healthy site. Until now a
typo in it went live unchecked and the daily bot-serving monitor was the
only net, a night later.

The chain is now build -> push $BUILD_ID -> deploy --no-traffic
--tag=candidate --revision-suffix=b$BUILD_ID -> smoke the candidate's
tag URL -> update-traffic to exactly that revision -> push :latest.

The smoke probes both halves of the split, only one of which a human
ever sees: a browser UA must get <div id="root">, Googlebot must get the
prerendered page. The marker is the canonical link — the SPA shell
carries none at all, and its value names the route, so one grep proves
both that the bot hop ran and that the right page came back. A deep
route is probed alongside the home page, which covers the request URI
reaching the upstream. robots.txt and llms.txt must come from the
`location =` bypasses, and llms.txt must keep its utf-8 charset.

Each probe fetches to a file instead of piping into `grep -q`: grep
exits at the first match and SIGPIPEs curl, so the pipe form passes only
because `bash -ceu` carries no pipefail. That also lets a failure name
which probe failed and what it expected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot AI balanced review requested due to automatic review settings September 2, 2026 21:14
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The shared candidate tag has a race that can promote an untested revision, and several deployment guarantees are inaccurate.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a smoke-tested candidate rollout for the frontend Cloud Run service.

Changes:

  • Deploys and probes a no-traffic candidate before promotion.
  • Delays the :latest push until promotion.
  • Documents the deployment workflow.
File summaries
File Description
app/cloudbuild.yaml Adds candidate deployment, smoke tests, and promotion.
agentic/docs/project-guide.md Documents candidate rollouts.
CHANGELOG.md Records the infrastructure change.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/cloudbuild.yaml
Comment thread app/cloudbuild.yaml Outdated
Comment thread CHANGELOG.md Outdated
Comment thread agentic/docs/project-guide.md
Comment thread app/cloudbuild.yaml Outdated
Review findings on #11207:

- The `candidate` tag is shared, so a concurrent build could move it
  between the check before the probes and the last probe — this build
  would then have smoked another revision while promoting its own. The
  smoke now re-asserts the tag after the probes. A competing build only
  ever tags its own revision and never ours back, so seeing our revision
  at both ends means every probe in between hit it.
- The `:latest` comment claimed more than waitFor can deliver. It orders
  steps within one build; two overlapping deploys still race for the tag.
  What it does guarantee is that the tag never names an image that was
  not rolled out, and neither pipeline reads it — both deploy $BUILD_ID.
- "mirrors api/cloudbuild.yaml step for step" was wrong in the header
  comment, the changelog and the project guide: the API still pushes
  :latest alongside the deploy and ends with get-url. All three now say
  "the same candidate-rollout pattern" and name the difference.
- The project guide said a failed revision "never serves a request"; the
  smoke's own requests go to exactly that revision. It now says live
  traffic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot AI review requested due to automatic review settings September 2, 2026 21:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The production deployment chain cannot be fully validated until its first live Cloud Build run.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Every bullet in the file cites its PR; this one was missing it. The entry
also now mentions the post-probe candidate-tag re-assertion added in the
review round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot AI review requested due to automatic review settings September 2, 2026 21:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The production rollout remains unexercised, and its documentation overstates the API pipeline’s concurrency protection.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread agentic/docs/project-guide.md Outdated
Review finding on #11207: the project guide claimed the candidate
guarantee for both cloudbuild files, but api/cloudbuild.yaml checks the
shared `candidate` tag only before its probes. Two overlapping API builds
can therefore still promote a revision that was not the one smoked.

The paragraph now says the guarantee holds unconditionally for the app
and one-build-at-a-time for the API, and the "the chains differ" list
carries the recheck as the second difference — with the note that the
same four lines would close it on the API side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot AI review requested due to automatic review settings September 2, 2026 21:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reusing the candidate tag can smoke the previous revision while Cloud Run routing propagates.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread app/cloudbuild.yaml
Comment thread agentic/docs/project-guide.md Outdated
Review finding on #11207: both tag checks read status.traffic, which is
the control-plane tag assignment, not proof of which revision answered a
given request. Because `candidate` is reused across builds, a probe
issued while a re-assignment is still propagating can reach the previous
revision.

Documented rather than closed, in the smoke step and in the project
guide, with the two ways to close it named: a build-unique tag (tag URLs
then accumulate on the service without bound) or a build id the served
response itself carries. The residual is narrow — it needs the previous
candidate to pass every probe too, so the worst case is promoting a
revision believed smoked, not shipping a page known to be broken.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot AI review requested due to automatic review settings September 2, 2026 21:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The production rollout path has not yet run, and one comment still overstates its revision-verification guarantee.

Review details

Suppressed comments (1)

app/cloudbuild.yaml:189

  • This conclusion contradicts the control-plane limitation documented above: matching status.traffic before and after only proves that the tag assignment did not change; it does not prove every HTTP probe reached this revision while routing propagated. Rephrase this so future maintainers do not mistake the recheck for serving-revision verification.
        # while promoting its own. A competing build only ever tags its OWN
        # revision and never ours back, so seeing our revision at both ends
        # means every probe in between hit it.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings September 2, 2026 21:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The production deployment chain receives its first end-to-end exercise only after merge and requires human monitoring.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@MarkusNeusinger
MarkusNeusinger merged commit 511a52d into main Sep 2, 2026
9 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the infra/app-candidate-chain branch September 2, 2026 21:57
MarkusNeusinger added a commit that referenced this pull request Sep 2, 2026
CHANGELOG only; both sides add entries under Added and both are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
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.

2 participants