Running in CI/CD

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

An end-to-end suite earns its keep when it runs on every pull request, on a machine nobody is sitting at, and tells the author what broke without anyone re-running it by hand. That is a different environment from a laptop: no display, a fresh file system on every run, a clock that is billed by the minute, secrets that must not leak, and a web application that has to be started (or found) before the first test begins. This page walks through that environment for the three tools in this section, using GitHub Actions as the example provider. It targets Playwright 1.64, Cypress 16.2 and Selenium 4.51 and was checked on 25 October 2026. The tests are the small Bookshelf smoke suites described in Example Application & Test Layout.

How the workflows were checked matters, so it is stated up front. GitHub-hosted runners were not available, so every workflow on this page was run with act 0.2.89, which executes a workflow’s jobs in Docker containers on the developer machine, against a local Git repository holding the Bookshelf application and the three test projects, on an Apple-silicon Mac (Linux arm64 containers). Each of those runs passed. act is not GitHub, though, and a few lines in the examples had to be changed to make it work; the section Validating workflows with act lists every difference. The Selenium Manager commands were run directly on the Mac, and a deployed preview was simulated with a second local Bookshelf instance. YAML syntax of every file was also checked with a YAML parser. Nothing here has been run on GitHub itself.

Provider-neutral ideas (one job per shard, cache the downloads, keep reports as artifacts) carry over to GitLab CI, Jenkins, CircleCI, Azure Pipelines and others; the tool vendors keep sample configurations for those. The container basics used below are covered in Running Containers and Docker CI/CD with GitHub Actions, and the Git side of triggers and checks in Pull Requests.

The shape of an E2E job

Whatever the tool, a CI run for browser tests has the same stages. The differences between Playwright, Cypress and Selenium are mostly in how each stage is spelled.

flowchart LR T["Trigger
push, pull request,
deployment, manual"] --> C["Checkout
and install
(cached)"] C --> B["Browsers
image, cache or
service container"] B --> A["Application
started locally or
preview URL"] A --> R["Run tests
one job or
shards in parallel"] R --> P["Evidence
blob, screenshots,
traces, videos"] P --> M["Merge and publish
HTML report,
summary, annotations"] M --> G["Pull request
status check"]

Seven decisions recur in every pipeline and are worth making deliberately:

  • Where the browsers come from. Install them in the job (Playwright’s install --with-deps), use an image that already contains them (Playwright, Cypress or Selenium images), or use the browsers preinstalled on the runner.

  • Where the application comes from. Start it inside the job (a webServer entry, the Cypress action’s start, a background process) or test an already deployed preview.

  • How the run is split. One job for a small suite, a matrix of shards for a large one.

  • What is cached. Package-manager downloads and browser binaries; never node_modules and never test results.

  • What is kept. Reports, traces, screenshots and videos as artifacts, with a short retention.

  • Who may see secrets. Credentials of test users and record keys come from the provider’s secret store, and are absent from fork pull requests.

  • When to give up. A global timeout inside the tool, so that the runner never kills the job before a report exists.

Triggers, permissions and concurrency

Every workflow below starts the same way. push on the main branch and pull_request give a check on each change; workflow_dispatch lets a person start the run with inputs; deployment_status reacts to a finished deployment (the preview section uses it). Two small lines save money and noise:

permissions:
  contents: read          # the job needs to read the repository, nothing more

concurrency:
  group: playwright-${{ github.ref }}
  cancel-in-progress: true   # a new push to a pull request cancels its older run

permissions narrows the automatic GITHUB_TOKEN, which matters because the job runs code from the pull request. concurrency with cancel-in-progress avoids spending runner minutes on a commit that has already been replaced. Do not use it on the branch that publishes releases.

What the tools assume about CI

All three tools change behaviour when they believe they are in CI, and the setting is usually the environment variable CI, which GitHub Actions sets to true on every runner.

Aspect Playwright Cypress Selenium

Display

Headless by default; no display needed

cypress run is headless; it starts Xvfb on Linux when needed

Headless only if you pass the --headless=new argument (Chrome) or -headless (Firefox); a Grid node supplies its own virtual display

Retries and workers

Set in the configuration, commonly retries of 1 or 2 and workers: 1 on CI (the documentation recommends one worker for stability)

retries for runMode; parallel only through Cypress Cloud’s --parallel

Your runner (JUnit parallel execution, pytest-xdist) decides

Timeout for the whole run

globalTimeout in the configuration stops the run and still produces a report

Stop the job with the provider’s timeout-minutes; no whole-run option

Provider’s timeout-minutes, or the test framework’s own

Evidence

Trace, screenshot, video per use options; report files in playwright-report

Screenshots on failure and optional video in cypress/screenshots and cypress/videos

Whatever your code writes (screenshots in a failure hook)

Test-run summary for the pull request

github reporter writes annotations; HTML report as artifact

The action writes a job summary

JUnit XML published by a test-report action or by the build tool

The rest of this page expands each column.

Playwright

Playwright’s documentation lists three steps for any provider: make sure the agent can run browsers (the Docker image or the CLI), install the packages and browsers, run npx playwright test. On GitHub Actions the npm init playwright@latest scaffold produces a ready-made workflow; the version below is the same idea, adapted to the layout of the example repository (a playwright folder next to the app folder), with a browser cache and a shard matrix.

Configuration for CI

The workflow depends on a few lines in playwright.config.ts. The ones that matter for CI are the reporter (a blob per shard, plus the github reporter for annotations), workers, retries, forbidOnly, globalTimeout and the webServer entry that starts the application only when no deployed URL is given:

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

const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,      // fail the build if a test.only slipped in
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  // Stop the run itself so that reporters still produce a report.
  globalTimeout: 20 * 60 * 1000,
  // One blob per shard on CI (merged later), a readable list locally.
  reporter: process.env.CI
    ? [['blob'], ['github']]
    : [['list'], ['html', { open: 'never' }]],
  use: { baseURL, testIdAttribute: 'data-testid', trace: 'on-first-retry' },
  // Start the application only when no deployed URL is given.
  webServer: process.env.BASE_URL
    ? undefined
    : {
        command: 'npm start',
        cwd: '../app',
        url: 'http://localhost:3000',
        reuseExistingServer: !process.env.CI,
      },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

A few details explain the choices:

  • workers: process.env.CI ? 1 : undefined follows the documentation’s advice to prioritise stability on CI. Speed comes from sharding across jobs rather than from many workers inside a small runner; raise the number only on a powerful self-hosted machine. See Parallelism, Sharding & Retries for the details.

  • globalTimeout is the documented replacement for a job-level timeout-minutes: when the runner kills a job, nothing is reported; when Playwright stops itself, the reporters still run. If you also set timeout-minutes, keep it comfortably above globalTimeout.

  • reuseExistingServer: !process.env.CI lets a developer keep a server running locally but makes CI always start its own, so a stale process cannot make tests pass.

  • trace: 'on-first-retry' records a trace only for a test that failed once; the trace is the main artifact for diagnosing CI failures (see Debugging, Tracing & Codegen).

A GitHub Actions workflow with a shard matrix

The test job runs once per shard; the merge-reports job collects the blob reports and builds one HTML report:

name: Playwright

on:
  push:
    branches: [main]
  pull_request:

# Cancel the previous run of the same pull request when a new commit arrives.
concurrency:
  group: playwright-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-24.04
    defaults:
      run:
        working-directory: playwright
    strategy:
      fail-fast: false # let every shard finish so the merged report is complete
      matrix:
        shardIndex: [1, 2]
        shardTotal: [2]
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: playwright/package-lock.json

      - run: npm ci

      # The browsers live outside node_modules; cache them per Playwright version.
      - name: Read the Playwright version
        id: playwright-version
        run: echo "version=$(npm ls @playwright/test --json | jq -r '.dependencies["@playwright/test"].version')" >> "$GITHUB_OUTPUT"

      - name: Cache the browsers
        id: browsers
        uses: actions/cache@v6
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}

      - name: Install the browsers and their system libraries
        if: steps.browsers.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps

      - name: Install the system libraries only (browsers restored from cache)
        if: steps.browsers.outputs.cache-hit == 'true'
        run: npx playwright install-deps

      - name: Run the tests of this shard
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
        env:
          BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}

      - name: Upload the blob report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: playwright/blob-report
          retention-days: 1

  merge-reports:
    if: ${{ !cancelled() }}
    needs: [test]
    runs-on: ubuntu-24.04
    defaults:
      run:
        working-directory: playwright
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: playwright/package-lock.json
      - run: npm ci

      - uses: actions/download-artifact@v8
        with:
          path: playwright/all-blob-reports
          pattern: blob-report-*
          merge-multiple: true

      - name: Merge into one HTML report and one summary
        run: npx playwright merge-reports --reporter=html,github ./all-blob-reports

      - uses: actions/upload-artifact@v7
        with:
          name: html-report--attempt-${{ github.run_attempt }}
          path: playwright/playwright-report
          retention-days: 14

