Emit robots.txt, and gate the base URL it is derived from - #60
Merged
Conversation
The old platform serves a robots.txt today and this site emitted none, so the cutover was not a return to a previous state, it was a move from having crawl directives to having none on a site that has had them for years. Hugo emits the file only when enableRobotsTXT is set, which is why /robots.txt answered 404 through the first production deploy. The Sitemap: line is the load-bearing part rather than any rule. Across the interim hostname's first full day no crawler fetched sitemap.xml or feed.xml once, every request to either coming from curl: a crawler is told where a sitemap is rather than guessing, and the only thing telling them is the file the cutover deletes. Every Disallow the old platform serves names a WordPress path this site does not have, so the content that preserves today's behavior is User-agent: *, no Disallow, and the sitemap. The theme's template already derives that URL from the built baseURL, so setting the flag is the whole change and there is no second edit to remember at the cutover. Verified across two base URLs: the mirror advertises its own sitemap and a production build advertises the site's. /robots.txt/ with a trailing slash is in the redirect contract and sent visitors to the home page. It now resolves to the real file, fixed in build-redirects.py rather than in the generated map, since the map is rewritten from the capture and a hand edit does not survive. Verified byte-identical regeneration otherwise: the map diff is that one line. /osd.xml/ stays pointed at the home page, being an OpenSearch description this site does not emit. The check is worth more than the file. Every contract list is path-only and check-live-urls.sh joins whatever base it is handed, which is what lets one contract cover four environments and also means a build baked with the wrong host passes all 1,245 URLs while every canonical tag and sitemap entry names another site. robots.txt is the one artifact whose absolute URL a gate can read without being told what to expect, so the parity check compares it against the origin read from the home page's canonical link. Four failures gated, all four demonstrated failing before the check was trusted: file absent, no Sitemap line, a line naming another origin, and a line advertising a sitemap not built. Released to the local production mirror and checked live, since this touches deploy/maps and hugo.yaml: 1245 URLs honored, /robots.txt/ 301s to /robots.txt, and that file answers 200 as text/plain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two overclaims in comments, both real. The hugo.yaml note said no crawler has ever fetched this site's sitemap unprompted, where the evidence is one day of traffic on one hostname, and the docstring said the site answered 404 for years, where the behaviour is a config flag rather than a duration. Each now says what was observed and over what window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four places said this check makes a wrong HUGO_BASEURL visible. It does not, and the reason is structural rather than a bug: the advertised sitemap origin and the canonical origin it is compared against are both derived from baseURL, so they agree whenever the build is coherent, including when baseURL held the wrong value for the environment being deployed to. Nothing inside the artifact can see that, which is exactly why the VPS side reads the origin out of the deployed sitemap.xml, og:url and feed.xml and reports the counts either way. Raised in review, in all four places at once, and correct. Each now states what the comparison proves, internal consistency, and what it still catches: an origin written rather than derived, where a committed static/robots.txt shadowing the template is how that happens, and a sitemap advertised but never built. A gate described as catching more than it catches is worse than no gate, because the next person stops looking for the failure it does not see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…row two claims Three findings, all correct. read_text with strict UTF-8 raises out of the entire parity run on an invalid byte, so the one gate written to report a bad robots.txt would stack-trace on one instead of failing normally. Confirmed both ways: strict raises UnicodeDecodeError at position 67 on a file carrying 0xff, and errors=replace reports through the ordinary path. The file most likely to carry it is a committed static/robots.txt, which is the case this check is for. The docstring still said two failures where there are four, and both the docstring and the README said the Sitemap: line is the only absolute URL this gate can read. It is not, since site_origin reads the canonical link and collect_refs reads absolute asset references. The true and narrower point is that it is the only place an origin is compared rather than joined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings, both correct. The advertising line printed before the sitemap-existence check ran, so the one failing case read as a pass on the line immediately above its own failure. Now every branch prints exactly once, after deciding: missing-sitemap reports "1 advertised sitemap(s) not built" and only a clean run prints what it advertises. The TODO paragraph describing the cutover loss still said "since this site emits none" in the present tense, one bullet below the entry saying the file is now built. It reads as history now, which is what it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The message named enableRobotsTXT as the cause. A partial build, a deleted file, and the wrong output directory reach the identical state, so asserting one sends a reader to verify a setting that is already correct. It now names the path it looked for, offers the likely cause as likely, and says the alternatives are indistinguishable from here, which is the same shape the orphan messages use for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"FAIL robots: 1 problems" was the report. The noun was a bare string and a count is always printed beside it, so every label with a plural noun had the same defect: the pre-existing gallery one prints "1 stray nodes" on a single stray node, which is the shape that made the new noun wrong in the same way. Now a (singular, plural) pair chosen by count. The default stays "missing" and needs no pair, being count-neutral already. Verified across both labels at 1 and at many, and the clean run is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR ensures the site emits a robots.txt (including a sitemap pointer derived from the built baseURL) and adds a deploy gate to verify that the generated robots.txt is present, internally consistent with the build’s origin, and only advertises sitemaps that were actually built. It also updates redirect generation so /robots.txt/ lands on the real file.
Changes:
- Enable Hugo
enableRobotsTXTso/robots.txtis emitted and includes aSitemap:line derived from the builtbaseURL. - Add a
check_robots()gate tocheck-url-parity.py, and improve failure summary wording to handle singular/plural properly. - Update redirect generation and the committed slug map so
/robots.txt/redirects to/robots.txt, plus documentation updates capturing the decision and its limits.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Updates the operational record/decision log for robots.txt, including the gate’s scope and limitations. |
| hugo.yaml | Enables enableRobotsTXT: true to emit robots.txt. |
| deploy/maps/slugs.map | Redirects /robots.txt/ to /robots.txt instead of /. |
| checks/README.md | Documents what the robots gate checks and the hard limit it cannot detect (wrong external base URL). |
| checks/check-url-parity.py | Adds check_robots() and improves failure noun handling (singular/plural), wiring the new gate into the parity run. |
| checks/build-redirects.py | Special-cases /robots.txt/ as a well-known root file redirect during map generation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Replaces #59, which GitHub closed when its base branch was deleted on the #58 merge. Same seven commits, rebased onto
developwith #58's two commits dropped, so the diff is only this change: 6 files.Two things carried over from #59 that matter.
#59 never ran CI, and I reported it as green.
test-pull-request.ymltriggers onpull_request: branches: [main, develop], and #59 was opened against a feature branch to stack it, so the validation workflow never fired. Itschecks=2/2was two skipped jobs. This PR targetsdevelop, so the gate runs for real.Six review rounds on #59 found six things, all accepted, none declined. Every fix is in the commits here, and the round-by-round exchange is on #59 for the record:
HUGO_BASEURLvisible. It cannot — both sides of the comparison derive frombaseURL— withdrawnread_textat strict UTF-8 would raiseUnicodeDecodeErrorand take down the whole parity run, in the one gate written to report a badrobots.txt— fixedrobots : built, advertising …above its own failure — fixedenableRobotsTXTas the cause where three causes are indistinguishable — fixedFAIL robots: 1 problems, which the pre-existing gallery label shared — fixed for bothcoverage=PARTIALat 5 of 6 files held in every one of those rounds, which the runbook says a re-request does not cure. Worth watching whether it clears here now that the base isdevelop.The old platform serves a
robots.txttoday and this site emitted none, so the cutover was not a return to a previous state. It was a move from having crawl directives to having none, on a site that has had them for years, and the sitemap pointer went with them. Hugo emits the file only whenenableRobotsTXTis set, which is why/robots.txtanswered 404 through the first production deploy.The Sitemap: line is the load-bearing part, not the rules
Across the interim hostname's first full day, every request for
sitemap.xmlandfeed.xmlcame fromcurl— the deploy gate's or the host side's — and none from a crawler, while/robots.txtwas requested nine times and 404'd every time, five of those from real agents on a name with no inbound links. A crawler is told where a sitemap is rather than guessing it, and the only thing telling them is the file the cutover deletes.Every
Disallowthe old platform serves names a WordPress path this site does not have, so the content that preserves today's behaviour isUser-agent: *, noDisallow, and the sitemap:The theme's template already derives that URL from the built
baseURL, so setting the flag is the whole change and there is no second edit to remember at M7b. Verified across two base URLs rather than reasoned about: a mirror build advertiseshttps://blog.home.insanegenius.net/sitemap.xmland a production build advertises the.comone, from the same bytes.What
check_robotsgates, and what it does notFour failures, each demonstrated failing before the check was trusted:
robots.txtabsent<public>/robots.txt does not exist - likely enableRobotsTXT is unset in hugo.yaml, though a partial build or the wrong output directory look identical hereSitemap:linerobots.txt carries no Sitemap: line - a crawler will not find the sitemap unaidedhttps://blog.insanegenius.net/sitemap.xml (this build's origin is https://blog.insanegenius.com)https://blog.insanegenius.com/news-sitemap.xml (advertised, but news-sitemap.xml was not built)Each exits 1, and each prints exactly one summary line, after deciding rather than before.
It does not detect a wrong
HUGO_BASEURL. The advertised origin and the canonical origin it is compared against both derive frombaseURL, so they agree whenever the build is coherent, including whenbaseURLwas wrong for the target environment. Nothing inside the artifact can see that, so the gap stays on the side that knows which host it is serving: the VPS agent reads the origin out of the deployedsitemap.xml,og:urlandfeed.xml, which is how the first production deploy was cleared.What the origin comparison is genuinely for is an origin written rather than derived: a committed
static/robots.txtshadowing the template, with the old platform's.comsitemap line pasted into it, is the mistake it catches./robots.txt/reaches the file rather than the home page/robots.txt/, with a trailing slash, is a URL the old platform served and is in the redirect contract. It resolved throughslugs.mapas an unresolvable attachment slug, which sends a visitor to the home page — right for a slug nothing claims, wrong for a file that now exists.Fixed in
build-redirects.pyrather than in the generated map, because the map is rewritten from the capture and a hand edit does not survive the next regeneration. Confirmed by regenerating first and diffing: the committed maps were byte-identical to a fresh run, so the map diff here is exactly the one line./osd.xml/stays pointed at the home page deliberately, being an OpenSearch description this site does not emit.Verification
This touches
deploy/maps/andhugo.yaml, so the local mirror gate applies rather than CI's green being sufficient:Re-verified after the rebase onto
develop: build clean under--panicOnWarning, parity gate PASS.Production still answers 404 for the file until a deploy carries this, which is recorded in
TODO.mdrather than assumed.🤖 Generated with Claude Code