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.
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.
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
-
The runner script will create one loopback AUT child process, wait
for
/health, and later terminate it. -
Each browser matrix lane will create a fresh WebDriver session and
its own
evidence/<browser>namespace. - The environment packet will record Selenium, session ID, actual browser name/version, and platform.
- 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.
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_URLis 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?
Both paths invoke the same
python ci/run_suite.py --browser ... command; the
CI provider only supplies configuration and orchestration.
What should happen when LAB_BASE_URL is
production-like?
The guard must fail before browser navigation rather than attempting the target.
Why does the controlled failure save screenshot/environment evidence before asserting the wrong value?
It preserves first-failure context while still allowing the assertion to return a real non-zero test result.
Why are browser versions recorded instead of hard-coded into the GitHub workflow?
Hosted runner browser packages evolve. The run must identify the actual browser used while Selenium itself remains pinned.
What is the next security boundary after CI integration?
Authentication, proxy, TLS/certificate, identity, and enterprise browser/network configuration, which Chapter 23 covers.
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.