What each part does:

  • npm ci installs exactly what package-lock.json lists and fails if the lock file and package.json disagree. actions/setup-node with cache: npm caches npm’s download cache (not node_modules), keyed by the lock file.

  • Installing browsers. npx playwright install --with-deps downloads Chromium, Firefox and WebKit builds that match the installed @playwright/test and installs the Linux libraries they need with apt. The browsers are large and the download is the slowest step of a cold run, hence the actions/cache step keyed on the Playwright version. When the cache is restored, the browsers are there but the operating-system libraries are not (they are not in ~/.cache/ms-playwright), so the job still runs npx playwright install-deps. Both branches ran under act; the cached run restored the key playwright-linux-1.64.1 and skipped the download. A different, simpler option is to use the Docker image shown below, where neither is needed.

  • --shard=1/2 runs the first half of the tests, --shard=2/2 the second. With fullyParallel: true the shards are balanced by test, otherwise by file. fail-fast: false matters: with the default, a failing shard cancels the others and the merged report is incomplete.

  • if: ${{ !cancelled() }} uploads the blob even when the tests failed, which is exactly when it is needed. failure() would skip the upload for a run that only had flaky tests, whose traces are still useful; always() would also upload after a manual cancel.

  • The merge-reports job downloads every blob (merge-multiple: true flattens them into one directory) and runs npx playwright merge-reports. The reporter list html,github produces the HTML report and the GitHub annotations and summary in one step. The blob reporter is what makes this possible: each shard writes raw events, and the merge job turns all of them into one consistent report.

Under act, with two shards and the chromium and firefox projects, each shard printed Running 3 tests using 1 worker, shard 1 of 2 (and 2 of 2), and the merge job reported all six tests as passed:

[Playwright/test-1]   | Running 3 tests using 1 worker, shard 1 of 2
[Playwright/test-1]   ❓  ::notice title=🎭 Playwright Run Summary::  3 passed (2.3s)
[Playwright/test-2]   | Running 3 tests using 1 worker, shard 2 of 2
[Playwright/test-2]   ❓  ::notice title=🎭 Playwright Run Summary::  3 passed (5.1s)
[Playwright/merge-reports]   | merging reports from ...
[Playwright/merge-reports]   | extracting: all-blob-reports/report-1.zip
[Playwright/merge-reports]   | extracting: all-blob-reports/report-2.zip
[Playwright/merge-reports]   ❓  ::notice title=🎭 Playwright Run Summary::  6 passed (49.0s)

The 49.0s is the sum of the test durations across both shards as recorded in the blobs, not the wall-clock time of the job.

Viewing the HTML report

The HTML report is a static site, but it needs a web server to work: opening index.html from disk does not load the traces. Download the artifact from the run page, extract it and serve it:

