Chapter 22Lesson 05~285 minutes

Checkpoint Lab — CI/CD Integration with GitHub Actions, GitLab CI, and Jenkins

The checkpoint turns the chapter into a small operating contract. You will generate a CI-ready repository fixture, run the exact same command locally and in GitHub Actions, prove a controlled failure remains red while its evidence survives, record versions/capabilities, and keep the matrix free of production targets and paid services.

CheckpointGitHub ActionsEnvironment guardMatrixFailure evidence

Learning objectives

  • Generate a complete disposable CI fixture with pinned Selenium and one portable command.
  • Run the fixture locally in Chrome or Firefox and inspect the evidence packet.
  • Use a GitHub Actions browser matrix without duplicating Selenium test logic.
  • Prove a production-like URL is rejected before any browser mutation.
  • Inject a controlled failure and prove the job/test remains red while evidence is retained.

1. Scenario and preflight

Create a temporary directory named ci-demo. The mandatory path uses Python 3.10+ and Selenium Python 4.47.0. Local execution requires Chrome or Firefox. The GitHub Actions example uses the standard free GitHub-hosted runner path available to public repositories. No paid browser cloud, external account, production endpoint, CI secret, or shared Grid is required.

Target guard is mandatory

The generated test accepts only loopback HTTP hosts. If LAB_BASE_URL points to a public, production-like, or non-loopback host, the test raises before creating navigation traffic. Do not weaken this guard for the course lab.

2. Predict state changes before running

  1. The runner script will create one loopback AUT child process, wait for /health, and later terminate it.
  2. Each browser matrix lane will create a fresh WebDriver session and its own evidence/<browser> namespace.
  3. The environment packet will record Selenium, session ID, actual browser name/version, and platform.
  4. A controlled assertion failure will make the portable command non-zero while the screenshot and environment file remain available for artifact upload.

3. Generate the fixture files

Use the following Python generator inside an empty disposable directory. It writes only the named lab files. Review the generated workflow before committing it anywhere.

from pathlib import Path

FILES = {
    "requirements.txt": "selenium==4.47.0\\n",
    # Paste the chapter's app/server.py, ci/run_suite.py, tests/test_ci_smoke.py,
    # and .github/workflows/selenium.yml blocks into the corresponding values.
}
for name, content in FILES.items():
    path = Path(name)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")

The exact file bodies used by this checkpoint are below. Keeping them separate makes their ownership explicit rather than hiding CI, AUT, and test behavior in one monolithic script.

requirements.txt

The following example makes the Generate the fixture files behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

selenium==4.47.0

app/server.py

The following example makes the Generate the fixture files behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json

HOST = "127.0.0.1"
PORT = 8765

PAGE = b"""<!doctype html><html lang='en'><head><meta charset='utf-8'><title>CI Fixture</title></head>
<body><main><h1 data-testid='status'>ready</h1><p data-testid='message'>CI evidence is portable</p></main></body></html>"""

class Handler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        return
    def do_GET(self):
        if self.path == "/health":
            payload = json.dumps({"ok": True}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return
        if self.path == "/":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(PAGE)))
            self.end_headers()
            self.wfile.write(PAGE)
            return
        self.send_response(404)
        self.end_headers()

if __name__ == "__main__":
    print(f"fixture listening on http://{HOST}:{PORT}", flush=True)
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

ci/run_suite.py

The following example makes the Generate the fixture files behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

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())

tests/test_ci_smoke.py

The following example makes the Generate the fixture files behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

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()

.github/workflows/selenium.yml

The following example makes the Generate the fixture files behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

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

4. Run the same command locally

Create an isolated environment if desired, install the pinned dependency, then run one browser lane. The application server lifecycle is owned by ci/run_suite.py; you do not need a separate terminal.

python -m pip install -r requirements.txt
python ci/run_suite.py --browser chrome
# Or, when Firefox is installed:
python ci/run_suite.py --browser firefox

