Skipping --with-deps when installing Playwright in GitHub Actions is the single most common reason tests pass locally but fail in CI: system libraries like libgtk and libnss aren't installed on fresh Ubuntu runners. The second most common mistake is omitting if: always() on the artifact upload step, which means you lose the HTML report precisely when tests fail and you need it most. This guide covers the complete setup: minimal working workflow, secrets, browser caching, branch filters, matrix cross-browser runs, sharding across parallel jobs, and PR comments with test results.

Why GitHub Actions

GitHub Actions is the dominant CI platform for new projects. The JetBrains State of CI/CD 2025 survey found that 62% of developers use it for personal projects and 41% use it in their organizations, both figures higher than any other tool.

The practical reasons are straightforward: it's free for public repositories, free for 2,000 minutes per month on private ones, and it's built into GitHub. There's no separate server to provision, no third-party account to connect, and no webhook configuration. You push a YAML file and tests start running.

For Playwright specifically, GitHub Actions works well because the Ubuntu runners it provides have everything Playwright needs. Browser dependencies, fonts, and system libraries are available via --with-deps. The official Playwright team tests against these runners. You're in the supported path.

The Minimal Working Workflow

Create the file .github/workflows/playwright.yml in your project root. This is where GitHub looks for workflow definitions.

mkdir -p .github/workflows

Here's the complete minimal workflow:

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 60

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Commit this file, push, and open the Actions tab in your GitHub repository. You'll see the workflow appear and run within seconds.

A few details worth understanding. npm ci instead of npm install installs exactly what's in package-lock.json without modifying it. This is the correct command for CI environments because it gives you reproducible builds. npx playwright install --with-deps installs the browsers along with their system-level dependencies (libgtk, libnss, and so on) that aren't present on a fresh Ubuntu runner. Skipping --with-deps is the most common reason Playwright fails in CI when it works locally.

The if: always() on the upload step is load-bearing. Without it, the artifact only uploads when the workflow succeeds, meaning you lose the report precisely when tests fail and you need it most.

The timeout-minutes: 60 on the job is a safeguard. A hung test can otherwise consume your entire monthly minute quota before you notice.

Storing and Downloading Test Reports

The upload-artifact step in the workflow above saves the entire playwright-report/ directory as a downloadable zip. After a run completes, go to the workflow run page, then Summary, then Artifacts, then playwright-report. Download and unzip it, then open index.html in a browser.

The HTML report shows which tests passed, which failed, failure screenshots, traces, and video if you have recording enabled. This is how you investigate failures without accessing the CI runner directly.

If you're generating multiple reports (say, one per browser in a matrix run), give each artifact a unique name:

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

retention-days: 14 keeps reports for two weeks, which is enough for most debugging workflows without accumulating unnecessary storage costs. GitHub allows up to 90 days.

Environment Variables and Secrets

Never put credentials or environment-specific URLs in the workflow YAML file. GitHub Secrets are the right place for anything sensitive.

Go to your repository, then Settings, then Secrets and variables, then Actions, then New repository secret. Add the secrets your tests need: TEST_USER, TEST_PASSWORD, and any API keys.

Reference them in the workflow under env: on the test step:

      - name: Run Playwright tests
        run: npx playwright test
        env:
          BASE_URL: https://staging.example.com
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

In your tests, read them with process.env:

const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';
const user = process.env.TEST_USER ?? '';
const password = process.env.TEST_PASSWORD ?? '';

GitHub masks secret values in logs. If a secret appears in output, it's replaced with *. Non-sensitive values like BASE_URL can also be stored as repository variables (not secrets), which are visible in the UI. Use variables for URLs and feature flags, secrets for credentials.

Secrets are not available to workflows triggered by pull requests from forks. This is a security feature: a fork PR could otherwise read your secrets. If you need to test fork PRs with secrets, look into the pull_request_target event, but understand the security implications before using it.

Caching for Faster Runs

A fresh workflow run installs Node modules and Playwright browsers from scratch every time. On a cold runner, that's 2-3 minutes before a single test runs. Caching brings that down significantly.

The setup-node action with cache: 'npm' already handles node_modules caching automatically. For Playwright browsers, add a dedicated cache step:

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        id: playwright-cache
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install Playwright browsers
        run: npx playwright install --with-deps
        if: steps.playwright-cache.outputs.cache-hit != 'true'

The cache key includes a hash of package-lock.json. When you upgrade Playwright versions, the lock file changes, the hash changes, the cache misses, and browsers reinstall. When nothing changes, the cache hits and you skip browser installation entirely.

This optimization matters most on large suites. A 200-test suite with caching enabled can go from 8 minutes to 4 minutes just by skipping repeated browser downloads.

Running Only on Specific Branches

The on: section controls when your workflow runs. The minimal example already filters to main and develop. You can be more specific:

on:
  push:
    branches:
      - main
      - develop
      - 'release/**'
  pull_request:
    branches:
      - main
      - develop
  workflow_dispatch:

workflow_dispatch: adds a "Run workflow" button in the Actions tab, letting you trigger it manually. This is useful for running tests against a specific branch on demand without pushing a commit.

You can also run tests on a schedule. Nightly smoke test runs catch issues that appear only in certain time windows or after data accumulates:

on:
  schedule:
    - cron: '0 3 * * *'   # 3am UTC every night
  push:
    branches: [main]
  pull_request:
    branches: [main]

Within a job, you can add branch conditions on specific steps using if::

      - name: Run full suite
        run: npx playwright test
        if: github.ref == 'refs/heads/main'

      - name: Run smoke tests only
        run: npx playwright test --grep @smoke
        if: github.event_name == 'pull_request'

This pattern gives pull requests fast feedback from a smoke suite while main branch runs get the complete test run.

