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 civsinstall, 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
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
pushon main — runs on every mergepull_request— runs on every PRschedulewith cron — nightly smoke for catching infra driftworkflow_dispatch— manual "Run workflow" button in the UI
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 install | npm ci |
|---|---|
| Reads package.json, may update package-lock.json | Reads package-lock.json strictly; refuses if drift |
| Faster on incremental local dev | Deletes node_modules first, fresh install |
| Non-deterministic across machines | Bit-for-bit deterministic — CI's friend |
| Wrong for CI | Always use this in CI |
Caching
cache: 'npm'on setup-node — caches~/.npmby hashingpackage-lock.json. Saves 30–60s.- Separate cache for
~/.cache/ms-playwright(browser binaries) — saves 1–2 minutes. install-depsstill runs after a cache hit to install OS libraries (libnss, libgtk, etc.) — Playwright browsers won't launch without them.
if: always(), if: failure(), if: success()
if: always()— run even if a previous step failed. Use for uploading reports/traces — you most need them when the test step failed.if: failure()— only on failure. Use for things you only care about when something broke.if: success()— default; only when previous steps passed.
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' },
});
- Local: 0 retries. A flake locally is a real bug. Fix it before pushing.
- CI: 2 retries. Absorbs occasional infra noise. But trace is captured on the first retry — so you can investigate even if it ultimately passes.
- Trace on first retry, not every run. Cheap when tests pass, full trace when they don't.
26.6 Monitoring retry rates
Retries hide failures. If a test needs 2 retries to pass, it is flaky — just less visibly. Two practices:
- 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).
- 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"
26.8 Debugging remote failures with show-trace
Test fails on CI, passes locally. Workflow:
- Open the failing GitHub Actions run.
- Scroll to the artifacts section; download
traces-*or the merged report. - Unzip; find the trace.zip inside (one per failure).
- Locally:
npx playwright show-trace path/to/trace.zip - 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
Module 8 — Interview Q&A bank
What are the four parts of a CI pipeline?
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?
--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?
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()?
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?
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?
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?
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?
~/.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?
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?
@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?
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?
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?
${{ 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?
workflow_run trigger or a job dependency.