npx playwright show-report path/to/extracted-html-report

The report links each failing test to its trace. To publish reports on the web instead of downloading zip files, the documentation describes uploading playwright-report to an Azure Storage static website from the workflow, with a service principal stored as repository secrets. That variant needs an Azure subscription and is not covered by the example above; steps that use secrets do not work for pull requests from forks.

Running changed tests first

For a faster feedback loop on pull requests, a preliminary run of only the test files affected by the change can fail early; the complete run still follows. It needs a full clone so that the base branch can be compared:

    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0   # a shallow clone cannot be compared to the base branch
      # ... setup, npm ci, playwright install ...
      - name: Run changed Playwright tests
        if: github.event_name == 'pull_request'
        run: npx playwright test --only-changed=origin/$GITHUB_BASE_REF
      - name: Run Playwright tests
        run: npx playwright test

--only-changed analyses the dependency graph of the test files, which is a heuristic; it can miss tests, so the full run must remain.

The Playwright Docker image

mcr.microsoft.com/playwright contains the Playwright browsers and the system libraries they need, built on Ubuntu. It does not contain the Playwright package: your project installs that with npm ci, and the version must match. Pin the image to the exact version of @playwright/test in the lock file, because a mismatch makes Playwright unable to find the browser executables. Tags exist per Ubuntu release (-noble for 24.04, -jammy for 22.04, -resolute for 26.04), and the images are published for amd64 and arm64. Alpine is not supported, because the Firefox and WebKit builds need glibc.

Running the image directly

The documentation recommends two flags for any container that runs browsers:

  • --init runs a small init process as PID 1, which prevents zombie processes (browsers spawn many children).

  • --ipc=host shares the host’s IPC namespace so Chromium does not run out of shared memory and crash. A more conservative alternative is a larger /dev/shm with --shm-size.

docker run --rm --init --ipc=host \
  -e CI=1 \
  -e BASE_URL=http://host.docker.internal:3965 \
  -v "$PWD":/work -w /work \
  mcr.microsoft.com/playwright:v1.64.1-noble \
  sh -c 'npm ci && npx playwright test --reporter=list'

Run on the Mac against a local Bookshelf (the container reaches the host through host.docker.internal), this printed Version 1.64.1 and 6 passed (5.4s) for the chromium and firefox projects.

Which user

By default the image runs as root. That disables the Chromium sandbox (it needs user namespaces, which root in a container lacks), which the documentation accepts for end-to-end tests of code you trust. For crawling or visiting untrusted sites, use the unprivileged pwuser account (user and group id 1001 in the image) together with a seccomp profile that allows user namespaces:

docker run -it --rm --ipc=host --user pwuser \
  --security-opt seccomp=seccomp_profile.json \
  mcr.microsoft.com/playwright:v1.64.1-noble /bin/bash

id inside the image confirmed uid=1001(pwuser), and that the browsers sit in /ms-playwright (chromium-1248, firefox-1555, webkit-2370). The seccomp profile itself is published in the documentation; it was not exercised here. The image is meant for testing and development, not for browsing untrusted sites.

As a job container

GitHub Actions can run a whole job inside the image with jobs.<job_id>.container. This keeps the host clean and gives the same operating system every time, which is also what makes screenshot comparisons stable between runs (see Visual Regression Testing):

name: Playwright in a container

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  e2e:
    runs-on: ubuntu-24.04
    container:
      # Pin the image to the exact version of @playwright/test in package-lock.json.
      image: mcr.microsoft.com/playwright:v1.64.1-noble
      options: --init --ipc=host --user 1001
    defaults:
      run:
        working-directory: playwright
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: playwright/package-lock.json

      - run: npm ci

      # No "playwright install": the browsers and their libraries are in the image.
      - run: npx playwright test

      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright/playwright-report
          retention-days: 14

options are passed to docker create, which is where --init, --ipc=host and --user 1001 go. The documentation’s own example uses --user 1001 and leaves the other two flags out; adding them follows the Docker page’s recommendation. Running as pwuser means the checkout and the node_modules folder must be writable by that user, which the runner arranges for the workspace. Under act the job passed with six tests, but act does not reproduce every container option of the real runner, so the effect of --init and --ipc=host was checked with the plain docker run above.

Cypress

Cypress needs two commands in CI, install and cypress run, plus a way to start the application and wait for it. Cypress maintains an official GitHub Action that does all of it. The Cypress documentation recommends binding to the latest major version, cypress-io/github-action@v7 (release 7.4.7 on 23 October 2026), or to an exact release tag such as 7.4.7 if you want no surprises.

The Cypress GitHub Action

name: Cypress

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  cypress:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7

      # Installs with "npm ci", restores and saves ~/.cache/Cypress and ~/.npm,
      # starts the application, waits for it, runs "cypress run" and writes a job summary.
      - uses: cypress-io/github-action@v7
        with:
          working-directory: cypress
          start: npm --prefix ../app start
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 60
          browser: chrome

      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/cypress/screenshots
          if-no-files-found: ignore
          retention-days: 7

In one step the action:

  1. Installs dependencies with the command that matches the lock file: npm ci for package-lock.json, pnpm install --frozen-lockfile for pnpm-lock.yaml, yarn --frozen-lockfile for yarn.lock (exactly one lock file must exist in the working directory).

  2. Caches the Cypress binary (in ~/.cache/Cypress, or CYPRESS_CACHE_FOLDER) and the package manager’s cache (~/.npm for npm), with a key derived from the lock file. There is no built-in cache for pnpm and Yarn Modern; use actions/setup-node for those.

  3. Starts the application with start in the background, and blocks until wait-on answers (wait-on-timeout is in seconds). This replaces the race of npm start & npx cypress run that the Cypress documentation warns against, and the start-server-and-test or wait-on packages.

  4. Runs cypress run with the requested browser, and publishes a job summary with the passed, failed, pending and skipped counts.

