Chapter 25Lesson 02210–270 min

CI/CD Integration with GitHub Actions, GitLab CI, and Jenkins: Guided Hands-On Workflow

Build a disposable CI contract locally, then map the same command to GitHub Actions, GitLab CI/CD, and Jenkins while preserving failure status, evidence, version provenance, and a fake secret boundary.

Local CI simulationGitHub Actions v7GitLab artifactsJenkins post alwaysFailure evidence

Learning objectives

  • Create a small synthetic Robot suite whose pass/fail outcome can be controlled without touching a real system.
  • Implement a cross-platform Python runner that writes a version manifest, invokes Robot, and exits with Robot's real return code.
  • Prove that a deliberately failing run still generates native Robot evidence and xUnit output.
  • Map the same command to current GitHub Actions, GitLab CI/CD, and Jenkins job skeletons.
  • Inject a fake secret without printing or persisting the secret value.
Lab boundary

Everything in this workflow is repository-local and synthetic. The only external actions shown are optional CI-provider wrappers. The mandatory learning path runs on a local Python environment and does not require a hosted runner, production endpoint, paid plan, container runtime, browser, database, SSH host, or real credential.

1. Create the disposable repository layout

Start in an empty training directory or a disposable branch. Keep source, runner logic, and generated evidence separate.

rf25-ci-lab/
├── requirements-ci.txt
├── suites/
│   └── ci_contract.robot
├── tools/
│   └── ci_run.py
└── artifacts/
    └── robot/              # generated, not committed

The suite contains no external integration. The runner owns only the process invocation and evidence directory. CI providers later own checkout/runtime/secret/artifact orchestration.

2. Preflight and pinned dependencies

# requirements-ci.txt
robotframework==7.4.2
python -m venv .venv

# Bash/zsh
source .venv/bin/activate
# Windows PowerShell
# .\.venv\Scripts\Activate.ps1

python -m pip install --upgrade pip
python -m pip install --requirement requirements-ci.txt
python --version
python -m robot --version

For this lab, Python 3.13 is a practical CI example version, but Robot Framework 7.4.2 supports older Python versions too. The key CI property is not “always newest”; it is that the version is explicit and recorded.

3. Create a deterministic synthetic suite

*** Settings ***
Documentation    Synthetic CI contract. No network or production state.

*** Variables ***
${EXPECTED_GATE}    PASS

*** Test Cases ***
Portable Contract Is Healthy
    ${payload}=    Set Variable    build-42
    Should Be Equal    ${payload}    build-42
    Log To Console    portable-check=ok

Release Gate Matches Expected State
    ${actual}=    Set Variable    PASS
    Should Be Equal    ${actual}    ${EXPECTED_GATE}

The first test proves the suite itself can run. The second creates a controlled failure switch: the wrapper can set EXPECTED_GATE=FAIL for a negative-path CI exercise. There is no random input, wall-clock dependency, network call, or shared mutable state.

4. Make one provider-neutral runner

from __future__ import annotations

import os
import platform
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
ARTIFACT_DIR = ROOT / "artifacts" / "robot"
SUITES = ROOT / "suites"


def write_manifest() -> None:
    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    secret_present = bool(os.environ.get("RF_FAKE_TOKEN"))
    lines = [
        f"python={platform.python_version()}",
        f"python_executable={sys.executable}",
        f"platform={platform.platform()}",
        f"cwd={ROOT}",
        f"fake_secret_present={str(secret_present).lower()}",
    ]
    (ARTIFACT_DIR / "runtime-manifest.txt").write_text(
        "\n".join(lines) + "\n", encoding="utf-8"
    )


def main() -> int:
    write_manifest()
    expected = os.environ.get("RF_EXPECTED_GATE", "PASS")
    command = [
        sys.executable, "-m", "robot",
        "--outputdir", str(ARTIFACT_DIR),
        "--output", "output.xml",
        "--log", "log.html",
        "--report", "report.html",
        "--xunit", "xunit.xml",
        "--variable", f"EXPECTED_GATE:{expected}",
        str(SUITES),
    ]
    print("Running provider-neutral Robot contract")
    completed = subprocess.run(command, cwd=ROOT)
    (ARTIFACT_DIR / "exit-code.txt").write_text(
        f"{completed.returncode}\n", encoding="utf-8"
    )
    return completed.returncode


if __name__ == "__main__":
    raise SystemExit(main())

The script deliberately does not print RF_FAKE_TOKEN. It records only whether the variable was present. The Robot command does not receive the token because the synthetic suite does not need it. This is the correct security instinct: inject the least data required by the test contract.

The wrapper exits with the exact Robot return code. It does not use check=False as an excuse to ignore failure; subprocess.run is used so the wrapper can write exit-code.txt and then propagate the code.

5. Run PASS and intentional FAIL locally

