Selenium Test Suites on GitHub Actions
Pin the browser and its driver as one version pair, cache what Selenium downloads, then run parallel sessions per shard across a warp- runner matrix.
Last verified:
To run Selenium tests on GitHub Actions, pin the browser and its driver to one version pair that lives in your repository, then fan the suite out as parallel sessions inside each job and shards across a matrix of jobs. The two failures that make Selenium suites slow and flaky on hosted runners are a driver resolved fresh on every ephemeral machine and a session count set higher than the machine's memory supports.
This page covers the version pinning problem and the cache that keeps the pair aligned, a workflow with a session grid per shard and a shard matrix, a sessions-per-machine table with the Linux x64 rates applied, and the arithmetic behind the runner size the table recommends.
Overview
Selenium 4 resolves drivers through Selenium Manager, which ships inside the client bindings. On a machine with no driver on PATH, it detects the installed browser, downloads the matching driver, and stores it under ~/.cache/selenium, documented in the Selenium Manager reference. The cache location moves with SE_CACHE_PATH.
That resolution step is convenient on a laptop and expensive on GitHub Actions. Every job runs on a fresh machine, so the folder starts empty, and every shard in a matrix repeats the same download before the first test executes.
The alignment problem sits underneath the download. The browser on a hosted runner comes from the runner image, and image releases move browser versions on their own schedule. Ubuntu images ship Chrome, chromedriver, Firefox, and geckodriver preinstalled, listed in the Ubuntu 24.04 image manifest, and WarpBuild Linux x64 images carry the same tooling as GitHub-hosted runners, described under preinstalled software. So a browser major version bump reaches your pipeline through an image release rather than through a commit, and the first symptom is SessionNotCreatedException with a message stating that the driver supports one Chrome version while the machine has another.
There are two honest fixes and they exclude each other:
Pin the pair in the repository. Keep a version string in a file, install the browser and the driver from that one string, and cache the result. The version now moves when someone edits that file.
Pin a container image tag. Run the browser inside a Selenium container. The docker-selenium images package the server, the browser, and the driver as one release, so the tag pins all three, and the runner image stops deciding what your tests run against.
Selenium work belongs on the Linux x64 sizes, which run from 2 to 32 vCPU at 4GB of memory per core, listed under cloud runners. Moving an existing job across is a one-line change to runs-on.
One boundary before the configuration. This page covers Selenium: driver and browser version alignment, sessions against a WebDriver endpoint, and the memory each session holds. Browser automation stacks that manage their own browser downloads and their own worker pool behave differently, and that configuration is on the Playwright test suites on GitHub Actions page.
Configuration
Start with the pin. A one-line file, .chrome-version, holds the full version string, and both installs read it, so the browser and the driver can never drift apart:
- name: Restore the driver and browser cache
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/selenium
key: selenium-${{ runner.os }}-${{ hashFiles('.chrome-version') }}
- name: Install the pinned browser and driver
run: |
version=$(cat .chrome-version)
npx @puppeteer/browsers install "chrome@$version" --path "$HOME/.cache/selenium"
npx @puppeteer/browsers install "chromedriver@$version" --path "$HOME/.cache/selenium"That cache entry carries no restore-keys ladder on purpose. A partial hit here restores a browser build for a version the job is about to replace, which costs storage and saves nothing, so an exact key and a clean miss is the cheaper miss.
The container route removes both the download and the pin file. This workflow runs a Selenium node as a service container per shard, sets the session ceiling on that node, and splits the suite four ways:
name: selenium
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
runs-on: warp-ubuntu-latest-x64-16x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
services:
selenium:
image: selenium/standalone-chromium:${{ vars.SELENIUM_IMAGE_TAG }}
ports:
- 4444:4444
env:
SE_NODE_MAX_SESSIONS: 12
SE_SESSION_REQUEST_TIMEOUT: 600
options: --shm-size=2g
env:
SELENIUM_REMOTE_URL: http://localhost:4444
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Restore the pip cache
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- run: pip install -r requirements.txt
- name: Wait for the node to register
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:4444/status | grep -q '"ready": *true' && exit 0
sleep 2
done
echo "Selenium node did not become ready" >&2
exit 1
- name: Run shard ${{ matrix.shard }}
run: |
pytest tests/e2e \
-n 12 --dist loadfile \
--splits 4 --group ${{ matrix.shard }} \
--junitxml=results-${{ matrix.shard }}.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.shard }}
path: results-${{ matrix.shard }}.xmlFour details in that file carry the weight.
The image tag comes from vars.SELENIUM_IMAGE_TAG, a repository variable. The env context is not available in a services block, and vars is, so a repository variable is how one pinned tag reaches every workflow that needs it. See the contexts reference for what each block can read, and the service containers guide for the lifecycle.
SELENIUM_REMOTE_URL is what redirects the client. Selenium bindings read it and build a remote session against that address, so the same test code that constructs a local driver on a laptop talks to the node in the service container with no branch in the test suite.
--shm-size=2g raises shared memory for the container. Docker defaults /dev/shm to 64MB, Chromium shares renderer memory through it, and the failure at the default size is a page crash that reads like a flaky test.
SE_NODE_MAX_SESSIONS: 12 is the ceiling, and -n 12 is the demand. Keep the two equal. A node's default ceiling is derived from the CPUs it sees, and raising it past that number needs SE_NODE_OVERRIDE_MAX_SESSIONS, which is a flag worth leaving alone. SE_SESSION_REQUEST_TIMEOUT is how long a queued request waits for a free slot before failing, so raise it while you tune the count rather than reading a queue timeout as a broken test.
Cache behavior from the caching documentation shapes the pip entry above. Entries are scoped to key, version, and branch and expire 7 days after last use, so seed on main and let branch builds fall back through restore-keys. Cache is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation.
Sizing
A Selenium session is a browser process tree, not a thread. Chromium holds a browser process, a renderer per page, its own utility and network processes, and the shared memory those renderers pass frames through. Budget 2 GB per concurrent session and keep a 4 GB reserve for the runner agent, the test process, and anything the suite starts locally.
That gives a memory ceiling of (RAM_GB - 4) / 2, capped at 0.75 sessions per vCPU, since a session that gets less than one core spends its time waiting on the browser rather than on the application:
| Runner label | vCPU | Memory | Sessions that fit | Per minute | Per session-minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | 1 | $0.004 | $0.0040 |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 3 | $0.008 | $0.0027 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 6 | $0.016 | $0.0027 |
| warp-ubuntu-latest-x64-16x | 16 | 64GB | 12 | $0.032 | $0.0027 |
| warp-ubuntu-latest-x64-32x | 32 | 128GB | 24 | $0.064 | $0.0027 |
Rates come from the cloud runners documentation. The right-hand column is the reason the workflow above sits on 16x rather than on four 4x machines: every Linux x64 size bills $0.002 per vCPU-minute, so the cost per concurrent session is flat from 4x upward, and only the 2x size prices worse because the 4 GB reserve eats half of its memory. Above 4x, larger machines are free in per-session terms and cheaper in wall clock, because each job pays checkout, install, and node startup once instead of once per machine.
The cap in that table applies per machine. Raising the shard count is the lever that adds sessions beyond it, and the mechanics of splitting a suite that way are covered in the guide to sharding test suites across a matrix. The per-session memory figure is worth measuring once for your own application, since a page holding large canvases or long DOM histories costs more than 2 GB; the method is in how to run browser tests headless in GitHub Actions.
Worked cost model
Take 900 Selenium tests averaging 30 seconds of wall time each: 450 minutes of session time per run. Four shards on warp-ubuntu-latest-x64-16x at 12 sessions each hold 48 sessions, so the session work lands near 9.4 minutes per shard, and setup adds about 2 minutes. Call it 12 minutes per shard at 500 workflow runs a month.
| Line item | Rate | Minutes per month | Monthly cost |
|---|---|---|---|
| 4 shards, 12 min each, 16 vCPU | $0.032/min | 24,000 | $768.00 |
| Cache storage and operations (1GB, 4,000 ops) | $0.20/GB-month plus $0.0001/op | n/a | $0.60 |
| WarpBuild total | 24,000 | $768.60 | |
| Same minutes on the 16-core Linux larger runner | $0.042/min | 24,000 | $1,008.00 |
The comparison behind that last row, stated as list-price arithmetic: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB), which is 24 percent lower list price (GitHub Actions billing reference, checked on 2026-08-13).
Every size and platform rate sits on the pricing page.
Bottlenecks
Driver resolution on every shard. Selenium Manager downloads a driver per cold machine, and a four-shard matrix is four cold machines per run. The tell is a gap between the start of the test command and the first test result, identical on every shard. The pin plus cache above removes it, and the container route removes it by never resolving a driver at all.
Version drift arriving through the image. A workflow that passed yesterday fails today at session creation with a driver and browser mismatch, and the diff for the day is empty. Nothing in the repository changed because the change was upstream. Both fixes in the overview stop it; what does not stop it is deleting caches, which is the usual first reaction and moves the failure by one run at most.
Shared memory in containers. Chromium renderers crash when /dev/shm is at the 64MB Docker default, and the crash surfaces as a lost session part way through a spec, moving between tests from run to run. Set --shm-size=2g on the service container. Where a container is not involved, the runner's own shared memory scales with machine memory, which is another reason session counts and machine size stay tied.
Sessions queued past the node ceiling. Asking for 16 parallel workers against a node capped at 12 does not fail immediately. Requests queue, wait for the request timeout, then fail in a batch that looks like an application timeout. Read the node status endpoint during a run, or set the worker count and SE_NODE_MAX_SESSIONS from one variable so they cannot disagree.
When the responsible layer is unclear, WarpBuild CI observability correlates runner system metrics with GitHub Actions job logs, which separates a memory-bound browser from a test genuinely waiting on the application. The Action Debugger pauses the workflow and opens an SSH session on the runner, so you can curl the node status endpoint and read ~/.cache/selenium on the machine itself.
Proof
Every cost number on this page is list-price arithmetic against published rates, with the source linked and the date stated, so you can re-run it against your own minute counts and session counts.
Public repositories running warp- labels are citable evidence, and checking one takes as long as reading a runs-on line. The Trigger.dev end-to-end matrix runs on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, which you can read in triggerdotdev/trigger.dev's e2e.yml (checked on 2026-08-13).
FAQ
Why does chromedriver stop matching Chrome on GitHub Actions with no change to my repository?
The runner image supplies both, and image releases move them on their own schedule. An ephemeral machine takes whatever the current image ships, so a browser major version bump arrives between two runs of an unchanged workflow and the session fails at creation. Pin a version string in the repository and install the browser and the driver from that one string, or pin a container image tag that carries both.
How many Selenium sessions fit on one GitHub Actions runner?
Subtract a 4 GB reserve from runner memory, divide by 2 GB per Chromium session, then cap the result at 0.75 sessions per vCPU. On warp-ubuntu-latest-x64-16x with 16 vCPU and 64 GB that lands at 12 sessions, and on warp-ubuntu-latest-x64-8x with 8 vCPU and 32 GB it lands at 6.
Do I need a Selenium Grid to run tests in parallel on GitHub Actions?
No. A local driver with a parallel test runner works when every session runs the same browser. A Grid earns its place when you want the browser pinned by an image tag rather than by the runner image, when sessions need different browsers, or when you want one place to set the session ceiling per machine.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.