The run printed the usual Cypress banner and summary under act:

Browser:        Chrome 156 (headless)
Specs:          1 found (smoke.cy.ts)
Running:  smoke.cy.ts                                                (1 of 1)
  2 passing (325ms)
✔  All specs passed!                        326ms        2        2        -        -

The full list of action inputs (from the action’s action.yml) falls into a few groups:

Input Purpose

working-directory, project

Folder containing the Cypress project (and its lock file) in a monorepo; project is Cypress’s own --project.

install, install-command, package-manager-cache

Skip or replace the install step; set package-manager-cache: false to disable automatic package-manager caching; cache-key customises the key.

build, start, start-windows, wait-on, wait-on-timeout

Optional build, the server start command (run in the background), the URL to wait for and how long to wait.

browser, headed

chrome, firefox, edge, electron (the default); headed: true runs a visible browser (under Xvfb on Linux). cypress run is headless by default.

spec, config, config-file, env, expose

Run specific specs; override configuration values; use another config file; set Cypress environment variables (env) and public, non-sensitive values (expose, from Cypress 15.10).

record, parallel, group, tag, ci-build-id, auto-cancel-after-failures

Cypress Cloud recording and parallelization; see Cypress Cloud.

command, command-prefix, runTests

Replace cypress run with your own command, prefix it, or only install and build (runTests: false) in a job that precedes the test jobs.

quiet, publish-summary, summary-title

Silence Cypress output; turn the job summary off; name the summary when several action calls share a job.

Outputs resultsUrl

The Cypress Cloud URL of a recorded run (dashboardUrl is deprecated).

The browser names refer to what is installed on the runner. GitHub’s Ubuntu images include Chrome, Firefox and Edge, but their versions change when the runner image is updated, which is a common source of "nothing in my repo changed" failures. Docker images, below, remove that variable.

Cypress Docker images

Cypress publishes four families of images (Docker Hub and the Amazon ECR Public Gallery; linux/amd64 and linux/arm64):

Image Contents

cypress/base

Debian with the operating-system libraries Cypress needs, Node.js, npm and Yarn Classic. The Node version is selected by the tag.

cypress/browsers

cypress/base plus Chrome, Firefox and (on amd64) Edge. The tag selects Node and the browser versions.

cypress/included

cypress/browsers plus a fixed version of Cypress installed globally. The tag selects the Cypress version.

cypress/factory

A Dockerfile generator for custom images with chosen versions of the operating system, Node.js, browsers and Cypress.

The short-form tag cypress/browsers:24.22.1 selects the newest browser versions built with that Node.js release; the long-form tags such as node-24.22.1-chrome-156.0.8078.47-1-ff-158.0.1-edge-155.0.4283.62-1 pin every browser version. Prefer a pinned tag over latest, so that a new major Node.js or browser release arrives as a reviewed change.

name: Cypress in a container

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  cypress:
    runs-on: ubuntu-24.04
    container:
      # Node.js and the browsers are fixed by the image tag, not by the runner image.
      image: cypress/browsers:24.22.1
      options: --user 1001
    steps:
      - uses: actions/checkout@v7

      - uses: cypress-io/github-action@v7
        with:
          working-directory: cypress
          start: npm --prefix ../app start
          wait-on: 'http://localhost:3000'
          browser: firefox

options: --user 1001 avoids permission problems between the container user and the checked-out workspace, as the action’s README advises. With cypress/included, set CYPRESS_INSTALL_BINARY: 0 in the step’s env, because the binary is already in the image and a restored cache would otherwise conflict with it. Containers require a Linux runner.

Under act this workflow ran Firefox 158 in the image (2 passing), with the npm and Cypress caches saved by the previous run restored (about 13 MB and 197 MB). The --user 1001 line had to be removed for act, because the cache step in the action failed there with npm error path /.npm (the unprivileged user had no usable home directory in the act container, which a real runner provides as /github/home).

Caching and install-time variables

Cypress downloads its binary once to a global cache: ~/.cache/Cypress on Linux, ~/Library/Caches/Cypress on macOS. In CI that directory must persist between runs, or every run downloads a few hundred megabytes. The Cypress documentation recommends caching ~/.cache (or the package manager’s own cache such as ~/.npm), not node_modules: the package manager’s cache validates integrity and re-downloads only what changed, whereas a cached node_modules can leave npm believing Cypress is installed when its binary is not. Use npm ci rather than npm install, keep cache keys strict (a lax restore key lets old binaries accumulate), and use CYPRESS_CACHE_FOLDER if the default location must change.

Other variables of the install phase that are common in CI:

Variable Effect

CYPRESS_INSTALL_BINARY

A version, URL or local file to install instead of the default; 0 skips the download (for cypress/included).

CYPRESS_DOWNLOAD_MIRROR

Download the binary from a mirror when the network blocks the public host.

CYPRESS_CACHE_FOLDER

Where the binary is kept.

CYPRESS_BASE_URL, CYPRESS_REPORTER, CYPRESS_DEFAULT_COMMAND_TIMEOUT

Any configuration value can be set with the CYPRESS_ prefix; the first points tests at a preview, the second switches to a CI-friendly reporter such as junit, the third gives slow machines more time.

HTTP_PROXY, HTTPS_PROXY, NO_PROXY

Proxy used to download the binary and to run tests; PAC files and SOCKS proxies are not supported.

CYPRESS_RECORD_KEY is different: it must be a real operating-system environment variable (from the provider’s secret store), not a value in cypress.env.json or the configuration’s env block. The Cypress Cloud page covers recording, parallelization and the --ci-build-id flag in Cypress Cloud, and the configuration options in Getting Started.

Other providers

