Chapter 22Lesson 02~255 minutes

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.

GitHub ActionsMatrixArtifactsGitLab CIJenkins

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
One command is the contract

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
Why <code>fail-fast: false</code>?

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.json contains 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-demo directory/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?

Does fail-fast: false make a failed matrix lane nonblocking?

Where should LAB_BROWSER be interpreted?

Why is 127.0.0.1 dangerous when the browser is in a service container?

What makes the GitLab/Jenkins snippets equivalent to GitHub Actions?

Next lesson

CI/CD Integration with GitHub Actions, GitLab CI, and Jenkins: Configuration, Design Patterns, and Trade-Offs

Continue with CI/CD Integration with GitHub Actions, GitLab CI, and Jenkins: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Official references and current-version notes

Version baseline — August 2026

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.