fix(seo): make the trailing-slash redirect relative - #10476
Conversation
#10473 removed a redirect that leaked http://api.anyplot.ai/seo-proxy/… and shipped one that leaks http://anyplot.ai:8080/… instead. Smaller, but the same defect: nginx builds a `permanent` rewrite's Location from $scheme://$host:$server_port, and behind Cloud Run that is plain http on port 8080. Verified live after the deploy — /box-basic/ answered 301 Location: http://anyplot.ai:8080/box-basic absolute_redirect off makes the Location relative. The client resolves it against the URL it actually requested, which is the only value in this setup guaranteed to be correct — hardcoding a scheme and host would work today and rot the next time either changes. The daily bot-serving-check would have caught it on its next run: the guard added in #10473 already rejects an http:// target. It now rejects an internal port explicitly as well, so the next variant of this does not need the scheme to be wrong before it fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes an SEO-related redirect bug in the anyplot-app nginx layer where the trailing-slash canonicalization was emitting an absolute Location header derived from Cloud Run’s internal scheme/port, producing http://…:8080/... for crawlers. It also tightens the scheduled bot-serving monitor to catch internal-port leaks.
Changes:
- Configure nginx to emit a relative
Locationfor the trailing-slash redirect viaabsolute_redirect off. - Add an explicit guard in
bot-serving-checkto reject redirects that include:8080. - Document the incident and fix in
CHANGELOG.mdunder Fixed.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
app/nginx.conf |
Turns off absolute redirects so the trailing-slash 301 produces a relative Location instead of http://$host:8080/... behind Cloud Run. |
.github/workflows/bot-serving-check.yml |
Extends the redirect-leak guard to also fail on :8080 in the redirect target. |
CHANGELOG.md |
Adds a Fixed entry describing the internal-port redirect leak and the guard update. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| slash_target=$(curl -sS --max-time 30 -o /dev/null -A "$GOOGLEBOT" \ | ||
| -w '%{redirect_url}' "$ORIGIN/scatter-basic/") | ||
| case "$slash_target" in | ||
| *"/seo-proxy"*|http://*) | ||
| *"/seo-proxy"*|http://*|*:8080/*) | ||
| echo "::error::trailing-slash redirect leaks or downgrades: $slash_target" |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
`gcloud builds list --project=anyplot` returns three builds from early 2026 and nothing since, because the deploy triggers run in europe-west4 and regional builds are absent from the global list. Read at face value that says nothing has deployed for months, which is what happened in this session: I reported the SEO work as merged-but-not-live, and the owner had to point at a successful build to correct me. Every merge had in fact deployed within about five minutes. The guide now carries the `--region` form of both commands, notes that notify-deployment.yml only records a GitHub deployment rather than performing one, and says plainly that the trustworthy check is probing production — the only method that also catches a build succeeding while shipping the wrong thing, which is precisely what this PR fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
agentic/docs/project-guide.md:906
- The
gcloud builds listexample usescreateTime.date('%H:%M'), which is not a validgcloud --formatfield transform (thedate()transform doesn’t accept strftime-style format strings). As written, the command is likely to fail, which undermines the goal of making deploy checks easy.
gcloud builds list --region=europe-west4 --project=anyplot --limit=10 \
--format="table(id.slice(0,8),status,createTime.date('%H:%M'),substitutions.TRIGGER_NAME,substitutions.SHORT_SHA)"
gcloud builds describe <build-id> --region=europe-west4 --project=anyplot
## The finding
```
$ gh run list --workflow=bot-serving-check.yml --limit 10
2026-08-18 failure (schedule)
2026-08-17 failure (schedule)
2026-08-16 failure (schedule)
...
2026-08-09 failure (schedule)
```
Ten consecutive scheduled runs, all red, on **one line**:
```
expects: <title>anyplot.ai</title>
actual: <title>anyplot.ai — AI-generated plot catalog for 15 libraries</title>
```
Nothing else was broken. `grep -qF` on the full title, copy changed,
check died.
## Why it matters more than a stale string
This run of SEO work edited this workflow **twice** and cited it
repeatedly as the regression cover for precisely the class of change
that has no local verification loop — nginx behaviour, bot serving. It
was failing daily throughout.
The trailing-slash port leak that shipped today is the proof: the guard
existed, it would have caught it, and it was already red so nobody would
have seen it. A monitor nobody reads protects nothing.
Found by an independent audit, not by me — I had asserted the guard's
value in three PR bodies without once checking whether it was green.
## Changes
**Home check matches on the prefix.** `<title>anyplot.ai` still
separates the prerendered page from the SPA shell, whose title is
`any.plot() — any library.` — that separation *is* the property under
test. The full title was never the point and made the check break on
copy edits.
**Empty redirect is now a failure.** `%{redirect_url}` is empty when
there is no redirect, which fell through to the success branch and
printed `OK: trailing slash -> `. So the rewrite disappearing entirely
would have read as a pass. Copilot raised this on #10473 and again on
#10476; neither addressed it.
## After merge
The workflow needs a manual `workflow_dispatch` to confirm the first
green run in ten days — the schedule alone would leave it unverified
until tomorrow. Note it will only pass once #10476 has deployed, since
the trailing-slash assertion is currently correct to fail.
## Not fixed here
Nothing alerted on ten days of failure. The workflow reports into GitHub
Actions and that is the whole notification path. Worth a decision
separately — a monitor that fails silently is only marginally better
than none.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What I shipped wrong
#10473 removed a redirect that leaked the internal proxy URL and replaced it with a smaller version of the same defect. Verified live after the deploy:
Internal port, plain http. The
api.anyplot.ai/seo-proxy/…leak is genuinely gone — this is not a regression to the old state — but nginx builds apermanentrewrite's Location from$scheme://$host:$server_port, and behind Cloud Run that ishttpon8080.Fix
absolute_redirect offmakes the Location relative (/box-basic). The client resolves it against the URL it actually requested, which is the only value in this setup guaranteed to be correct. Hardcodinghttps://anyplot.aiwould work today and rot the next time the scheme, host or port changes.The guard nearly earned its keep
bot-serving-checkalready rejects a trailing-slash target starting withhttp://, so it would have failed on its next daily run — the check written in #10473 was correct, it just runs daily rather than at deploy. It now also rejects an internal port explicitly, so the next variant of this does not need the scheme to be wrong before it fails:Note on verification
nginx changes are only observable after deploy — this repo has no local loop for them, which is stated in
CLAUDE.mdas a known gap and is exactly how this got out. The livecurlabove is the verification; the guard is what makes the next one cheaper to catch.🤖 Generated with Claude Code