The Cypress documentation keeps worked examples for AWS Amplify, AWS CodeBuild, Azure Pipelines, Bitbucket Pipelines, Buildkite, CircleCI, GitLab, Jenkins, Netlify, Semaphore and Travis CI. All of them reduce to the same recipe: cache the global Cypress cache and the package manager cache, npm ci, boot the server with start-server-and-test, wait-on or concurrently, and npx cypress run, optionally with --record --parallel on several machines. Machine size matters: if videos have random pauses, the CPU percentage in the debug logs stays above 100% or the browser crashes, the runner has too little CPU or memory; splitting very long specs into shorter ones also helps.

Selenium

Selenium does not ship a CI action; it is a library, so a pipeline simply runs the project’s own build (mvn verify, pytest, dotnet test, npm test). The CI-specific decisions are where the browser and driver come from.

Option How Trade-off

Browser and driver on the runner

GitHub’s Ubuntu images include Chrome, Firefox and Edge; Selenium Manager (bundled with the bindings) finds or downloads the matching driver.

Simplest; browser versions change with the runner image, and Selenium Manager needs network access unless its cache is warm.

Selenium Grid in a service container

selenium/standalone-chrome (or Firefox, Edge) as a job service; the tests use RemoteWebDriver.

Pinned browser versions, a virtual display and noVNC built in; needs a way for the browser to reach the application.

A Grid with several nodes

docker compose with the hub and nodes, or the Docker Selenium Helm chart on a cluster.

Parallel sessions on many browsers; more moving parts. See Selenium Grid.

A cloud grid

A vendor’s Selenium endpoint; see Cross-Browser Testing.

No infrastructure; needs an account, credentials and a tunnel to anything not public.

Source: Selenium: Grid

Selenium Grid as a service container

The standalone image runs a complete Grid (router, distributor, node and a browser) in one container, which is ideal for a CI job. GitHub Actions starts it as a service container beside the job, waits for its health check, and publishes the port to the runner:

name: Selenium

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  grid:
    runs-on: ubuntu-24.04
    services:
      # The browser runs in this container; the tests run on the runner.
      selenium:
        image: selenium/standalone-chrome:4.51.0
        ports:
          - 4444:4444
        options: >-
          --shm-size=2g
          --health-cmd "/opt/bin/check-grid.sh --host 0.0.0.0 --port 4444"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 12
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24

      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'
          cache: pip
          cache-dependency-path: selenium-python/requirements.txt

      - run: pip install -r selenium-python/requirements.txt

      - name: Start the application
        run: |
          npm --prefix app start &
          for i in $(seq 1 30); do
            curl --silent --fail --output /dev/null http://localhost:3000/api/books && exit 0
            sleep 1
          done
          exit 1

      - name: Find an address of the runner that the Grid container can reach
        run: echo "APP_HOST=$(hostname -I | awk '{print $1}')" >> "$GITHUB_ENV"

      - name: Run the tests through the Grid
        working-directory: selenium-python
        run: python -m pytest tests
        env:
          SELENIUM_REMOTE_URL: http://localhost:4444
          # The browser is in another container: give it an address that reaches the runner.
          BASE_URL: http://${{ env.APP_HOST }}:3000
          API_URL: http://localhost:3000
          BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}

Source: docker-selenium

The pieces that are specific to a Grid:

  • --shm-size=2g. Chrome uses /dev/shm; Docker’s default of 64 MB makes it crash on larger pages. The same reason applies to --ipc=host for Playwright.

  • The health check. /opt/bin/check-grid.sh is a script in the docker-selenium images that exits with success when the Grid reports itself ready. Without a health check the first test can start before a browser slot exists. The job log showed the container turn from "starting" to "healthy" before the steps ran.

  • Two addresses for the application. The tests run on the runner and reach the application at localhost:3000, but the browser runs inside the service container, where localhost is the container itself. The workflow therefore passes the browser an address of the runner (BASE_URL, derived from hostname -I) and keeps API_URL on localhost for the test-data reset call. The application must listen on all interfaces, not only on 127.0.0.1. This worked under act; it was not tried on a GitHub-hosted runner, where the alternative is --add-host=host.docker.internal:host-gateway in the service options and a BASE_URL that uses host.docker.internal.

  • The test code. A fixture creates webdriver.Remote(command_executor=GRID_URL, options=options) when SELENIUM_REMOTE_URL is set, and a local headless webdriver.Chrome otherwise. The same switch in Java is new RemoteWebDriver(new URL(gridUrl), options). See Selenium Grid for both bindings in full.

# selenium-python/tests/conftest.py
import os
import urllib.request

import pytest
from selenium import webdriver

# Where the browser opens the application, and (optionally) where the Grid is.
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000")
GRID_URL = os.environ.get("SELENIUM_REMOTE_URL")


@pytest.fixture(autouse=True)
def reset_data():
    # Always reset through the address the test runner can reach.
    api = os.environ.get("API_URL", BASE_URL)
    urllib.request.urlopen(urllib.request.Request(f"{api}/api/test/reset", method="POST"))


@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    if GRID_URL:
        driver = webdriver.Remote(command_executor=GRID_URL, options=options)
    else:
        options.add_argument("--headless=new")
        driver = webdriver.Chrome(options=options)
    yield driver
    driver.quit()

Under act the service container was created, became healthy, and python -m pytest tests finished with 2 passed in 1.59s: the search test and a signed-in test, both driving the Chrome inside the container against the Bookshelf on the job container. A first attempt, without the reader password in the environment, failed the sign-in test with a timeout instead of an authentication error, because an unset secret expands to an empty string; see the secrets section. For the Java binding, actions/setup-java with cache: maven plays the role of setup-python with cache: pip, and mvn -B verify runs the tests; that variant was not run on this page.

For several parallel browsers, replace the standalone image with a hub and nodes started by docker compose up -d in an earlier step (see Compose in Practice) and point the tests at the hub. A Grid on a self-hosted fleet or in Kubernetes is covered in Selenium Grid.

Selenium Manager in CI

