# View an HTML Report from GitLab CI Without Setting Up Pages

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

> GitLab.com won't render HTML artifacts inline — the artifact browser serves them as downloads, and Pages is a whole separate project. Publish the report to a stable URL from one curl in your .gitlab-ci.yml.

# View an HTML report from GitLab CI

Your pipeline produces `coverage/index.html`. You add it to `artifacts:paths`.
You open the job, click Browse, click the file — and GitLab downloads it
instead of showing it.

That's not a misconfiguration. GitLab.com deliberately does not render HTML
artifacts inline: serving arbitrary user HTML from a GitLab domain would run
that HTML in GitLab's own origin, which is an obvious way to hand attackers a
session. The documented answer is **GitLab Pages** — which is a real answer,
and also a whole separate deployment: another job, another artifact contract,
one site per project, and its own access model to reason about.

For a report that ten people need to _read and discuss twice a week_, that's a
lot of infrastructure for a document.

## One curl in `.gitlab-ci.yml`

Store a [scoped token](/docs/api-tokens) (`reports:write` is enough) as a
masked CI/CD variable, create the report once, and `PATCH` it from every
pipeline so there is **one URL per report** rather than one per run:

```yaml
publish_report:
  stage: report
  image: alpine:latest
  when: always
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      curl -fsS -X PATCH "https://commareports.com/api/v1/reports/$COMMA_REPORT_ID" \
        -H "Authorization: Bearer $COMMA_API_TOKEN" \
        -H "Content-Type: application/json" \
        -d "$(jq -n --rawfile html coverage/index.html \
              --arg title "Coverage — $CI_COMMIT_SHORT_SHA" \
              '{title: $title, html: $html}')"
    - echo "Report → https://commareports.com/p/$COMMA_REPORT_ID"
```

`when: always` matters — the pipelines you most want to look at are the failed
ones, and the default `on_success` skips the publish exactly then.

## Posting the link on the merge request

```yaml
- |
  [ -n "$CI_MERGE_REQUEST_IID" ] && curl -fsS -X POST \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
    -H "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
    --data-urlencode "body=📊 [Coverage report](https://commareports.com/p/$COMMA_REPORT_ID) — updated for $CI_COMMIT_SHORT_SHA"
```

Because the report URL never changes, the note stays accurate as commits land
— no stale links accumulating down the MR thread.

## What this buys over the artifact browser

|                         | GitLab artifact   | GitLab Pages       | Comma report                   |
| ----------------------- | ----------------- | ------------------ | ------------------------------ |
| Renders HTML in browser | No (download)     | Yes                | Yes                            |
| Stable URL across runs  | No                | Yes                | Yes                            |
| Setup                   | `artifacts:paths` | Pages job + config | one curl                       |
| Expiry                  | 30 days default   | until redeployed   | revisions kept                 |
| Comments on the report  | —                 | —                  | anchored threads               |
| History you can diff    | —                 | —                  | revision per run               |
| Readers without a seat  | No                | Public or nothing  | private / team / domain / link |

The row that usually decides it is the last one. GitLab artifacts require
project membership; Pages on a private project is public-or-nothing unless
you're on a tier with access control. A report that a PM, a customer or an
auditor needs to open wants
[identity-based visibility](/docs/sharing), not a seat.

## Details before you wire it up

- **HTML body: 5 MB.** Screenshots, videos and trace bundles go in as
  [assets](/docs/api) — 25 MB per file, 250 MB per report.
- **Scripts are stripped on write.** If your report is a JavaScript app
  (Allure, Playwright's default reporter), publish a static digest and attach
  the archive — see [sharing an Allure report](/share-allure-report).
- **Mask the token.** `reports:write` only, masked variable, revocable from
  [API tokens](/docs/api-tokens) without touching the pipeline.
- **Announce the run.** A [webhook](/docs/api) on `revision.created` posts each
  new revision into Slack or Discord.

## Try it

Comma is free — unlimited reports, unlimited commenters, unlimited revision
history. Add the job above to the pipeline that currently uploads an artifact
nobody opens.

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

### Related

- [Publish from CI](/docs/ci) — the general pipeline pattern
- [GitHub Actions HTML reports](/ci/github-actions-html-report)
- [Jenkins HTML reports](/ci/jenkins-html-report)
- [Share a coverage report](/share-coverage-report)