# Bash/zsh — PASS
export RF_FAKE_TOKEN='fake-training-token'
unset RF_EXPECTED_GATE
python tools/ci_run.py
printf 'exit=%s\n' "$?"

# Intentional FAIL. This should return 1, not 0.
export RF_EXPECTED_GATE='FAIL'
python tools/ci_run.py
rc=$?
printf 'exit=%s\n' "$rc"
find artifacts/robot -maxdepth 1 -type f -print | sort

# Windows PowerShell equivalent:
# $env:RF_FAKE_TOKEN = 'fake-training-token'
# $env:RF_EXPECTED_GATE = 'FAIL'
# python .	ools\ci_run.py
# $rc = $LASTEXITCODE
# Write-Host "exit=$rc"
# Get-ChildItem .rtifacts
obot

Expected failing-run evidence: exit=1, plus output.xml, log.html, report.html, xunit.xml, runtime-manifest.txt, and exit-code.txt. The negative run is successful as a diagnostic exercise only because the process is correctly non-zero.

6. GitHub Actions mapping — current major actions

The provider job calls the same Python script. The artifact step has if: always() so a Robot failure does not skip evidence upload.

name: robot-ci

on:
  push:
  pull_request:

permissions:
  contents: read

jobs:
  robot:
    runs-on: ubuntu-latest
    env:
      RF_FAKE_TOKEN: ${{ secrets.RF_FAKE_TOKEN }}
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'

      - name: Install pinned dependencies
        run: python -m pip install --requirement requirements-ci.txt

      - name: Run provider-neutral Robot contract
        run: python tools/ci_run.py

      - name: Upload Robot evidence
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: robot-evidence-${{ github.run_id }}
          path: artifacts/robot/
          if-no-files-found: warn

For the lab, create RF_FAKE_TOKEN in the repository's Actions secret store with a clearly fake value. Never use a production credential to prove secret wiring. On self-hosted runners, keep the runner version compatible with the JavaScript runtime required by the selected action major versions.

7. GitLab CI/CD mapping

robot:
  image: python:3.13-slim
  stage: test
  before_script:
    - python -m pip install --requirement requirements-ci.txt
  script:
    - python tools/ci_run.py
  artifacts:
    when: always
    paths:
      - artifacts/robot/
    reports:
      junit: artifacts/robot/xunit.xml

Store a fake lab variable named RF_FAKE_TOKEN in GitLab CI/CD variables, or set it only in a local runner simulation. GitLab's artifacts:when: always keeps ordinary paths on failed jobs, and artifacts:reports:junit makes the xUnit file available to the test-report UI. The job still fails because the script returns Robot's non-zero status.

8. Jenkins mapping

pipeline {
    agent any

    stages {
        stage('Robot') {
            environment {
                RF_FAKE_TOKEN = 'fake-training-token' // simulation only
            }
            steps {
                sh 'python -m pip install --requirement requirements-ci.txt'
                sh 'python tools/ci_run.py'
            }
            post {
                always {
                    archiveArtifacts artifacts: 'artifacts/robot/**',
                                     allowEmptyArchive: true
                    junit testResults: 'artifacts/robot/xunit.xml',
                          allowEmptyResults: true
                }
            }
        }
    }
}

On a Windows Jenkins agent, use the provider's Windows shell step instead of sh. In production, replace the literal fake token with a Jenkins credential binding appropriate to the dedicated Jenkins course. The important Robot integration invariant is the same: non-zero runner status fails the stage; post { always { ... } } preserves evidence.

9. Evidence matrix: before and after

Observation Before run After PASS After intentional FAIL
Process status not started 0 1
runtime-manifest.txt absent present present
output.xml/log/report absent or older run current run current failed run
xunit.xml absent or older run all passing contains failing test
Secret evidence unknown presence flag only presence flag only
CI gate not evaluated open closed while artifacts remain available

10. Challenge: choose the correct layer

Your team asks for smoke tests on pull requests and a larger regression suite nightly. Do not duplicate two CI-specific Robot commands. Decide which repository-owned input should represent the selection, for example, versioned argument files or an execution-profile mechanism from Chapter 15, then let each provider pass only that profile choice to the same runner. Explain which layer owns selection, which owns scheduling, and which owns evidence.

Knowledge check

Why does the wrapper use subprocess.run and then return completed.returncode?

What does if: always() protect in the GitHub Actions example?

Why is RF_FAKE_TOKEN not passed into the Robot suite?

GitLab shows the test report but the artifact browser lacks log.html. What configuration should you inspect?

Summary and bridge

You now have a single cross-platform Robot execution contract and three thin provider mappings. Lesson 3 examines when to deviate from this baseline—caches, containers, parallelism, xUnit surfaces, environment promotion, and provider-specific scripts—and what each deviation costs.

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.

Current primary references

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.