Publish HTML reports from CI

Your pipeline already generates the report. Playwright writes one, so does pytest --html, so does genhtml, so does every scanner you run. Then the job uploads it as an artifact and the report effectively ceases to exist: it needs a CI login, a download, an unzip and a local file open before anyone reads a word of it.

Nobody does that. They screenshot the failure into Slack.

The pattern, once

Every provider guide below is the same three lines with different syntax around them:

# 1. generate — and survive a failing build
run-the-thing || true

# 2. publish to a saved report id
curl -fsS -X PATCH "https://commareports.com/api/v1/reports/$REPORT_ID" \
  -H "Authorization: Bearer $COMMA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile html report.html \
        --arg title "Run $(git rev-parse --short HEAD)" \
        '{title: $title, html: $html}')"

# 3. there is no step 3

Three things make it work in practice:

  • PATCH a saved id, don't POST. POSTing creates a new report every build — a spray of orphan links. PATCHing one id gives the suite a permanent URL and a revision per run, which is also what makes two runs diffable.
  • Make it unconditional. if: always(), after_script, post { always { … } }. A failing build is when the report matters.
  • Use a scoped token. reports:write only, stored as a CI secret, revocable without touching anything else.

By provider

  • GitHub Actionsif: always(), and why the artifact upload isn't the same thing
  • GitLab CIafter_script, and where Pages falls short for per-run reports
  • Jenkinspost { always { … } }, and the HTML Publisher CSP problem
  • CircleCIstore_artifacts versus a URL
  • Azure DevOps — publishing without a custom extension
  • Bitbucket Pipelines — the after-script step

Multi-file reports

Most tools that say "HTML report" emit a directoryindex.html plus CSS, JavaScript and JSON. Playwright, Allure, genhtml, nbconvert, Storybook and Gradle all do.

The entry HTML goes in the PATCH body; the siblings go up through POST /api/v1/reports/$REPORT_ID/assets as base64, one call per file. Worth scripting once and forgetting. Relative references are rewritten to the uploaded copies, so the drill-down links resolve. See the API reference.

What you get that an artifact never gave you

  • A link in the PR that a reviewer clicks instead of reconstructing.
  • Threads on the failure. Select the failing test, leave a comment, and it stays anchored there across runs — so a flaky test carries its own history. See commenting on HTML.
  • Diffable revisions. Two runs, one diff, which tests changed state.
  • Access control per report — private, team, domain-gated, or a link. See sharing & access control.

By report type

Framework-specific guides, each with the exact generate command:

pytest · Jest and Vitest · Playwright · Cypress · JUnit · Gradle · Go · PHPUnit · RSpec · Allure · coverage · JaCoCo · Lighthouse · k6 · Trivy · Semgrep · SonarQube

The full library is on the blog index.

Scheduled, not just triggered

Some reports shouldn't wait for a push. A routine re-runs the job on a cron and posts the refreshed output as a new revision at the same URL — no pipeline required for the weekly digest that currently exists as a recurring calendar reminder to someone.

Try it

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

Publish from your pipeline →

Related