Repository navigation
Relative links in README.md 404 on the PyPI package page #85
Description
This was generated by AI during triage.
Spun out of #84, which measured the reverse case. Where #84 is about paths the gate cannot see, this is about links the gate sees, passes, and is right to pass, that are broken anyway on the surface most users read.
The gap
PyPI does not rewrite relative links in a Markdown long description. A relative link that works on GitHub resolves against https://pypi.org/project/<name>/ on the package page and 404s.
Verified against the live rendered page for faststream-concurrent-aiokafka 0.6.5, reading the href PyPI actually emits:
raw href: planning/decisions/2026-07-28-control-signals-not-honoured.md
resolved: https://pypi.org/project/faststream-concurrent-aiokafka/planning/decisions/… → 404
raw href: LICENSE
resolved: https://pypi.org/project/faststream-concurrent-aiokafka/LICENSE/ → 404
Both confirmed 404, not inferred from how the renderer is supposed to behave.
How wide
Published state, from the PyPI JSON API across every org package: 23 of 25 published packages carry relative links in their uploaded README, 37 in total, every one dead on the package page.
| Target | Packages |
|---|---|
LICENSE |
23 |
./examples |
10 (modern-di-* integrations) |
docs/providers/github.md, semvertag/ioc.py |
semvertag |
planning/decisions/… |
faststream-concurrent-aiokafka |
architecture/free-threading.md |
lite-bootstrap |
The last two point at directories #67 deleted outright, so those are broken in the checkout as well and merely have not been released yet.
At HEAD, across the 27 repos declaring a readme in pyproject.toml: 40 relative links, of which 38 sit in the 25 READMEs that ship to PyPI. All 40 resolve in the checkout, so every gate is green.
It is growing rather than shrinking. chat-app and compose2pod were the only two clean packages at their last release; both have acquired a relative link at HEAD since.
Why nothing catches it
The offline gate checks that a relative link resolves in the checkout. These do. The gate is not wrong; it is answering a different question than the one the package page asks. Nothing anywhere checks a README against the surface it is actually published to.
Worth naming plainly, because it cuts against standing guidance: #82 says that "rewriting absolute self-links to relative removes the class of problem, and where possible it is the better fix." For README.md specifically, that is backwards. The planning/ → docs/adr/ migration rewrote faststream-concurrent-aiokafka's README ADR link from absolute to relative, and it will be a 404 on the package page from the next release.
The same file often carries both forms already. In every modern-di-* README the LICENSE badge links absolutely and works on PyPI, while the License heading a hundred lines down links relatively and does not:
[](https://github.com/modern-python/modern-di-typer/blob/main/LICENSE)
## 📝 [License](LICENSE)Options
1. Rewrite README relative links to absolute self-links. The existing --remap from #82 then checks them against the working tree, so gate coverage is preserved exactly and no new machinery appears anywhere.
Measured end to end on modern-di-typer, over a clean git archive HEAD export with the shipped remap block:
relative (today): 7 OK / 0 errors
rewritten to absolute: 7 OK / 0 errors ← identical coverage
absolute, examples/ deleted: 6 OK / 1 error ← still catches the break
The error names the remap, so the diagnostic is not degraded either. --remap already handles tree/main/ as well as blob/main/, so directory links like ./examples and docs/adr/ convert cleanly.
Cost: longer links in prose. No config drift, since the remap interpolates ${{ github.repository }} and holds no repo name to copy wrong.
2. A test asserting the declared README contains no relative Markdown links. Reads the readme key from pyproject.toml rather than assuming a filename, since two repos use lowercase readme.md. Stops the class from regrowing, which (1) alone does not. Copied per repo, as #66 copied the gate. Composes with (1) and is the natural guard for it.
3. Accept and document. Nobody clicks a LICENSE link from PyPI. Weak on its own terms: two of the 37 already point at directories that no longer exist, and the count is rising, not falling.
(1) and (2) compose, and (2) without (1) just turns 38 CI failures red at once.
Scope
The 25 repos that publish to PyPI. The two *-sqlalchemy-template repos are not published and carry no gate, so they are out either way.
Deciding whether (2) is worth the per-repo copy is the work here. (1) is mechanical once decided.
Activity
lesnik512 commentedon Sep 7, 2026
Outcome: option (1), plus the rule as an instruction. No test, no ADR.
Shipped across all 25 publishing repos. What landed differs from every option as written, so the
reasoning is recorded here rather than reconstructed from 26 PRs.
What shipped
38 link occurrences rewritten to absolute
blob/main/self-links,tree/main/for adirectory, across 25 repos. 36 distinct targets;
eof-fixerlinksLICENSEtwice andfaststream-concurrent-aiokafkacites ADR-0003 twice.The rule added to
AGENTS.mdin all 25, byte-identical:.githubtook the ADR deletion andAGENTS.mdtrim, but not the rule: it publishes no longdescription. Same for the two
*-sqlalchemy-templaterepos.Why no test
CLAUDE.mdimportsAGENTS.mdin all 28 repos, so the rule is loaded into every agent turn. In anagent-driven org that is enforcement, not documentation, which is the specific reason a test was
judged premature rather than unnecessary.
The two documents that actually produced these 38 links both pointed the wrong way. #82 states that
rewriting absolute self-links to relative is "the better fix" where possible, and the now-deleted
ADR 0003 justified absolute README URLs on the grounds that GitHub renders them directly, never
mentioning PyPI. Correcting the guidance removes the cause, which is a different position from
hoping nobody does it again.
Falsification condition, deliberately sharp: one relative link appearing in any README after
this. Not two, and not a scheduled re-audit. A single instance means an agent wrote one with the
corrected
AGENTS.mdin context, which is precisely the event that disproves the call. At thatpoint the test is the answer and this issue gets reopened.
Why
mainand not a release tagA PyPI long description is frozen per release, so
blob/main/in a shipped README points atmainforever and an old release's page can rot if a target moves later. Tag-pinned
blob/<tag>/wouldbe permanently correct.
Rejected because #82 deliberately excludes tag-pinned URLs from the gate, so those links would be
correct and unchecked, and writing the tag in needs release-time substitution no repo has.
mainbounds the rot to old releases while keeping every link gate-checked. Revisit if a dead link is
reported on an old release page.
Rejected:
ref-fixer, a shared checker packageRecording this because it is the alternative most likely to be re-proposed.
The design was a PyPI package following the
eof-fixerpattern (in every repo'slintgroup,called from
justfilelint-ci), carrying both this check and #84's ADR-citation check, withautofix for the README rewrite.
ref-fixeris available on PyPI.It was rejected as too much machinery for two checks. The
eof-fixerprecedent cuts both ways: itsimplementation is 28 lines, so the org has already paid full product ceremony for something smaller,
but that is an argument that the ceremony is affordable, not that it is warranted here.
Also rejected along the way, and worth not re-deriving:
**/*.py. Measured exactly vacuous: across 26 repos it adds onechecked link, and that link is a directory. It also turns
db-retryred with 9 PostgreSQL DSNparse errors that
--scheme,--excludeand every regex variant fail to suppress, because thefailure happens at URL construction before filtering. Only
--exclude-pathescapes it, whichre-parameterises the gate block that The offline link gate never checks absolute self-links #82 deliberately de-parameterised.
--offlineexcludes them in the consuming repo,.github's weekly run only scans its own tree,and no test references
brand/projectsat all. Offline, only the slug segment is checkable.Dropped with the rest of the machinery; the gap is real and unclosed.
CONTEXT.mdsurface entry. Dropped as overhead once the tool was dropped.Still open, deliberately
current package pages, and those are frozen. They correct only as each package cuts its next
release. Nothing here fixes a page that is already live.
.github's own README is out of scope and keeps its relative links, which is fine because itships nowhere.
Closing. Reopen on the falsification condition above.