When a job runs the browser on the runner rather than in a Grid, Selenium Manager (a Rust command-line tool bundled in every binding since Selenium 4.6) finds or downloads the driver, and since 4.11 can also download the browser. Its state lives in ~/.cache/selenium, which makes it a good cache candidate, and it can be configured without code changes through environment variables or a se-config.toml file in that cache. Every key has an environment variable: upper-case it, replace - with _, prefix SE_.

Variable Use in CI

SE_CACHE_PATH

Where drivers, browsers and metadata are stored; point it at a folder that the cache action saves.

SE_OFFLINE

true disables network requests; Selenium Manager then uses only what is in the cache or on the PATH.

SE_PROXY

HTTP proxy used by Selenium Manager, for example myproxy:8080 or user:pass@myproxy:8080.

SE_TIMEOUT

Timeout for its network requests in seconds (default 300).

SE_DRIVER_MIRROR_URL, SE_BROWSER_MIRROR_URL

Internal mirrors of the driver and browser repositories for air-gapped networks.

SE_BROWSER_PATH, SE_DRIVER_VERSION, SE_CHROME_VERSION

Use a specific installed browser or request specific versions.

SE_AVOID_BROWSER_DOWNLOAD, SE_SKIP_DRIVER_IN_PATH

Never download a browser; ignore drivers found on the PATH.

SE_AVOID_STATS

true opts out of the anonymous usage statistics Selenium Manager sends once a day.

SE_TTL

Seconds that cached version lookups stay valid (default 3600).

The order of precedence is CLI arguments (which the bindings fill from the browser options), then se-config.toml, then environment variables. A proxy set in the browser options is used by the browser and by Selenium Manager; use SE_PROXY when only the downloads need a proxy. Any other value (cache path, offline, mirrors) cannot be set from browser options, so set it in the environment.

A job that uses the runner’s browser and caches Selenium Manager’s folder looks like this:

name: Selenium on the runner

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  local-chrome:
    runs-on: ubuntu-24.04
    env:
      SE_CACHE_PATH: ${{ github.workspace }}/.selenium-cache
      SE_AVOID_STATS: 'true'
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24

      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'
          cache: pip
          cache-dependency-path: selenium-python/requirements.txt

      - run: pip install -r selenium-python/requirements.txt

      # chromedriver and the version metadata are downloaded once per week.
      - uses: actions/cache@v6
        with:
          path: ${{ env.SE_CACHE_PATH }}
          key: selenium-manager-${{ runner.os }}-${{ hashFiles('selenium-python/requirements.txt') }}

      - run: npm --prefix app start &

      - name: Run the tests with the Chrome of the runner
        working-directory: selenium-python
        run: python -m pytest tests
        env:
          BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}

This job was not run under act because the Linux arm64 container has no Chrome installed and no attempt was made to add one; the Selenium Manager variables it relies on were exercised on the Mac instead. With an empty cache, no network and Chrome already installed, the test run failed because the driver could not be found:

$ SE_CACHE_PATH=/tmp/se-cache SE_OFFLINE=true python -m pytest tests -x -q
WARNING  selenium.webdriver.common.selenium_manager: Unable to discover proper chromedriver version in offline mode
E   selenium.common.exceptions.NoSuchDriverException: Message: Unable to obtain driver for chrome; ...

The same command online filled the cache (chromedriver/mac-arm64/155.0.8059.84 and se-metadata.json, which holds the resolved driver version, its time to live and the statistics entry) and passed:

$ SE_CACHE_PATH=/tmp/se-cache python -m pytest tests -q
..                                                                       [100%]
2 passed in 3.62s
$ SE_CACHE_PATH=/tmp/se-cache SE_OFFLINE=true python -m pytest tests -q
..                                                                       [100%]
2 passed in 1.28s

With the cache warm, SE_OFFLINE=true runs without any network access, which is the configuration for a locked-down network after a first, cache-filling job. A proxy that cannot be reached fails at the first download, which is how a wrong SE_PROXY shows up:

$ SE_CACHE_PATH=/tmp/se-cache2 SE_PROXY=http://127.0.0.1:9 SE_TIMEOUT=5 python -m pytest tests -x -q
E   {'code': 65, 'message': 'error sending request for url (https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json)', 'driver_path': '', 'browser_path': ''}

A Grid in a container avoids all of this, because the driver and browser are already inside the image.

Running against a preview environment

Many teams deploy every pull request to a temporary URL (Vercel, Netlify, a Kubernetes namespace, a review app) and run the end-to-end suite against it. The suite then tests the artifact that will be deployed, behind the real network, TLS and proxy, rather than a server started on the runner. The workflow needs two things: the URL, and the commit that was deployed.

name: E2E against a preview

on:
  # A deployment service reports "success" with the URL of the preview.
  deployment_status:
  # A person (or another workflow) can point the suite at any URL.
  workflow_dispatch:
    inputs:
      base_url:
        description: URL of the deployed application
        required: true

permissions:
  contents: read

jobs:
  e2e:
    if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
    runs-on: ubuntu-24.04
    container:
      image: mcr.microsoft.com/playwright:v1.64.1-noble
      options: --init --ipc=host --user 1001
    env:
      BASE_URL: ${{ github.event.inputs.base_url || github.event.deployment_status.target_url }}
    defaults:
      run:
        working-directory: playwright
    steps:
      - uses: actions/checkout@v7
        with:
          # Test the commit that was deployed, not the tip of the default branch.
          ref: ${{ github.event.deployment.sha || github.sha }}

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: playwright/package-lock.json
      - run: npm ci

      - name: Wait until the preview answers
        run: |
          for i in $(seq 1 30); do
            curl --silent --fail --output /dev/null "$BASE_URL/api/books" && exit 0
            sleep 2
          done
          echo "::error::$BASE_URL did not become ready"
          exit 1

      - run: npx playwright test --project=chromium
        env:
          BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}

How the URL reaches each tool:

Tool Mechanism

Playwright

