feat(ci): add PR check for underscores in URL paths - #704
Merged
Conversation
The site's Nginx config carries an unconditional redirect:
rewrite ^/(.*)_(.*)$ /$1-$2 permanent;
Any request whose path contains an underscore is 301'd to the hyphenated
equivalent whether or not the underscored file exists. A file added with an
underscore in its path is therefore unreachable by construction: every request
for it arrives at the origin hyphenated, finds nothing, and 404s. A link with
an underscore costs a 301 round-trip and breaks outright when no hyphenated
target exists.
This adds a standalone check that catches both cases before merge:
- Link targets on lines the PR adds or modifies in Markdown under docs/,
covering Markdown inline and reference links plus raw HTML href/src.
- Paths of files the PR adds under docs/ or overrides/.
Scoped to the diff, with no grandfathering: pre-existing underscored paths are
untouched, but a new line may not introduce one. Modified files are excluded
from the path check, since their path already existed.
Exemptions follow the mechanism rather than taste — each is something the
rewrite cannot reach:
- Anchor fragments (#stop_timestxt). A fragment never leaves the browser.
These are GTFS file and field names, underscored by the spec.
- Query strings. Nginx matches rewrite against the decoded URI and reattaches
the query untouched.
- External URLs and mailto:. Another host serves those.
Reporting mirrors the syntax check: GitHub Actions error annotations on the
offending line (which fork PRs receive too) plus a findings JSON consumed by
the review-posting and enforce steps. Findings carry no suggestion block —
swapping _ for - would point at a page that does not exist yet, so the repair
is a human decision.
The checker is stdlib-only, so this check needs no pip install step and stays
independent of requirements.txt. Like the syntax check, it is deliberately not
a required status check: it makes the problem visible without blocking merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
post-review.py hardcoded a summary body announcing a syntax check, which is wrong for any other check reusing it. Read REVIEW_BODY from the environment instead, defaulting to the existing wording so the syntax check is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
skalexch
approved these changes
Aug 14, 2026
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.
What
Adds a standalone PR check that fails when a pull request introduces an underscore into a path that becomes a public URL:
docs/— Markdown inline links, reference definitions, and raw HTMLhref/src.docs/oroverrides/(photos, scripts, stylesheets, new pages).Two new files, plus a one-line change to the existing review poster:
scripts/ci/check-underscores.py.github/workflows/pr-underscore-check.ymlscripts/ci/post-review.pyREVIEW_BODY, defaulting to the current wordingWhy
The site's Nginx config carries an unconditional redirect:
Any request whose path contains an underscore is 301'd to the hyphenated equivalent, whether or not the underscored file exists. Consequences:
The convention dates back to Google advising the project to move the site off underscores for search ranking; the redirect was the practical way to do that at this site's size. This check stops new content from adding paths that depend on the redirect, or that the redirect makes permanently unreachable.
Scope and exemptions
Diff-only, with no grandfathering — pre-existing underscored paths are untouched, but a new line may not introduce one. Modified files are excluded from the path check, since their path already existed.
Exemptions follow the mechanism rather than taste; each is something the rewrite cannot reach:
](#stop_timestxt)) — a fragment never leaves the browser. These are GTFS file and field names, underscored by the spec and unrenameable.rewriteagainst the decoded URI and reattaches the query untouched.mailto:— another host serves those.Reviewer notes
pr-syntax-check.yml: it shows a red X and inline comments without blocking merge. Say the word if it should be required.suggestionblock, because swapping_for-would point at a page that does not exist yet.requirements.txt.post-review.pychange is additive: withoutREVIEW_BODYset, the syntax check posts exactly the same text as before.mailto:cases. This PR adds no underscored paths, so the check should come back green on itself.🤖 Generated with Claude Code