CI/CD with GitHub Actions

26CI/CD with GitHub Actions

What you will master here

  • The four-part pipeline shape: trigger, environment, steps, artifacts
  • Full annotated GitHub Actions YAML for Playwright
  • Sharding with matrix + fail-fast: false
  • npm caching, npm ci vs install, pinning Node
  • Artifacts with if: always()
  • Retries strategy on CI
  • show-trace for debugging remote failures
  • Monitoring retry rates and quarantining flaky tests

26.1 The four-part pipeline shape

1. Trigger push, PR, cron, workflow_dispatch 2. Environment runner OS, Node version, env 3. Steps checkout, install, test, lint 4. Artifacts HTML report, traces, junit.xml
Figure 8 — Every CI pipeline is these four parts.

26.2 Minimal Playwright pipeline

name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

This is what most teams ship with. Now let's make it production-grade.

26.3 Production-grade pipeline with sharding

name: e2e
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 6 * * *'        # nightly smoke
  workflow_dispatch:           # manual trigger

concurrency:
  group: e2e-${{ github.ref }}
  cancel-in-progress: true     # latest commit cancels older runs on same branch

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false         # one shard failing does NOT cancel the others
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20.11.1'   # pin exact version, not "20"
          cache: 'npm'

      - name: Install npm deps
        run: npm ci                  # deterministic; respects package-lock.json

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

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

      - name: Install browser system deps (always)
        if: steps.pw-cache.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - name: Run tests (shard ${{ matrix.shard }})
        run: npx playwright test --shard=${{ matrix.shard }}
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}
          CI: 'true'

      - name: Upload blob report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ strategy.job-index }}
          path: blob-report
          retention-days: 7

      - name: Upload traces
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: traces-${{ strategy.job-index }}
          path: test-results/
          retention-days: 7

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

26.4 Walking through every important line

Triggers

concurrency — cancel older runs

When you push twice quickly to the same branch, the first run is still chewing through tests when the second starts. cancel-in-progress: true kills the older one — saves CI minutes and gives you faster feedback on the latest commit.

fail-fast: false

By default GitHub Actions cancels the whole matrix the moment one job fails. For test suites that's wrong — you want to see all shard failures, not just the first one. fail-fast: false lets every shard run to completion.

Sharding with matrix

--shard=1/4 tells Playwright "run a quarter of the tests, you are shard 1 of 4". The matrix runs four parallel jobs, each on its own runner. A 30-minute suite becomes a 7.5-minute suite.

Pin the Node version

node-version: '20' picks "latest 20" and can change overnight; '20.11.1' is reproducible. Floating Node versions cause "passed yesterday, fails today" mysteries.

npm ci vs npm install

npm installnpm ci
Reads package.json, may update package-lock.jsonReads package-lock.json strictly; refuses if drift
Faster on incremental local devDeletes node_modules first, fresh install
Non-deterministic across machinesBit-for-bit deterministic — CI's friend
Wrong for CIAlways use this in CI

Caching

if: always(), if: failure(), if: success()

blob reports + merge-reports

Each shard emits a "blob" report (binary intermediate). The merge-reports job downloads all of them and merges into a single HTML report — so you have one URL to look at, not four.

26.5 Retries strategy

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: { trace: 'on-first-retry' },
});

26.6 Monitoring retry rates

Retries hide failures. If a test needs 2 retries to pass, it is flaky — just less visibly. Two practices:

  1. Add a report that lists tests that needed any retry to pass (parse the JSON report or use a tool like Currents.dev / Sorry Cypress / a custom dashboard).
  2. Set a threshold: if retry rate > 2% across a week, halt new feature merges until top flakes are fixed or quarantined.

26.7 Quarantining flaky tests

// Tag and isolate
test('checkout flow @quarantine', async ({ page }) => { /* ... */ });

// Run normally without quarantined
// npx playwright test --grep-invert "@quarantine"

// Nightly job runs ONLY quarantined to track if they've recovered
// npx playwright test --grep "@quarantine"
Quarantine is a holding cell, not a graveyardSet a deadline. If a quarantined test isn't fixed in two sprints, delete it — broken tests don't belong in a suite, even silent ones.

26.8 Debugging remote failures with show-trace

Test fails on CI, passes locally. Workflow:

  1. Open the failing GitHub Actions run.
  2. Scroll to the artifacts section; download traces-* or the merged report.
  3. Unzip; find the trace.zip inside (one per failure).
  4. Locally: npx playwright show-trace path/to/trace.zip
  5. Scrub the timeline. Inspect the DOM at the failure step. Look at network. The cause is usually obvious in 30 seconds.

26.9 Other CI niceties

# Secrets — never hardcode credentials
env:
  ADMIN_PASS: ${{ secrets.ADMIN_PASS }}
  DB_URL:    ${{ secrets.DB_URL }}

# Allow a step to fail without failing the whole job
- run: npx playwright test --grep "@experimental"
  continue-on-error: true

