SauceLabs CI/CD Integration: GitHub Actions, Jenkins, and CircleCI

SauceLabs CI/CD Integration: GitHub Actions, Jenkins, and CircleCI

SauceLabs fits into any CI/CD pipeline as a remote WebDriver endpoint. The integration pattern is the same across GitHub Actions, Jenkins, and CircleCI: store credentials as secrets, optionally start a Sauce Connect tunnel for internal URLs, run your test suite pointing at SauceLabs, then collect results. This post shows concrete configs for each CI platform.

The Integration Pattern

Every SauceLabs CI integration follows this structure:

  1. Store SAUCE_USERNAME and SAUCE_ACCESS_KEY as secrets/environment variables
  2. Optionally start Sauce Connect Tunnel (for internal URLs)
  3. Run tests pointing at SauceLabs endpoint
  4. Collect test results and artifacts (video, screenshots)
  5. Stop the tunnel

The SauceLabs endpoint is always:

https://SAUCE_USERNAME:SAUCE_ACCESS_KEY@ondemand.us-west-1.saucelabs.com/wd/hub

GitHub Actions

Basic Setup (No Tunnel)

# .github/workflows/cross-browser.yml
name: Cross-Browser Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chrome, firefox, safari]
      fail-fast: false  # Don't cancel other browsers if one fails

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run ${{ matrix.browser }} tests
        env:
          SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
          SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
          BROWSER: ${{ matrix.browser }}
          BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
          BUILD_NAME: "PR-${{ github.event.pull_request.number || 'main' }}"
        run: |
          pytest tests/e2e/ \
            --browser=$BROWSER \
            --tb=short \
            --junit-xml=results-${{ matrix.browser }}.xml

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.browser }}
          path: results-*.xml

With Sauce Connect Tunnel

name: Integration Tests with Sauce Connect

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Start Sauce Connect Tunnel
        uses: saucelabs/sauce-connect-action@v2
        with:
          username: ${{ secrets.SAUCE_USERNAME }}
          accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
          tunnelName: "github-${{ github.run_id }}"
          # Optional: configure which domains go through tunnel
          # tunnelDomains: "localhost,staging.internal.com"

      - name: Start local server
        run: |
          npm run build
          npm run start &
          # Wait for server to be ready
          timeout 30 bash -c 'until curl -sf http://localhost:3000/; do sleep 1; done'

      - name: Run tests
        env:
          SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
          SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
          SAUCE_TUNNEL_NAME: "github-${{ github.run_id }}"
          APP_URL: "http://localhost:3000"
          BUILD_ID: ${{ github.run_id }}
        run: pytest tests/ -n 4 --tb=short

Parallel Jobs with Matrix

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - browser: chrome
            platform: "Windows 11"
            browser-version: latest
          - browser: firefox
            platform: "Windows 11"
            browser-version: latest
          - browser: safari
            platform: "macOS 14"
            browser-version: latest
          - browser: MicrosoftEdge
            platform: "Windows 11"
            browser-version: latest
      fail-fast: false

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt

      - name: Run tests on ${{ matrix.browser }}
        env:
          SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
          SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
          BROWSER: ${{ matrix.browser }}
          BROWSER_VERSION: ${{ matrix.browser-version }}
          PLATFORM: ${{ matrix.platform }}
          BUILD_ID: ${{ github.run_id }}
        run: pytest tests/ --tb=short --junit-xml=results.xml

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: results-${{ matrix.browser }}
          path: results.xml

Jenkins

Jenkinsfile (Declarative Pipeline)

pipeline {
    agent any

    environment {
        SAUCE_USERNAME = credentials('sauce-username')
        SAUCE_ACCESS_KEY = credentials('sauce-access-key')
        BUILD_ID = "${env.BUILD_NUMBER}"
    }

    stages {
        stage('Install') {
            steps {
                sh 'pip install -r requirements.txt'
            }
        }

        stage('Start Sauce Connect') {
            steps {
                script {
                    // Download Sauce Connect if not present
                    sh '''
                        if [ ! -f ./sc ]; then
                            curl -L https://saucelabs.com/downloads/sauce-connect/5.1.3/sauce-connect-5.1.3_linux.x86_64.tar.gz | tar xz
                        fi
                    '''
                    // Start tunnel in background
                    sh "./sc run --username $SAUCE_USERNAME --access-key $SAUCE_ACCESS_KEY --tunnel-name jenkins-${BUILD_NUMBER} --daemon"
                    // Wait for tunnel ready
                    sh "timeout 60 bash -c 'until ./sc status --tunnel-name jenkins-${BUILD_NUMBER}; do sleep 2; done'"
                }
            }
        }

        stage('Test') {
            parallel {
                stage('Chrome') {
                    environment { BROWSER = 'chrome' }
                    steps {
                        sh "pytest tests/ --browser=chrome --junit-xml=results-chrome.xml"
                    }
                    post {
                        always {
                            junit 'results-chrome.xml'
                        }
                    }
                }
                stage('Firefox') {
                    environment { BROWSER = 'firefox' }
                    steps {
                        sh "pytest tests/ --browser=firefox --junit-xml=results-firefox.xml"
                    }
                    post {
                        always {
                            junit 'results-firefox.xml'
                        }
                    }
                }
            }
        }
    }

    post {
        always {
            sh "./sc stop --tunnel-name jenkins-${BUILD_NUMBER} || true"
        }
        failure {
            // Collect SauceLabs job IDs from test output
            sh '''
                JOB_IDS=$(grep -o 'SauceJobID=[a-z0-9-]*' test.log | cut -d= -f2)
                for id in $JOB_IDS; do
                    curl -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
                        "https://api.us-west-1.saucelabs.com/rest/v1/$SAUCE_USERNAME/jobs/$id/assets/video.mp4" \
                        --output "video-$id.mp4"
                done
            '''
            archiveArtifacts artifacts: 'video-*.mp4', allowEmptyArchive: true
        }
    }
}