use.baseURL; the example configuration reads BASE_URL and removes webServer when it is set. The environment variable PLAYWRIGHT_TEST_BASE_URL sets baseURL without any configuration change (the documentation’s own deployment example uses it).

Cypress

CYPRESS_BASE_URL, or baseUrl through the action’s config: baseUrl=... input. Do not use start and wait-on for an external URL; use wait-on only if the preview takes time to warm up.

Selenium

Your own variable (BASE_URL above) read by the test fixtures; in Grid setups the Grid must be able to reach the URL, which for a private preview means a runner inside the same network.

Under act, a workflow_dispatch event with base_url set to the address of a second Bookshelf instance on the host passed (Running 3 tests using 1 worker, 3 passed). The checkout step’s ref input had to be removed for that run, because act cannot fetch a ref from GitHub for a local repository; the deployment_status trigger itself was not exercised.

Preview testing has traps of its own:

  • The data. A shared preview has no per-test reset unless the application offers a test-only API, which should be off in production. Bookshelf has such an API (/api/test/reset and /api/test/seed) and can disable it with BOOKSHELF_TEST_API=off; if the preview has it disabled, the fixtures that call it fail with a 404. Prefer tests that create their own data with unique names.

  • Concurrency. Two runs against one preview share state. Give each pull request its own preview, or serialise runs with a concurrency group keyed on the environment.

  • Readiness. A deployment can report success before the application is warm; a readiness loop like the one above is cheaper than flaky first tests.

  • Authentication. Previews are often behind a protection layer (a bypass header or token); pass it as a secret through extraHTTPHeaders (Playwright), cy.intercept or request headers (Cypress), or the browser options (Selenium).

Secrets

Test accounts, API tokens for staging back-ends, record keys and cloud-grid credentials all enter the job as GitHub secrets and are exposed to a step as environment variables:

      - run: npx playwright test
        env:
          # The step sees the value; the log masks it; the code reads process.env.
          BOOKSHELF_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}

Rules that apply regardless of the tool:

  • Expose a secret to the smallest scope that needs it. Put env on the step that runs the tests, not on the whole workflow, and never pass a secret to a step that runs code you did not review.

  • Never print secrets. GitHub masks exact matches in logs, but not transformations of them (Base64, URL-encoded). Do not echo them and do not enable debug logging of requests that contain them.

  • Fork pull requests do not receive secrets. A fork’s pull_request workflow runs with an empty value, which is why the first Selenium run above failed with a timeout instead of an authentication error: the sign-in form was submitted with an empty password. Make the test fail early and clearly when a required variable is empty. Do not switch to pull_request_target to get secrets; it runs the workflow definition of the base branch with secrets and, if it also checks out the pull request’s code, executes untrusted code with access to them.

  • Artifacts are as sensitive as secrets. Playwright traces, HTML reports, Cypress screenshots and videos, and console logs can contain tokens, credentials of the test user and application data. The Playwright documentation advises uploading them only to trusted artifact stores or encrypting them. Use short retention (retention-days).

  • Use environments for production-like credentials. A GitHub environment can require reviewers before a job that uses its secrets runs, and can restrict which branches may use them.

  • Prefer short-lived credentials. OpenID Connect lets the workflow obtain a temporary token for a cloud provider instead of storing a long-lived key.

  • Record keys. The Cypress record key is not enough to read runs, but it can write to the project; keep it in a secret and pass it as CYPRESS_RECORD_KEY.

Credentials in the example repository are the demonstration defaults of the Bookshelf application; real systems use values from the provider’s secret store only.

Artifacts, reports and annotations

Evidence is what turns "the build is red" into a diagnosis. Each tool leaves different files:

Tool Files worth keeping How they are produced How to read them

Playwright

playwright-report (HTML), blob-report, test-results (traces, screenshots, videos)

The html and blob reporters; trace, screenshot and video in use

npx playwright show-report, npx playwright show-trace trace.zip

Cypress

cypress/screenshots, cypress/videos

Screenshots on failure by default; video when video: true

Open the files, or use Cypress Cloud’s Test Replay when recording

Selenium

Screenshots, page source, browser logs, JUnit XML

Your code in a failure hook; JUnit XML from the test runner

Open the files; a test-report action renders the XML

Upload the evidence on failure with the right condition, and give each artifact a clear name:

      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}      # runs after a failure, not after a manual cancel
        with:
          name: playwright-traces-${{ matrix.shardIndex }}
          path: playwright/test-results
          if-no-files-found: ignore
          retention-days: 7

Two artifact limits explain several surprises. Artifact names must be unique within a run in the current major versions of the artifact actions, which is why the matrix index is in the name above; and artifacts are only available after the uploading job completes, so one job cannot read another’s artifacts until that job has finished (needs).

Annotations and summaries

Artifacts live on the run page; what a pull request author sees first is the check status and the annotations on the diff.

  • Playwright’s github reporter prints workflow commands that GitHub turns into annotations on the failing line, plus a summary notice. A deliberately wrong assertion (toHaveCount(2) where the page shows one card) produced, with CI=1 npx playwright test --reporter=github,list:

    ::error file=tests/fail.spec.ts,title=[chromium] › tests/fail.spec.ts:2:5 › search shows two results,line=6,col=47::  1) [chromium] › ...
    ::notice title=🎭 Playwright Run Summary::  1 failed%0A    [chromium] › tests/fail.spec.ts:2:5 › search shows two results ...
  • The Cypress action writes a job summary (Markdown on the run’s summary page) with the pass, fail, pending and skipped counts and the duration; summary-title names it when a job calls the action more than once, and publish-summary: false switches it off. Recorded runs add resultsUrl, the Cypress Cloud link, as a step output.

  • JUnit XML is the lingua franca for everything else. Playwright has the junit reporter, Cypress --reporter junit (a bundled reporter), JUnit and pytest produce it natively. Test-report actions and the provider’s test-result view then show failed tests, durations and history. To add a Markdown report yourself, append to the file in $GITHUB_STEP_SUMMARY.

  • Workflow commands such as ::error file=...,line=...::message and ::notice::message work from any step, so a Selenium suite can annotate a failure from a conftest.py hook.