Matrix Strategy for Cross-Browser Testing

Playwright supports Chromium, Firefox, and WebKit. Running all three on every PR is expensive in time and minutes, but you still want cross-browser coverage. The matrix strategy runs parallel jobs with different configurations:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 60

    strategy:
      fail-fast: false
      matrix:
        browser: [chromium, firefox, webkit]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        id: playwright-cache
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}

      - name: Install Playwright browsers
        run: npx playwright install ${{ matrix.browser }} --with-deps
        if: steps.playwright-cache.outputs.cache-hit != 'true'

      - name: Run tests
        run: npx playwright test --project=${{ matrix.browser }}
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

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

fail-fast: false means if Firefox fails, the Chromium and WebKit jobs keep running. With fail-fast: true (the default), any matrix job failure cancels the rest, usually not what you want when debugging cross-browser issues. npx playwright install ${{ matrix.browser }} --with-deps installs only the browser for that job, not all three. This is faster than installing everything in every job.
Run the matrix only on pushes to main, not on every PR. Add if: github.ref == 'refs/heads/main' to the job or use the branch filter on the on.push trigger. Your PRs stay fast; your main branch gets full coverage.

Sharding: Splitting Tests Across Jobs

Sharding splits your test suite across multiple parallel jobs. Where the matrix strategy runs different configurations, sharding runs the same tests faster by dividing them:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install chromium --with-deps

      - name: Run shard
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

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

Playwright distributes tests evenly across shards based on previous run timing data if you provide a blob report, or by file ordering otherwise. With four shards, a 20-minute suite runs in roughly 5 minutes.

You can combine sharding with the matrix strategy. A common pattern is four shards per browser for large suites:

    strategy:
      matrix:
        browser: [chromium, firefox]
        shard: [1, 2, 3, 4]

This creates 8 parallel jobs. Be aware that each job uses runner minutes. 8 jobs running for 5 minutes each costs 40 minutes of your quota, same as running everything serially. The advantage is wall-clock time, not quota consumption.

Merging shard reports into a single HTML report requires the blob reporter. Set reporter: 'blob' in your Playwright config for CI runs, then add a merge job:

  merge-reports:
    if: always()
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true
      - name: Merge into HTML report
        run: npx playwright merge-reports --reporter html ./all-blob-reports
      - name: Upload merged report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Posting Results to PR Comments

Having test results show up directly in the pull request makes failures much more visible. This requires a separate action after the test run completes.

The simplest approach uses the dawidd6/action-junit-report action, which reads JUnit XML output and posts a comment. First, add the JUnit reporter to your Playwright config:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['html'],
    ['junit', { outputFile: 'results.xml' }],
  ],
});

Then add a reporting step to your workflow:

      - name: Run Playwright tests
        run: npx playwright test
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

      - name: Post test results to PR
        uses: dawidd6/action-junit-report@v4
        if: always()
        with:
          report_paths: results.xml
          github_token: ${{ secrets.GITHUB_TOKEN }}
          check_name: Playwright Test Results

GITHUB_TOKEN is automatically available in every workflow. No manual secret configuration needed.

The comment shows pass/fail counts, test names, and failure messages directly in the PR conversation. Reviewers don't need to navigate to the Actions tab to know whether tests passed.

For a more visual summary, the ctrf-io/github-actions-ctrf-summary and similar actions can generate a formatted job summary table. These require generating a CTRF-format report, which Playwright supports via the playwright-ctrf-json-reporter package. The integration is a few extra lines but produces a clean pass/fail table in the GitHub Actions summary view.

FAQ

Tests pass locally but fail in GitHub Actions. Where do I start?

Check these in order: environment variables (a secret not set in Actions will be undefined, not an error), the BASE_URL (if it's hardcoded to localhost, it won't work in CI), and timing. CI runners are slower than dev machines. Increase timeouts in your playwright.config.ts or add retries: 1 specifically for CI using process.env.CI ? 1 : 0.

How do I view the HTML report from a failed run?

Go to the failed workflow run, then Summary, then scroll to Artifacts, then download playwright-report. Extract the zip file and open index.html in a browser. The report includes screenshots and traces for failing tests.

Do I need to commit the Playwright browsers to the repository?

No. The workflow installs them at runtime via npx playwright install. With caching configured, subsequent runs skip the download. Committing browser binaries would bloat your repository by hundreds of megabytes.

My workflow is slow. What has the most impact?

Caching is the first optimization, saving Playwright browsers cuts 1-2 minutes per run. Sharding is the second, splitting a 15-minute suite across 3 shards brings wall-clock time to 5 minutes. Running only Chromium on PRs and full matrix on main is the third, it triples the speed of every PR run.

Can I use the Playwright Docker image instead of installing browsers?

Yes. Set container: mcr.microsoft.com/playwright:v1.52.0-jammy on the job and skip the install step. The tradeoff: Docker containers start slower than bare Ubuntu runners, and you're pinned to a specific Playwright version in the image tag. It's a reasonable choice for teams that want to avoid the install step entirely.

How do I run only tests related to changed files?

Playwright doesn't support this natively. Some teams use path-based filters in the workflow, only triggering the test job when files in certain directories change. Add paths: under on.pull_request to limit when the workflow runs. Full test selection based on code changes requires external tooling and is rarely worth the complexity until your suite exceeds 10 minutes.

→ See also: CI/CD for QA: GitHub Actions, Jenkins, and GitLab Compared | Debugging Flaky Tests: A Practical Guide | Parallel Execution in Playwright: Workers, Shards, and Sharding for Speed | Playwright Test Reports: Built-in HTML vs Allure | GitLab CI + Playwright: Complete Setup Guide