# Publish HTML Reports From CI — Every Provider, One Pattern

Canonical: https://commareports.com/ci

> Your pipeline already builds an HTML report and then buries it in an artifact zip. The publish step is one curl. Guides for GitHub Actions, GitLab, Jenkins, CircleCI, Azure DevOps and Bitbucket.

# 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:

```bash
# 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](/docs/api-tokens).** `reports:write` only, stored
  as a CI secret, revocable without touching anything else.

## By provider

- [GitHub Actions](/ci/github-actions-html-report) — `if: always()`, and
  why the artifact upload isn't the same thing
- [GitLab CI](/ci/gitlab-ci-html-report) — `after_script`, and where
  Pages falls short for per-run reports
- [Jenkins](/ci/jenkins-html-report) — `post { always { … } }`, and the
  HTML Publisher CSP problem
- [CircleCI](/ci/circleci-html-report) — `store_artifacts` versus a URL
- [Azure DevOps](/ci/azure-devops-html-report) — publishing without a
  custom extension
- [Bitbucket Pipelines](/ci/bitbucket-pipelines-html-report) — the
  `after-script` step

## Multi-file reports

Most tools that say "HTML report" emit a **directory** — `index.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](/docs/api).

## 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](/comment-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](/docs/sharing).

## By report type

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

[pytest](/share-pytest-report) · [Jest and Vitest](/share-jest-report) ·
[Playwright](/share-playwright-report) · [Cypress](/share-cypress-report) ·
[JUnit](/share-junit-report) · [Gradle](/share-gradle-test-report) ·
[Go](/share-go-test-report) · [PHPUnit](/share-phpunit-report) ·
[RSpec](/share-rspec-report) · [Allure](/share-allure-report) ·
[coverage](/share-coverage-report) · [JaCoCo](/share-jacoco-report) ·
[Lighthouse](/share-lighthouse-report) · [k6](/share-k6-load-test-report) ·
[Trivy](/share-trivy-report) · [Semgrep](/share-semgrep-report) ·
[SonarQube](/share-sonarqube-report)

The full library is on the [blog index](/blog).

## Scheduled, not just triggered

Some reports shouldn't wait for a push. A
[routine](/features/routines/scheduled-html-reports) 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 →](https://commareports.com/)**

### Related

- [API reference](/docs/api) · [API tokens](/docs/api-tokens) · [CI docs](/docs/ci)
- [Stop screenshotting reports](/stop-screenshotting-reports)
- [Where should my agent post?](/agents/where-should-my-agent-post)
