Repository navigation
Relative links in README.md 404 on the PyPI package page #85
Description
Activity
- addedbugSomething isn't workingSomething isn't workingneeds-triageMaintainer needs to evaluate this issueMaintainer needs to evaluate this issue
on Sep 7, 2026 This was generated by AI during triage.
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 a
directory, across 25 repos. 36 distinct targets;eof-fixerlinksLICENSEtwice and
faststream-concurrent-aiokafkacites ADR-0003 twice. -
The rule added to
AGENTS.mdin all 25, byte-identical:Every link in
README.mdmust be absolute:
https://github.com/modern-python/<repo>/blob/main/<path>, or.../tree/main/<path>for a
directory. Never a relative path:README.mdis also the PyPI long description, and PyPI does
not rewrite relative links, so a relative one 404s on the package page. -
.githubtook the ADR deletion andAGENTS.mdtrim, but not the rule: it publishes no long
description. 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 an
agent-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
correctedAGENTS.mdin context, which is precisely the event that disproves the call. At that
point 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 atmain
forever and an old release's page can rot if a target moves later. Tag-pinnedblob/<tag>/would
be 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.main
bounds 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 fromjustfilelint-ci), carrying both this check and #84's ADR-citation check, with
autofix 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: its
implementation 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:
- Widening the lychee glob to
**/*.py. Measured exactly vacuous: across 26 repos it adds one
checked link, and that link is a directory. It also turnsdb-retryred with 9 PostgreSQL DSN
parse errors that--scheme,--excludeand every regex variant fail to suppress, because the
failure happens at URL construction before filtering. Only--exclude-pathescapes it, which
re-parameterises the gate block that The offline link gate never checks absolute self-links #82 deliberately de-parameterised. - A brand-hotlink shape check. ADR 0003 created 78 hotlinks that nothing verifies at any point:
--offlineexcludes them in the consuming repo,.github's weekly run only scans its own tree,
and no test referencesbrand/projectsat all. Offline, only the slug segment is checkable.
Dropped with the rest of the machinery; the gap is real and unclosed. - An ADR and a
CONTEXT.mdsurface entry. Dropped as overhead once the tool was dropped.
Still open, deliberately
- Already-published long descriptions stay broken. 23 of 25 packages have dead links on their
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. - Relative links in non-README Markdown are untouched. They are correct and gate-checked.
.github's own README is out of scope and keeps its relative links, which is fine because it
ships nowhere.
Closing. Reopen on the falsification condition above.
-
- removedneeds-triageMaintainer needs to evaluate this issueMaintainer needs to evaluate this issue
on Sep 7, 2026
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-aiokafka0.6.5, reading thehrefPyPI actually emits: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.
LICENSE./examplesmodern-di-*integrations)docs/providers/github.md,semvertag/ioc.pysemvertagplanning/decisions/…faststream-concurrent-aiokafkaarchitecture/free-threading.mdlite-bootstrapThe 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 areadmeinpyproject.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-appandcompose2podwere the only two clean packages at their last release; both have acquired a relative link atHEADsince.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.mdspecifically, that is backwards. Theplanning/ → docs/adr/migration rewrotefaststream-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:Options
1. Rewrite README relative links to absolute self-links. The existing
--remapfrom #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 cleangit archive HEADexport with the shipped remap block:The error names the remap, so the diagnostic is not degraded either.
--remapalready handlestree/main/as well asblob/main/, so directory links like./examplesanddocs/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
readmekey frompyproject.tomlrather than assuming a filename, since two repos use lowercasereadme.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-templaterepos 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.