Cypress Cloud, Playwright Workspaces (Azure App Testing) and the cloud grids add dashboards, flaky-test history and pull request comments; they need accounts and tokens, so none of that was run for this page. See Cypress Cloud and Cross-Browser Testing.

Validating workflows with act

act runs a workflow’s jobs in Docker containers and emulates the GitHub environment (the runner file commands, the github context, a local artifact and cache server). It is useful to catch YAML mistakes, wrong paths and missing installation steps before pushing, and was how every workflow on this page was exercised. The commands used were of this form, from the root of the example repository:

# once: download the release binary for the host platform, e.g. act_Darwin_arm64.tar.gz from
# https://github.com/nektos/act/releases and unpack it

act push -W .github/workflows/playwright.yml \
  -P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
  --container-architecture linux/arm64 \
  --artifact-server-path ./.act-artifacts \
  --secret-file .act.secrets          # one NAME=value line per secret; keep it out of Git

act workflow_dispatch -W .github/workflows/preview.yml -e event.json   # event.json: {"inputs":{"base_url":"..."}}

Source: nektos/act

-P maps the runs-on label to a Docker image; the catthehacker images are community-built approximations of GitHub’s runner image and are much smaller than the real one. For Cypress the label was mapped to cypress/browsers:24.22.1 instead, because that image carries the system libraries and browsers Cypress needs, which the community image was not built to provide; the real GitHub runner image includes them.

The differences between act and GitHub that affected the examples, and what was done for the runs:

  • Artifact actions. act 0.2.89’s artifact server rejected actions/upload-artifact@v7 (it does not understand the request’s mime_type field), so the act runs used copies of the workflows with upload-artifact@v4 and download-artifact@v4. The published workflows use the current majors, v7 and v8, as listed by the actions' repositories on 25 October 2026.

  • Parallel matrix jobs. The act containers shared the host’s port 3000, so two shards could not both start the application; the act copy of the Playwright workflow added max-parallel: 1. On GitHub each job has its own machine.

  • Unprivileged container user. Removed options: --user 1001 from the Cypress container job (see above); --init and --ipc=host were validated with docker run instead.

  • Checkout of a specific ref. Removed the ref input from the preview workflow’s checkout.

  • Architecture. The containers were linux/arm64, so the Chromium, Firefox and Chrome builds were the arm64 ones. Safari and WebKit-based results on a real runner are not covered.

A green act run therefore means that the commands, paths, caches and tool configuration work end to end in a clean Linux container, not that GitHub’s scheduler, permissions, secrets handling or runner image will behave identically.

Pitfalls

  • No health check or wait. Starting the application and the tests in the same step races. Use webServer.url, the Cypress action’s wait-on, or a loop with curl --fail. Do not use a fixed sleep.

  • Browsers and tests out of sync. A Playwright image tag different from the @playwright/test version makes browser executables unfindable; a cached ~/.cache/ms-playwright keyed without the version restores the wrong browsers after an upgrade.

  • Caching the wrong things. Cache package-manager downloads and browser binaries, not node_modules; keep the key tied to the lock file and tool version.

  • localhost means a different machine. Inside a container or service container, localhost is the container. Use a reachable address for browsers in other containers, and host.docker.internal only where it resolves.

  • Out-of-memory crashes. Chrome in a container with a small /dev/shm crashes with errors that look like random test failures. Use --ipc=host or --shm-size.

  • Hung jobs. Without globalTimeout (Playwright) or timeout-minutes, a stuck job runs until the provider’s limit, which defaults to hours, and Playwright produces no report if the runner kills it.

  • Skipping the report when tests fail. An upload step without if: ${{ !cancelled() }} (or always()) does not run after a failing step, so the evidence disappears when it is needed.

  • Unpinned actions and images. A floating latest tag changes the browser or Node.js under you. Pin images, pin action majors (or full SHAs for security-sensitive workflows) and update them in a pull request.

  • Secrets in forks. An empty secret looks like a wrong-password failure. Check for it at the start of the run.

  • Retries that hide problems. A suite that passes only on the second try is flaky; track flaky tests instead of raising the retry count. See Flaky Tests & Reliability.

  • Different results from local. Make the CI path reproducible on a laptop: the Docker run above, or the same npm ci and test command, so a failure can be debugged without pushing commits.

What changed recently

  • GitHub actions, October 2026. The latest releases are actions/checkout v7.0.1, actions/setup-node v7.1.0, actions/cache v6.1.0, actions/upload-artifact v7.0.2 and actions/download-artifact v8.0.2. The official Playwright and Cypress examples still show checkout@v6, setup-node@v6 and upload-artifact@v4 or v5; older majors keep working, but check the release notes of an action before moving to a new major version.

  • Playwright documentation, October 2026. The CI examples use the v1.64.0-noble image, recommend setting globalTimeout instead of a job-level timeout and one worker on CI, and the image tags include -resolute for Ubuntu 26.04.

  • Playwright 1.46. --only-changed; 1.37 introduced the blob reporter and merge-reports that the shard workflow uses.

  • Cypress 16. Node.js 22, 24 or 26 and later are required (Node 20 and 25 are no longer supported), so a CI image or setup-node version must match; Cypress 16 removed the synchronous environment accessor, so test code reads secrets with cy.env().

  • cypress-io/github-action v7. The latest release is 7.4.7 (23 October 2026); the expose input for public configuration values requires Cypress 15.10 or later; the README examples run on ubuntu-26.04 as well as 24.04.

  • Selenium 4.51. The Docker Selenium images are published at 4.51.0 and mirrored to the GitHub Container Registry; Selenium Manager is bundled with every binding and sends anonymous statistics unless SE_AVOID_STATS is set.