CircleCI

# .circleci/config.yml
version: 2.1

orbs:
  browser-tools: circleci/browser-tools@1.4

executors:
  python:
    docker:
      - image: cimg/python:3.12
    environment:
      SAUCE_USERNAME: $SAUCE_USERNAME
      SAUCE_ACCESS_KEY: $SAUCE_ACCESS_KEY

commands:
  setup-sauce-connect:
    steps:
      - run:
          name: Install Sauce Connect
          command: |
            curl -L https://saucelabs.com/downloads/sauce-connect/5.1.3/sauce-connect-5.1.3_linux.x86_64.tar.gz | tar xz -C /usr/local/bin/
      - run:
          name: Start Sauce Connect
          command: sc run --username $SAUCE_USERNAME --access-key $SAUCE_ACCESS_KEY --tunnel-name "circleci-$CIRCLE_BUILD_NUM" --daemon
          background: true
      - run:
          name: Wait for tunnel
          command: timeout 60 bash -c 'until sc status --tunnel-name circleci-$CIRCLE_BUILD_NUM 2>/dev/null; do sleep 2; done'

jobs:
  cross-browser-test:
    executor: python
    parallelism: 4  # 4 parallel containers

    steps:
      - checkout
      - restore_cache:
          keys:
            - deps-{{ checksum "requirements.txt" }}
      - run: pip install -r requirements.txt
      - save_cache:
          key: deps-{{ checksum "requirements.txt" }}
          paths: [~/.cache/pip]

      - setup-sauce-connect

      - run:
          name: Run tests (split across containers)
          environment:
            BUILD_ID: $CIRCLE_BUILD_NUM
            SAUCE_TUNNEL_NAME: circleci-$CIRCLE_BUILD_NUM
          command: |
            # CircleCI test splitting distributes tests across containers
            TEST_FILES=$(circleci tests glob "tests/**/*.py" | circleci tests split --split-by=timings)
            pytest $TEST_FILES --junit-xml=test-results/results.xml

      - store_test_results:
          path: test-results

      - store_artifacts:
          path: test-results
          destination: test-results

workflows:
  cross-browser:
    jobs:
      - cross-browser-test

Test Reporting

Linking SauceLabs Jobs to CI Builds

Pass the CI build identifier into SauceLabs metadata:

import os

def get_sauce_options(test_name: str) -> dict:
    return {
        "username": os.environ["SAUCE_USERNAME"],
        "accessKey": os.environ["SAUCE_ACCESS_KEY"],
        "name": test_name,
        "build": os.environ.get("BUILD_ID", "local"),
        # CI-specific metadata
        "customData": {
            "ci": os.environ.get("CI", "false"),
            "branch": os.environ.get("GITHUB_REF_NAME", "local"),
            "commit": os.environ.get("GITHUB_SHA", "unknown"),
            "pr": os.environ.get("GITHUB_EVENT_NUMBER", ""),
        }
    }

Downloading Failure Artifacts

When a test fails, retrieve video and screenshots:

# Get failed job IDs from last build
FAILED_JOBS=$(curl -s -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
  "https://api.us-west-1.saucelabs.com/rest/v1/$SAUCE_USERNAME/jobs?limit=50&full=true" \
  | jq -r '.[] | select(.passed == false) | .id')

for JOB_ID in $FAILED_JOBS; do
  # Download video
  curl -s -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
    "https://api.us-west-1.saucelabs.com/rest/v1/$SAUCE_USERNAME/jobs/$JOB_ID/assets/video.mp4" \
    --output "artifacts/video-${JOB_ID}.mp4"

  # Download final screenshot
  curl -s -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
    "https://api.us-west-1.saucelabs.com/rest/v1/$SAUCE_USERNAME/jobs/$JOB_ID/assets/final_screenshot.png" \
    --output "artifacts/screenshot-${JOB_ID}.png"
done

Concurrency Management

Avoid hitting concurrency limits by controlling the number of parallel sessions:

# GitHub Actions: limit matrix parallelism
jobs:
  test:
    strategy:
      matrix:
        browser: [chrome, firefox, safari, edge]
      max-parallel: 3  # Don't exceed SauceLabs concurrency limit

For pytest-xdist, pass the concurrency limit:

pytest tests/ -n 5  # Match your SauceLabs plan limit

Sauce Connect in Docker

If your CI runs in Docker:

FROM python:3.12-slim

RUN curl -L \
  https://saucelabs.com/downloads/sauce-connect/5.1.3/sauce-connect-5.1.3_linux.x86_64.tar.gz \
  | tar xz -C /usr/local/bin/ --strip-components=1

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

CMD ["sh", "-c", \
  "sc run --username $SAUCE_USERNAME --access-key $SAUCE_ACCESS_KEY --tunnel-name docker-$BUILD_ID --daemon && \
   pytest tests/ && \
   sc stop --tunnel-name docker-$BUILD_ID"]

Read more

Start now free