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.
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.
3. Put the link where the reviewer is
- 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
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
-
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.
Related
- Artifacts & Pipeline Reporting — the parent section on publishing evidence from a run
- GitHub Actions Workflows — the workflow this publishing step belongs to
- Baseline Management — reviewing the diffs the report links to
- Pipeline Gating Thresholds — the gate whose failures the report explains