Expected success: unittest returns exit code zero; evidence/<browser>/environment.json records the session and actual browser version; viewport.png shows the loopback fixture.

5. Prove the production-target guard before browser navigation

Set an obviously non-loopback placeholder. The command must refuse it. This is a deliberate negative test of environment configuration, not a request to contact that host.

LAB_BASE_URL=https://app.example.invalid python ci/run_suite.py --browser chrome
# Expected: RuntimeError refusing non-loopback LAB_BASE_URL

On PowerShell, set the environment variable for the process using your normal PowerShell environment-variable syntax, then run the same Python command. The important contract is the guard behavior, not shell-specific quoting.

6. Inject one failure without hiding it

Set LAB_INJECT_FAILURE=1. The test saves environment/screenshot evidence before the controlled wrong assertion. The command must return non-zero. Locally, verify the evidence directory remains. In GitHub Actions, a matrix lane must be red and its Upload Selenium evidence step must still run because it uses if: always().

LAB_INJECT_FAILURE=1 python ci/run_suite.py --browser chrome
# Expected: non-zero exit; evidence/chrome/environment.json and viewport.png remain

7. Commit only to a disposable authorized repository and inspect the matrix

When the fixture is placed in an authorized public test repository, the workflow creates two free hosted jobs: Chrome and Firefox. Each invokes the same python ci/run_suite.py --browser ... command. The provider matrix selects configuration; it does not duplicate test code. The artifact names include browser, run ID, and run attempt to prevent collisions.

Public-repository free path

GitHub documents standard hosted runners as free and unlimited for public repositories. Private-repository billing/quotas vary by plan; those economics are outside this Selenium course.

8. Required evidence packet and diagnostic conclusion

  • Source identity: commit SHA plus CI run/job identity.
  • Version evidence: Selenium version, runner browser inventory, returned browser name/version/platform, session ID.
  • Result: command exit status and unittest output.
  • Browser evidence: viewport screenshot from each lane.
  • Configuration: browser lane and loopback base URL only; no secret values.
  • Failure conclusion: for the injected failure, state that session creation/navigation succeeded and the controlled application assertion failed; this distinguishes the failure from runner/browser provisioning.

9. Verification checklist

  • Exactly one provider-neutral command runs locally and in GitHub Actions.
  • Selenium is pinned to 4.47.0; actual browser versions are recorded at runtime.
  • Chrome and Firefox matrix lanes use independent browser sessions and artifact names.
  • Non-loopback LAB_BASE_URL is rejected.
  • A failing test produces a non-zero exit status.
  • Failure evidence exists before cleanup and is eligible for always-on artifact upload.
  • No real secret, account, personal profile, production URL, or paid service is required.

10. Cleanup and rollback

Stop only processes created by the runner (it does this in finally), delete the disposable ci-demo directory and its evidence tree when finished, and delete the temporary test repository/workflow if you created one solely for the lab. Do not delete shared CI runners, unrelated caches, browser installations, or organization artifacts.

11. What Chapter 22 adds, and the bridge to Chapter 23

You now have the delivery boundary around Selenium: one portable command, explicit browser/environment inputs, preserved failure status, version/capability evidence, matrix isolation, environment guards, and artifacts that survive red jobs. Chapter 23 moves to another production boundary—authentication flows, proxies, certificates, and enterprise browser environments—where CI secrets and network trust require even stricter separation.

Knowledge check

What proves the checkpoint uses the same test design locally and in CI?

What should happen when LAB_BASE_URL is production-like?

Why does the controlled failure save screenshot/environment evidence before asserting the wrong value?

Why are browser versions recorded instead of hard-coded into the GitHub workflow?

What is the next security boundary after CI integration?

Next chapter

Authentication Flows, Proxies, Certificates, and Enterprise Browser Environments: Core Concepts and Mental Model

Continue with Authentication Flows, Proxies, Certificates, and Enterprise Browser Environments: Core Concepts and Mental Model. 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.