Black Friday30% off · BLACKFRIDAY30–d––h––m
Publish a Playwright HTML Report from GitHub Actions to a PR
Nov 7, 2026

Publish a Playwright HTML Report from GitHub Actions to a PR

Copy a GitHub Actions workflow that publishes your Playwright HTML report to DocHost and updates the same report URL and pull request comment on every push.

To share a Playwright HTML report from GitHub Actions, run Playwright with the HTML reporter, upload the complete report folder as a GitHub artifact, and publish its index.html to DocHost. The workflow below adds one comment to the pull request. Later commits replace the report at the same URL and edit that comment to point at the latest Actions run.

The diagram above shows where the two outputs go. The browser link gives reviewers a quick way to see test results. The complete artifact keeps the traces, screenshots, videos and other files that Playwright may store alongside index.html.

1. Set up the repository secret

Use a repository with Playwright Test installed, an npm lockfile, and a Playwright project named chromium. If your tests need a local app server, configure Playwright’s webServer in your project as usual. Playwright CI setup

Create a DocHost API key in Settings → API Keys. In GitHub, open your repository’s Settings → Secrets and variables → Actions, then save it as a repository secret named DOCHOST_API_KEY. The publish job reads it from the environment. DocHost API key walkthrough

2. Copy the GitHub Actions workflow

Save the workflow as .github/workflows/playwright-dochost.yml. If you use pnpm or a browser project with a different name, change the install and test commands to match. Download the YAML

name: Playwright report

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

concurrency:
  group: playwright-dochost-${{ github.event.pull_request.number }}
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run Playwright
        run: npx playwright test --project=chromium --reporter=html
        env:
          CI: 'true'
          PLAYWRIGHT_HTML_OPEN: never
          PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report
      - name: Save complete report and attachments
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v6
        with:
          name: playwright-report
          path: playwright-report/
          if-no-files-found: ignore

  share:
    needs: test
    if: >-
      ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository
          && github.actor != 'dependabot[bot]' }}
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      pull-requests: write
    steps:
      - uses: actions/download-artifact@v7
        with:
          name: playwright-report
          path: playwright-report
      - name: Publish report and update PR comment
        uses: actions/github-script@v8
        env:
          DOCHOST_API_KEY: ${{ secrets.DOCHOST_API_KEY }}
          TEST_RESULT: ${{ needs.test.result }}
        with:
          script: |
            const fs = require('node:fs');
            const key = process.env.DOCHOST_API_KEY;
            if (!key) throw new Error('Set the DOCHOST_API_KEY repository secret.');

            const body = fs.readFileSync('playwright-report/index.html', 'utf8');
            const marker = '<!-- dochost-playwright-report -->';
            const { owner, repo } = context.repo;
            const issue_number = context.payload.pull_request.number;
            const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${context.runId}`;

            async function call(name, args) {
              const response = await fetch('https://dochost.io/api/mcp', {
                method: 'POST',
                headers: {
                  Authorization: `Bearer ${key}`,
                  'Content-Type': 'application/json',
                  Accept: 'application/json, text/event-stream',
                },
                body: JSON.stringify({
                  jsonrpc: '2.0', id: 1, method: 'tools/call',
                  params: { name, arguments: args },
                }),
                signal: AbortSignal.timeout(30_000),
              });
              if (!response.ok) throw new Error(`DocHost ${name}: HTTP ${response.status}`);
              const { result, error } = await response.json();
              if (error || result?.isError || !result?.structuredContent) {
                throw new Error(`DocHost ${name} failed. Check API key, page access and file size.`);
              }
              return result.structuredContent;
            }

            const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number });
            if (pr.head.sha !== context.payload.pull_request.head.sha) {
              core.notice('Skipping a superseded commit.');
              return;
            }

            const comments = await github.paginate(github.rest.issues.listComments, {
              owner, repo, issue_number, per_page: 100,
            });
            const previous = comments.find(({ user, body }) =>
              user?.login === 'github-actions[bot]' && body?.startsWith(marker + '\n'));
            const slug = previous?.body.match(/^<!-- dochost-slug: ([a-z0-9-]{1,64}) -->$/m)?.[1];
            if (previous && !slug) throw new Error('Existing report comment is missing its DocHost slug.');

            let report;
            if (slug) {
              const live = await call('get_page_content', { slug });
              if (!live.editable) throw new Error('The existing report is not editable.');
              await call('update_page', {
                slug, body, format: 'html', ifVersion: live.version,
                ...(live.sha256 ? { ifSha: live.sha256 } : {}),
              });
              report = live;
            } else {
              report = await call('publish', { body, format: 'html', public: false });
            }

            const url = new URL(report.url);
            if (url.protocol !== 'https:' || !/^[a-z0-9-]{1,64}$/.test(report.slug)) {
              throw new Error('DocHost returned an unexpected URL or slug.');
            }
            const comment = [
              marker,
              `<!-- dochost-slug: ${report.slug} -->`,
              '### Playwright HTML report',
              `[Open the report](${url.href}) · [Actions run and full artifact](${runUrl})`,
              `Test job: **${process.env.TEST_RESULT}** · Commit: \`${pr.head.sha.slice(0, 7)}\``,
            ].join('\n');

            if (previous) {
              await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body: comment });
            } else {
              await github.rest.issues.createComment({ owner, repo, issue_number, body: comment });
            }

The test job keeps its original pass or fail result. If Playwright produces a report on a failed test run, the artifact upload and the separate share job still run. For fork PRs, the workflow runs tests and uploads the artifact, while the share job skips the DocHost write and PR comment. The share job downloads the artifact without checking out or running PR code. Its GitHub token can write PR comments. GitHub Actions workflow syntax

Open a pull request from a branch in the same repository. When the workflow finishes, look for the Playwright HTML report comment and open its report link. Push a change that alters a test result or test title. Open the same report URL after the next run and check that the changed result appears. The original comment should now show the newer commit and Actions run.

An actual Playwright report hosted on DocHost showing one passing checkout test

The first run calls publish with public: false and stores the returned slug in a hidden marker in the bot’s comment. Later runs read that slug, fetch the current page version, and call update_page. The version check rejects an intervening edit instead of overwriting it. Runs are serialized per PR and a run checks the current head before writing. If a comment is deleted, a later run has no slug to recover and creates a fresh report.

What reviewers can open

Share only reports whose contents are suitable for everyone who might receive the link. public: false keeps the report off Explore. Anyone with the URL can read it without an account. Use the GitHub artifact for reports with sensitive test data, internal URLs, screenshots or credentials.

The hosted page contains one HTML file. The tests and results embedded in that file can be browsed there. For linked attachments or Trace Viewer assets, use the artifact from the Actions run, extract it, and run npx playwright show-report <extracted-folder>. Playwright HTML reports

FAQ

Can I publish the report when a test fails?

Yes, if Playwright finished writing index.html. The upload step still runs after test failures and the failed job stays visible in GitHub. If a setup error prevents the report from being created, check the test job logs.

The first successful publication creates the URL. Later runs reuse the slug saved in the bot comment and update the page. The comment changes to show the latest run and commit.

A free link published off Explore lasts three days. An update preserves the existing expiration date. Use a permanent plan if reviewers need the link beyond the free window. If you need the history of individual runs, keep the artifacts in GitHub.

Can a fork pull request publish a report?

This workflow skips the publish job for forks and Dependabot. Reviewers can still use the GitHub artifact when the tests are allowed to run.

To try publishing a standalone HTML report in the browser, open the client report demo.

Newsletter

Join the community

Subscribe to our newsletter for the latest news and updates