The coverage summary loads, the file links 404

htmlcov/index.html opens, the table of files renders, the percentages look right — and clicking any filename gives File not found.

Only the index travelled

Every coverage tool emits a directory, not a file. The index is a table of contents; the content is one generated page per source file:

htmlcov/                          # coverage.py
├── index.html                    ← the summary table
├── style.css  coverage_html.js   ← shared assets
├── z_a1b2c3_models_py.html       ← one page per source file
├── z_a1b2c3_views_py.html
└── … several hundred more

The names differ, the shape does not:

Tool Directory Assets
coverage.py htmlcov/ style.css, coverage_html.js
Istanbul / nyc coverage/lcov-report/ prettify.js, base.css
JaCoCo target/site/jacoco/ jacoco-resources/
SimpleCov coverage/ assets/<version>/
genhtml (lcov) out/ gcov.css, amber.png
Go (go tool cover) single file — (the exception)

Attach index.html to a ticket, drop it in a chat, copy it out of the build directory — and you have shipped the table of contents of a book you left behind.

The variants of this bug

Sorting and search stop working too. Those are coverage_html.js and friends. Missing assets take the interactivity with them, which looks like a separate bug and is the same one — the five causes of a report losing its CSS.

It works locally, 404s in CI. The artifact upload had a path: that matched only the entry file, or the copy step used the file instead of the directory.

It works in CI's viewer, breaks after download. Now you are on file://, where relative paths and any XHR-loaded data both fail. See why localhost was the wrong fix.

Everything 404s except index. Some tools emit paths with characters that a static host or bucket key-encodes differently than the browser requests them. Bucket hosting also needs per-extension content types, or the pages download instead of rendering.

Keep the whole tree

Publish the directory as a unit. index.html becomes the report body; every generated page, stylesheet and script uploads alongside it with relative references rewritten to the uploaded copies, so the drill-down resolves over HTTPS for anyone with the link.

From CI, at the end of the job:

- name: Publish coverage
  if: always()
  run: |
    coverage html                      # or nyc report --reporter=lcov-report
    # upload the folder — multi-file call in /docs/ci

Limits: entry HTML 5 MB; assets 25 MB per file, 250 MB and 500 files per report. A large monorepo's per-file pages can approach the file count — scope the report to the package under review, which is usually what the reviewer wanted anyway.

Why the drill-down is the point

"Coverage dropped 0.4%" starts an argument that nobody can settle in chat. The per-line view settles it: the reviewer opens the file, sees the uncovered branch, selects it, and comments therecomment on HTML. Publish the same report id every run and each build appends a revision, so the trend and the argument live at one address.

Try it

Comma is free — unlimited reports, unlimited commenters, unlimited revision history.

Publish a coverage report →

Related