CI/CD Integration with GitHub Actions, GitLab CI, and Jenkins: Guided Hands-On Workflow
This workflow builds one free, local-compatible command first, then runs that same command on GitHub-hosted Ubuntu. GitHub-specific YAML provisions and collects; it does not fork the Selenium test logic. Concise GitLab and Jenkins mappings show how the same boundary transfers without duplicating three full CI courses.
Learning objectives
- Create one command that runs the same Selenium smoke test locally and in CI.
- Use GitHub Actions with current action major versions and a free browser matrix.
- Record actual browser/Selenium versions instead of trusting a floating runner image.
- Upload evidence with an always-run artifact step while preserving the test failure status.
- Map the portable command to GitLab CI and Jenkins without hiding browser/Grid prerequisites.
1. Disposable repository fixture and explicit local command
The lab fixture is loopback-only and contains no accounts or
secrets. The runner script starts the AUT as a child process, waits
for its health endpoint with a bounded poll, invokes
standard-library unittest, preserves the child process
exit code, and stops the AUT in finally. The browser
test itself uses normal WebDriver synchronization and teardown.
ci-demo/
requirements.txt
app/server.py
ci/run_suite.py
tests/test_ci_smoke.py
.github/workflows/selenium.yml
python ci/run_suite.py --browser chrome
python ci/run_suite.py --browser firefox
The shell that invokes the command can differ across Windows, Linux, GitHub Actions, GitLab, or Jenkins. The command’s meaning—configuration in, test/evidence out, real exit status—is stable.
2. The test owns WebDriver; CI does not
The test reads LAB_BROWSER and
LAB_BASE_URL, rejects non-loopback targets, creates a
fresh driver, records returned capabilities, saves a screenshot,
performs assertions, and quits in finally. Chrome uses
current headless mode; Firefox uses its headless argument. Selenium
Manager remains the normal driver-resolution path when no explicit
driver is supplied.
import json
import os
from pathlib import Path
from urllib.parse import urlparse
import selenium
from selenium import webdriver
from selenium.webdriver.common.by import By
BASE_URL = os.environ.get("LAB_BASE_URL", "http://127.0.0.1:8765")
BROWSER = os.environ.get("LAB_BROWSER", "chrome").lower()
EVIDENCE = Path(os.environ.get("LAB_EVIDENCE", "evidence")) / BROWSER
def guard_base_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
raise RuntimeError(f"refusing non-loopback LAB_BASE_URL: {url}")
def new_driver():
if BROWSER == "chrome":
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
return webdriver.Chrome(options=options)
if BROWSER == "firefox":
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
return webdriver.Firefox(options=options)
raise RuntimeError(f"unsupported LAB_BROWSER: {BROWSER}")
def write_environment(driver):
EVIDENCE.mkdir(parents=True, exist_ok=True)
record = {
"selenium": selenium.__version__,
"session_id": driver.session_id,
"browserName": driver.capabilities.get("browserName"),
"browserVersion": driver.capabilities.get("browserVersion"),
"platformName": driver.capabilities.get("platformName"),
}
(EVIDENCE / "environment.json").write_text(json.dumps(record, indent=2), encoding="utf-8")
def test_ci_smoke():
guard_base_url(BASE_URL)
driver = new_driver()
try:
driver.get(BASE_URL + "/")
write_environment(driver)
driver.save_screenshot(str(EVIDENCE / "viewport.png"))
status = driver.find_element(By.CSS_SELECTOR, "[data-testid='status']").text
message = driver.find_element(By.CSS_SELECTOR, "[data-testid='message']").text
if os.environ.get("LAB_INJECT_FAILURE") == "1":
assert message == "INTENTIONALLY WRONG", "controlled failure for artifact demonstration"
assert status == "ready"
assert message == "CI evidence is portable"
finally:
driver.quit()
3. Provider-neutral runner preserves the test exit code
The runner is deliberately small. Its bounded health poll belongs to
fixture-process startup, not browser synchronization. Most
importantly, subprocess.run(...).returncode is returned
unchanged, so a failing suite cannot become a green job by accident.
from pathlib import Path
from urllib.request import urlopen
import argparse
import os
import subprocess
import sys
import time
def wait_health(url: str, timeout_s: float = 8.0) -> None:
deadline = time.monotonic() + timeout_s
last = None
while time.monotonic() < deadline:
try:
with urlopen(url, timeout=1) as response:
if response.status == 200:
return
except Exception as exc:
last = exc
time.sleep(0.1) # bounded fixture-process health polling, not Selenium synchronization
raise RuntimeError(f"fixture did not become healthy: {last}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--browser", choices=["chrome", "firefox"], default=os.environ.get("LAB_BROWSER", "chrome"))
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
env = os.environ.copy()
env["LAB_BROWSER"] = args.browser
env.setdefault("LAB_BASE_URL", "http://127.0.0.1:8765")
env.setdefault("LAB_EVIDENCE", str(root / "evidence"))
server = subprocess.Popen([sys.executable, str(root / "app" / "server.py")], cwd=root)
try:
wait_health(env["LAB_BASE_URL"] + "/health")
command = [sys.executable, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py", "-v"]
print("provider-neutral test command:", " ".join(command), flush=True)
completed = subprocess.run(command, cwd=root, env=env)
return completed.returncode
finally:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
if __name__ == "__main__":
raise SystemExit(main())
4. Complete GitHub Actions path: current actions, explicit matrix, always-on evidence
As of this chapter’s August 2026 verification, current GitHub action
documentation uses the v7 major lines for checkout, Python setup,
and artifact upload. The job uses ubuntu-24.04.
GitHub’s current Ubuntu 24.04 image inventory includes
Chrome/ChromeDriver, Edge/WebDriver, Firefox/Geckodriver, and
Selenium Server, but those package versions move with the image. The
workflow therefore prints browser versions and the test writes
returned capabilities.
name: selenium-ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
browser-smoke:
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
browser: [chrome, firefox]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
cache: pip
- name: Install pinned Python dependencies
run: python -m pip install -r requirements.txt
- name: Record runner browser inventory
run: |
google-chrome --version || true
firefox --version || true
python -c "import selenium; print('selenium', selenium.__version__)"
- name: Run portable Selenium command
run: python ci/run_suite.py --browser "${{ matrix.browser }}"
- name: Upload Selenium evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: selenium-${{ matrix.browser }}-${{ github.run_id }}-${{ github.run_attempt }}
path: evidence/
if-no-files-found: warn
retention-days: 7
The browser matrix collects both lane outcomes and their evidence even if one browser fails. This does not turn a failed lane green; it only prevents the matrix scheduler from cancelling sibling lanes before evidence is collected.
5. What each GitHub step reads or changes
The following table organizes the key choices and evidence for What each GitHub step reads or changes. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.
| Step | Reads | Changes/creates | Verification |
|---|---|---|---|
| checkout | Commit/ref and repository token with repository-scoped permissions | Workspace source tree | Record commit SHA/run ID |
| setup-python | Requested Python version; cache metadata | Toolchain/PATH and optional pip cache | python --version |
| pip install | Pinned requirements | Virtual/job Python environment | selenium.__version__ |
| run_suite | Browser request, loopback URL, source | AUT child process, WebDriver session, evidence files | Exit code + environment.json + screenshot |
| upload-artifact | Evidence directory | CI artifact object with retention | Artifact name includes browser/run/attempt |
6. Concise GitLab CI and Jenkins mappings
The GitLab pattern below deliberately targets an authorized
browser-linux runner tag whose browser inventory your
team controls. If instead you use a Selenium service container, the
browser container must reach the AUT by a service/network
address—127.0.0.1 inside one container is not another
container. GitLab artifacts: when: always preserves
evidence after failures.
selenium-smoke:
stage: test
tags: [browser-linux] # authorized runner whose browser inventory you control
parallel:
matrix:
- LAB_BROWSER: [chrome, firefox]
script:
- python3 -m pip install -r requirements.txt
- python3 ci/run_suite.py --browser "$LAB_BROWSER"
artifacts:
when: always
expire_in: 7 days
paths:
- evidence/
Jenkins uses the same command on an agent labeled for browser
execution. Declarative Pipeline
post { always { ... } } is the artifact-preservation
boundary. The shell step naturally fails the stage on a non-zero
test exit unless you explicitly defeat that behavior.
pipeline {
agent { label 'browser-linux' }
parameters {
choice(name: 'LAB_BROWSER', choices: ['chrome', 'firefox'], description: 'Browser lane')
}
stages {
stage('Selenium smoke') {
steps {
sh 'python3 -m pip install -r requirements.txt'
sh 'python3 ci/run_suite.py --browser "$LAB_BROWSER"'
}
}
}
post {
always {
archiveArtifacts artifacts: 'evidence/**', allowEmptyArchive: true
}
}
}
7. Challenge: choose the control, do not copy the sequence
Your Chrome lane fails before session creation because
google-chrome is missing, while Firefox passes. Should
you add a Selenium explicit wait, increase Grid sessions, retry the
job, or repair runner provisioning? The correct control is
runner/browser provisioning. A WebDriver wait cannot create a
missing browser binary, and a retry only repeats the same
environment defect.
8. Verification and cleanup
-
Local run: confirm
evidence/chrome/environment.jsoncontains Selenium, session ID, browser name/version, and platform. -
Controlled failure: set
LAB_INJECT_FAILURE=1; the command must return non-zero while evidence still exists. - GitHub Actions: confirm each matrix lane has a uniquely named artifact.
-
Delete only the generated
ci-demodirectory/evidence created for the lab. No profiles, accounts, external services, or secrets are used.
Knowledge check
Why does the workflow print browser versions even though the runner image documents them?
Runner images update. Runtime evidence proves what the specific job actually used.
Does fail-fast: false make a failed matrix lane
nonblocking?
No. It prevents cancellation of sibling jobs; the failed lane still fails.
Where should LAB_BROWSER be interpreted?
At the portable test/configuration boundary, not by duplicating different Selenium test bodies in each CI provider.
Why is 127.0.0.1 dangerous when the browser is in a
service container?
Loopback refers to the container itself. The browser needs a network-reachable AUT service/host address.
What makes the GitLab/Jenkins snippets equivalent to GitHub Actions?
They invoke the same provider-neutral command, preserve its exit status, provide browser/config inputs, and retain evidence.
Official references and current-version notes
- Selenium 4.47 release notes
- Selenium downloads — current stable client and Grid versions
- GitHub-hosted runners reference
- GitHub Ubuntu 24.04 runner image inventory
- actions/checkout
- actions/setup-python
- actions/upload-artifact
- GitLab CI job artifacts
- GitLab CI/CD YAML reference
- Jenkins Pipeline tests and artifacts
- Jenkins Declarative Pipeline syntax
The mandatory examples pin Selenium Python to 4.47.0.
The complete GitHub Actions example uses current major action
lines actions/checkout@v7,
actions/setup-python@v7, and
actions/upload-artifact@v7. Hosted runner browser
packages are deliberately not pinned here because runner
images update; every run records the actual browser and returned
WebDriver capabilities.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.