# Publish an HTML Report from GitHub Actions (Without Pages or Artifact Zips)

Canonical: https://commareports.com/ci/github-actions-html-report
Published: 2026-08-19

> GitHub Actions can't render HTML. Artifacts download as zips, expire in 90 days, and need repo access. Publish the report to a stable URL with one curl step — comments, revisions, and diffs included.

# Publish an HTML report from GitHub Actions

GitHub Actions will run anything that produces HTML — coverage, Lighthouse,
Playwright, benchmarks, eval scoreboards, a generated data digest — and then
give you exactly two ways to look at it, neither of which is looking at it.

**`actions/upload-artifact`** stores a zip. A reviewer opens the run page,
scrolls to Artifacts, downloads, unzips, and opens `index.html` from
`~/Downloads` — where the relative asset paths sometimes work and sometimes
don't. **`$GITHUB_STEP_SUMMARY`** renders markdown inline, which is genuinely
useful, but it strips scripts and iframes, ignores most styling, and caps out
at 1 MiB. Neither one produces a link you can paste into Slack that opens a
report.

So teams screenshot the artifact back into the PR thread, and the actual
report — the thing with all the detail in it — never gets read.

## The one-step fix

Store a [scoped token](/docs/api-tokens) (`reports:write` is enough) as a
repository secret, then add one step:

```yaml
- name: Publish report to Comma
  if: always()
  env:
    COMMA_API_TOKEN: ${{ secrets.COMMA_API_TOKEN }}
    REPORT_ID: ${{ vars.COMMA_COVERAGE_REPORT_ID }}
  run: |
    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 coverage/index.html \
            --arg title "Coverage — ${GITHUB_SHA:0:7}" \
            '{title: $title, html: $html}')"
```

Three details in there earn their keep:

- **`PATCH`, not `POST`.** Create the report once by hand, put its id in a
  [repository variable](https://docs.github.com/en/actions/learn-github-actions/variables),
  and every run appends a revision **at the same URL**. One bookmark per
  suite, forever — and any two runs can be diffed.
- **`if: always()`.** The runs worth sharing are the red ones. Without this,
  the publish step is skipped exactly when the report matters.
- **A commit-stamped title.** Revisions titled `Coverage — a1b2c3d` make the
  history read like a log instead of a pile of timestamps.

## Posting the link back to the PR

The report URL is stable, so a PR comment is one more step — no marketplace
action required:

```yaml
- name: Comment the report link
  if: github.event_name == 'pull_request'
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    gh pr comment "${{ github.event.number }}" \
      --body "📊 [Coverage report](https://commareports.com/p/$REPORT_ID) — updated for ${GITHUB_SHA:0:7}"
```

Reviewers click once and land on the rendered report with a comment layer on
it, instead of a zip.

## What you get that an artifact can't give you

- **Comments anchored to the report.** A reviewer highlights the row for the
  module that lost 8% coverage and pins a thread to it. The thread survives
  the next twelve runs — see [commenting on HTML](/comment-on-html).
- **Revision history with diffs.** "What changed since the last green build?"
  becomes a diff instead of a memory exercise. Artifacts have no concept of a
  previous version.
- **Access without repo access.** Artifacts require a GitHub seat on the
  repository. A Comma report can be team-visible, domain-gated, or shared with
  a named reviewer who will never have a GitHub login — see the
  [sharing model](/docs/sharing).
- **No expiry.** Revisions stay. The 90-day artifact clock doesn't apply.
- **Announcements for free.** A [webhook](/docs/api) on `revision.created`
  posts each new run to Slack or Discord, so the Slack message goes back to
  being a notification and the report stays the artifact.

## Limits worth knowing before you wire it up

- **HTML body: 5 MB.** Screenshots, trace zips and JS bundles go in as
  [assets](/docs/api) instead — 25 MB per file, 250 MB per report.
- **Scripts are stripped on write.** Comma sanitizes incoming HTML, so a
  report that _is_ a JavaScript application (Playwright's default reporter,
  Allure) should be published as a static digest with the full archive
  attached as an asset. Details in
  [sharing a Playwright report](/share-playwright-report).
- **Rate limit: 60 requests/minute per token.** One publish per build is not
  close.
- **Keep the token scoped.** `reports:write` only. It can't read your other
  reports, and it's revocable from
  [API tokens](/docs/api-tokens) without touching CI.

## When GitHub's own tools are the right answer

Be honest about it: if the artifact is a build output nobody reads, leave it
in `upload-artifact`. If your report is genuinely a multi-page site with
routing, GitHub Pages is the correct tool. Comma's unit is _the report_ — one
document, rendered faithfully, with a review layer on top.

## Try it

Comma is free — unlimited reports, unlimited commenters, unlimited revision
history. Take the workflow that currently uploads a zip, add the step above,
and send the link instead.

**[Create your first report →](https://commareports.com/)**

### Related

- [Publish from CI](/docs/ci) — the general pipeline pattern
- [GitLab CI HTML reports](/ci/gitlab-ci-html-report)
- [Jenkins HTML reports](/ci/jenkins-html-report)
- [GitHub Pages alternatives](/alternatives/github-pages-alternatives)