# Custom name in the UI per shard
- name: "Tests · shard ${{ matrix.shard }}"
  run: npx playwright test --shard=${{ matrix.shard }}

26.10 The 3-line summary you can quote in an interview

Concise pitch"On CI we run Playwright in a 4-shard matrix with fail-fast off and 2 retries, capturing traces on first retry. Each shard uploads a blob report; a final job merges them into one HTML report and uploads it as an artifact. We pin Node, use npm ci, and cache both npm and the Playwright browser binaries — a full run is ~8 minutes on a 30-minute serial suite."

Module 8 — Interview Q&A bank

What are the four parts of a CI pipeline?
Trigger (what starts it — push, PR, cron, manual), Environment (which runner OS, language version, env vars/secrets), Steps (checkout, install, test, lint), Artifacts (HTML report, traces, JUnit XML — kept after the run for debugging).
Why use npm ci instead of npm install on CI?
npm ci respects package-lock.json strictly and fails if it's out of sync with package.json. It also wipes node_modules first for a clean install. Result: bit-for-bit reproducible installs, no surprise upgrades on CI.
Why pin the Node version with the full SemVer?
'20' floats — "latest 20.x" can roll forward and silently break tests. '20.11.1' is reproducible. The same logic applies to Playwright version and OS image — pin them all.
What is sharding and why does it matter?
Splitting the test suite across multiple parallel jobs. --shard=1/4 says "run a quarter of the tests, I am shard 1". With a 4-shard matrix on independent runners, a 40-minute suite finishes in 10. Combined with in-shard workers, total parallelism = shards × workers.
What does fail-fast: false do?
By default the matrix cancels remaining jobs when one fails. fail-fast: false lets all jobs finish, so you see every shard's failures in one run instead of bisecting them one at a time.
Why upload artifacts with if: always()?
You need the report and traces most when the test step failed. Without if: always(), the upload step is skipped because a previous step failed — exactly when you need it. Use if: failure() for things you only want on failure (e.g. screenshots).
What's a good retry strategy?
0 retries locally (a flake = a bug). 1–2 retries on CI to absorb infra noise. Combine with trace: 'on-first-retry' so you always have evidence when the first attempt failed. Monitor retry rate as a quality metric.
How do you investigate a CI failure that doesn't reproduce locally?
Download the trace artifact from the failed run, then npx playwright show-trace trace.zip locally. Trace Viewer shows DOM, screenshots, network, console at every step — the cause is usually obvious in seconds.
What is the concurrency block for?
Groups workflow runs and cancels older ones in the same group. group: e2e-${{ github.ref }} + cancel-in-progress: true means a new push to a branch cancels the previous run on that branch — you only spend CI minutes on the latest commit.
Why cache Playwright browser binaries separately from npm?
They live in ~/.cache/ms-playwright, not node_modules. They're large (~300 MB) and change only with the Playwright version. Caching them saves a 1–2 minute install on every run. Always pair with install-deps after a cache hit to install Linux system libraries.
What's the difference between install --with-deps and install-deps?
install --with-deps downloads the browsers AND installs OS libraries. install-deps only installs OS libraries (assumes browsers are already there — useful after a cache hit).
How do you manage test environment URLs in CI?
Set them via secrets/env: env: BASE_URL: ${{ secrets.STAGING_URL }}. Tests read process.env.BASE_URL. Different workflows can point to different envs (staging on PR, prod-smoke on nightly).
How do you stop a known flaky test from blocking merges?
Tag it (@quarantine) and exclude with --grep-invert "@quarantine" from the main CI. A separate nightly job runs only those, tracking recovery. Quarantine is a holding cell with a deadline — fix or delete.
What does merge-reports do?
When you shard, each shard produces a partial blob report. npx playwright merge-reports downloads all the blobs and combines them into one HTML report — so failures, traces and statistics are unified, not scattered across N shards.
How do you write a workflow that runs only on PRs touching test code?
Use a paths filter on the trigger: on: pull_request: paths: ['tests/**', 'playwright.config.ts']. Workflow runs only when those files changed.
How do you provide a secret like an API token?
Add it in repo Settings → Secrets and variables → Actions. Reference as ${{ secrets.MY_TOKEN }}. Never echo secrets to logs — Actions masks them by default, but printing derived values can leak.
What's the difference between needs: and dependencies: ?
needs: is Actions syntax — declares a job depends on another job in the same workflow. dependencies: is Playwright config — declares a project depends on another project (e.g. tests depend on a setup project that authenticates).
Should you deploy from the same workflow as tests?
Usually separate. Tests run on every PR. Deploy runs on push to main, only after the test workflow on that commit succeeded. You can wire it with workflow_run trigger or a job dependency.
One-sentence summary of a good Playwright CI pipeline.
Pinned Node + npm ci + cached browsers + matrix sharding with fail-fast off + 2 retries with trace-on-first-retry + blob reports merged into one HTML artifact uploaded if:always().