Publishing Playwright HTML reports from CI

This page is part of the artifacts and pipeline reporting section of the CI/CD test gating guide, which covers what happens after a run finishes.

The specific problem: Playwright produces a rich HTML report with traces, screenshots and per-step timings, and in CI it ends up inside a zip that nobody downloads. The report is excellent and effectively invisible.

Problem statement

An artefact is a download. Downloading a four-megabyte zip, unzipping it, and opening a local file is three steps more than a reviewer will take when the alternative is rerunning the job.

The symptom is a repository with a well-configured HTML reporter and a team that debugs from the raw CI log, because the log is the thing that is one click away.

Root cause

The report is a static site — HTML, JavaScript and images — and static sites want a URL. The artefact mechanism is designed for files a machine will consume, not for a site a human will browse, and the friction is entirely in that mismatch.

Every solution therefore has the same shape: put the report somewhere it can be served, and put the link somewhere the reviewer already is.

Choosing where the report goes A decision node asking whether the repository is public. A public repository can use GitHub Pages for free hosting and a real URL. A private repository with a small team is well served by a run artefact, which is a download with no setup. A private repository with a large team benefits from object storage with signed URLs and a retention policy. Anything involving regulated data should stay as an artefact and never be published externally. Is the repository public? GitHub Pages free hosting, a real URL yes Run artefact a download, zero setup no, small team Object storage signed URLs, retention no, large team Artefact only do not publish externally regulated data

Minimal reproduction

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
# the report exists, and reaching it takes a download, an unzip and a file:// URL

Step-by-step fix

1. Keep the artefact upload as the fallback

      - name: Upload report artefact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.browser }}
          path: playwright-report/
          retention-days: 7

What this does: guarantees the report exists even if publishing fails, and covers private repositories where publishing is not appropriate.

2. Publish to a per-pull-request path

      - name: Publish report
        if: always() && github.event_name == 'pull_request'
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: playwright-report
          destination_dir: pr-${{ github.event.pull_request.number }}
          keep_files: true          # do not wipe other pull requests' reports

What this does: gives each pull request its own URL, so two open reviews cannot overwrite each other’s report.

      - name: Comment the report link
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://${context.repo.owner}.github.io/${context.repo.repo}/pr-${context.issue.number}/`;
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body: `Playwright report: ${url}`,
            });

What this does: turns three steps into one click, which is the entire point of the exercise.

4. Prune old reports

      - name: Prune closed pull-request reports
        run: |
          # keep the last 20 directories, delete the rest
          ls -dt pr-* | tail -n +21 | xargs -r rm -rf

What this does: stops the pages branch growing without limit. Reports for merged pull requests have no readers.

5. Do not publish from forks

        if: github.event.pull_request.head.repo.full_name == github.repository

What this does: a fork’s pull request runs with a restricted token by design, and attempting to publish from it either fails noisily or, if misconfigured, hands write access to untrusted code.

Verification

A report published to a per-PR path A publish job for run 2118. The Playwright report of 4.2 megabytes across 61 files is deployed to a pull-request-specific path on the pages branch, published in six seconds. The job comments the link on pull request 418 and prunes two older previews for closed pull requests. Publish report — run #2,118 playwright-report/ 4.2 MB, 61 files deploying to gh-pages/pr-418/ published in 6 s commented on PR #418 with the link older previews pruned: pr-401, pr-404

Open the commented URL from a browser that is not signed in to the CI platform. A public report should load; a private one should not, which is the check that the visibility of the report matches the visibility of the code.

Edge cases and caveats

Four places to put an HTML report Four rows. Uploading the report as a run artefact takes one step and gives the reviewer a download, which suits private repositories. Deploying to a GitHub Pages branch needs a deploy job and gives a URL, which suits public projects. Syncing to object storage needs credentials and gives a URL, suiting larger teams. Deploying per pull request to its own path gives a URL per pull request, which suits review-heavy work. Destination Setup Link is Good for Run artefact (zip) one step a download private repos GitHub Pages branch a deploy job a URL public projects Object storage (S3/R2) credentials + sync a URL large teams Pages, per-PR path deploy to /pr-123/ a URL per PR review-heavy work
  • Report size. Traces inflate a report quickly. trace: 'on-first-retry' keeps most runs small while still capturing the failures worth investigating.

  • Sharded runs. Each shard produces its own report. Merge the blob reports into one before publishing, or reviewers get several partial reports and no combined view.

  • Secrets in screenshots. A report is a static site; if it is public, its screenshots are public. Anything rendering real data should stay an artefact.

What the report is actually for

It is worth being precise about which question the HTML report answers, because that determines how much effort publishing it deserves.

It does not answer “did the suite pass” — the check’s status does that, and better. It does not answer “which test failed” — the job summary does that in one line, without a click.

What it answers is “what happened inside the failing test”. The per-step timeline shows which action was slow, the attached screenshot shows the state at the moment of failure, and the trace lets a reviewer scrub through the run and inspect the DOM at any step. That is a genuinely different class of information from a log, and it is the difference between diagnosing a race condition in two minutes and reproducing it locally over half an hour.

Two consequences follow. First, the report only needs to exist for failing runs, because the questions it answers only arise then. Second, the trace is the most valuable thing in it and the most expensive — which is exactly why on-first-retry is the right default: the retry is the run that already indicated instability, and capturing a trace only there keeps the report small while capturing it exactly when it is wanted.

The practical test of whether publishing was worth setting up: when someone next reports a flaky test, does the conversation start with a link to the trace, or with “it passed when I reran it”? If it is still the latter, the link is not reaching people, and that is a reporting problem rather than a tooling one.

FAQ

Is publishing to GitHub Pages safe for a private repository?

Not by default. A Pages site built from a private repository can still be publicly reachable depending on the plan and settings, so a report containing screenshots of internal data may be exposed. For private work, keep the artefact download or publish to storage that enforces authentication — the convenience is not worth the disclosure risk.

Should the report be published on passing runs too?

Usually not — nobody reads a passing report, and publishing every run fills the branch. The exception is a main-branch run kept as a rolling reference, which is useful for comparing a failure against the last known-good report. One per branch, overwritten, costs almost nothing.

How do I stop the pages branch bloating the repository?

Prune aggressively and keep reports small. A pages branch accumulates every published report in its history, so even deleted directories persist in the objects. If the branch becomes large, force-pushing a fresh orphan branch periodically resets it — which is acceptable precisely because these reports are ephemeral by nature.

Can the same approach publish a Storybook build for review?

Yes, and the mechanics are identical — a static directory deployed to a per-pull-request path with the link commented. It is one of the more useful things to publish, because it lets a designer review the actual component states in a branch without checking anything out. The same caveats apply: do not publish from forks, prune old previews, and keep it private if the stories render real data.