Skip to content

Relative links in README.md 404 on the PyPI package page #85

Description

@lesnik512

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:

[![License](…/license/modern-python/modern-di-typer.svg)](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

  1. added
    bugSomething isn't working
    needs-triageMaintainer needs to evaluate this issue
    on Sep 7, 2026
  2. lesnik512 commented on Sep 7, 2026

    @lesnik512
    MemberAuthor

    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-fixer links LICENSE twice and
      faststream-concurrent-aiokafka cites ADR-0003 twice.

    • The rule added to AGENTS.md in all 25, byte-identical:

      Every link in README.md must be absolute:
      https://github.com/modern-python/<repo>/blob/main/<path>, or .../tree/main/<path> for a
      directory. Never a relative path: README.md is also the PyPI long description, and PyPI does
      not rewrite relative links, so a relative one 404s on the package page.

    • .github took the ADR deletion and AGENTS.md trim, but not the rule: it publishes no long
      description. Same for the two *-sqlalchemy-template repos.

    Why no test

    CLAUDE.md imports AGENTS.md in 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
    corrected AGENTS.md in context, which is precisely the event that disproves the call. At that
    point the test is the answer and this issue gets reopened.

    Why main and not a release tag

    A PyPI long description is frozen per release, so blob/main/ in a shipped README points at main
    forever and an old release's page can rot if a target moves later. Tag-pinned blob/<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 package

    Recording this because it is the alternative most likely to be re-proposed.

    The design was a PyPI package following the eof-fixer pattern (in every repo's lint group,
    called from justfile lint-ci), carrying both this check and #84's ADR-citation check, with
    autofix for the README rewrite. ref-fixer is available on PyPI.

    It was rejected as too much machinery for two checks. The eof-fixer precedent 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 turns db-retry red with 9 PostgreSQL DSN
      parse errors that --scheme, --exclude and every regex variant fail to suppress, because the
      failure happens at URL construction before filtering. Only --exclude-path escapes 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:
      --offline excludes them in the consuming repo, .github's weekly run only scans its own tree,
      and no test references brand/projects at 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.md surface 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions