Chapter 19Lesson 02~175 minutes

CI Integration with GitHub Actions, GitLab CI, Jenkins, and Other Runners: Guided Hands-On Workflow

Build a portable local analysis launcher, generate coverage before scanning, then translate the same evidence contract into a pinned GitHub Actions workflow with explicit Quality Gate enforcement.

CI/CDGitHub ActionsGitLab CIJenkinsQuality Gate

Learning objectives

  • Create a small tested Python project whose coverage report is generated before analysis.
  • Implement a provider-neutral local launcher that records runner, revision, tests, coverage, scanner, ceTaskId, Compute Engine, and gate evidence.
  • Run the launcher once in asynchronous mode and once with explicit Quality Gate waiting.
  • Translate the same evidence ordering into a GitHub Actions workflow using reviewed/pinned action revisions.
  • Retain first-failure logs and reports with an always() evidence-upload step while keeping secrets redacted.
  • Explain how the same contract maps to GitLab and Jenkins without duplicating those platforms' full courses.

1. Disposable lab contract

Local/free mandatory path. Use a disposable SonarQube Community Build instance and synthetic repository. The GitHub Actions YAML is executable when the repository/runner can reach your SonarQube instance, but no paid GitHub plan, provider sandbox, commercial SonarQube license, or production repository is required for chapter completion.
Item Lab assumption
SonarQube Community Build 26.9.0.129388 at http://localhost:9000
Scanner SonarScanner CLI 8.1.0.6389; modern distribution uses Java 21 embedded/provisioned runtime semantics
Project sq-ch19-ci-lab, main branch only for Community Build
Python test stack Python 3.13 in GitHub example; pytest==9.1.1, pytest-cov==7.1.0
GitHub scan action v8.2.1, pinned in the teaching YAML to commit 22918119ff8e1ca75a623e15c8296b6ea4fbe28f
GitHub gate action v1.2.0, pinned to cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b
Credentials Disposable SonarQube project-analysis token; GitHub example reads secrets.SONAR_TOKEN

2. Create the tested fixture and pin the test producer

mkdir -p sq-ch19-ci-lab/src sq-ch19-ci-lab/tests sq-ch19-ci-lab/ci
cd sq-ch19-ci-lab
git init -b main
git config user.email "learner@example.invalid"
git config user.name "SonarQube Learner"

cat > src/calculator.py <<'PYCODE'
def classify(value: int) -> str:
    if value < 0:
        return "negative"
    if value == 0:
        return "zero"
    return "positive"
PYCODE

cat > tests/test_calculator.py <<'PYCODE'
from src.calculator import classify

def test_classify():
    assert classify(-1) == "negative"
    assert classify(0) == "zero"
    assert classify(2) == "positive"
PYCODE

cat > requirements-ci.txt <<'EOF'
pytest==9.1.1
pytest-cov==7.1.0
EOF

cat > sonar-project.properties <<'EOF'
sonar.projectKey=sq-ch19-ci-lab
sonar.projectName=SQ Chapter 19 CI Lab
sonar.sources=src
sonar.tests=tests
sonar.python.coverage.reportPaths=coverage.xml
sonar.sourceEncoding=UTF-8
EOF

cat > .gitignore <<'EOF'
.venv/
__pycache__/
.pytest_cache/
.coverage
coverage.xml
.scannerwork/
evidence/
.sonar/
EOF

git add .
git commit -m "chapter19 CI fixture"
git rev-parse HEAD

Create the disposable project in SonarQube first, then create a project-analysis token. Export it only in the current shell. The token is not part of the fixture.

3. Provider-neutral launcher: preserve evidence around each boundary

The launcher below deliberately separates test/report production from scanner execution and preserves evidence even when the scanner fails. It does not echo the token and it refuses to run when the token/host URL are missing.

cat > ci/run-analysis.sh <<'BASH'
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

: "${SONAR_HOST_URL:?SONAR_HOST_URL is required}"
: "${SONAR_TOKEN:?SONAR_TOKEN is required}"
: "${SONAR_PROJECT_KEY:=sq-ch19-ci-lab}"
: "${SONAR_GATE_WAIT:=false}"

mkdir -p evidence
{
  date -u +'%Y-%m-%dT%H:%M:%SZ'
  printf 'project=%s\n' "$SONAR_PROJECT_KEY"
  printf 'revision='; git rev-parse HEAD
  printf 'shallow='; git rev-parse --is-shallow-repository
  python --version
  sonar-scanner --version
  printf 'SONAR_TOKEN=set\n'
  printf 'SONAR_HOST_URL=%s\n' "$SONAR_HOST_URL"
} | tee evidence/preflight.txt

# 1) Build/test/report evidence comes first.
set +e
python -m pytest -q \
  --junitxml=evidence/junit.xml \
  --cov=src --cov-report=term \
  --cov-report=xml:coverage.xml \
  2>&1 | tee evidence/test.log
TEST_RC=${PIPESTATUS[0]}
set -e
if [ "$TEST_RC" -ne 0 ]; then
  printf 'test_exit=%s\n' "$TEST_RC" | tee evidence/final-state.txt
  exit "$TEST_RC"
fi

test -s coverage.xml
cp coverage.xml evidence/coverage.xml

# 2) Scanner. Environment variables carry authentication.
scan_args=()
if [ "$SONAR_GATE_WAIT" = "true" ]; then
  scan_args+=("-Dsonar.qualitygate.wait=true" "-Dsonar.qualitygate.timeout=300")
fi

set +e
sonar-scanner -X "${scan_args[@]}" 2>&1 | tee evidence/scanner.log
SCAN_RC=${PIPESTATUS[0]}
set -e
printf 'scanner_exit=%s\n' "$SCAN_RC" >> evidence/final-state.txt

# Preserve task metadata whenever upload reached that point, even if gate waiting failed.
if [ -f .scannerwork/report-task.txt ]; then
  cp .scannerwork/report-task.txt evidence/report-task.txt
  CE_TASK_ID="$(sed -n 's/^ceTaskId=//p' .scannerwork/report-task.txt)"
  printf 'ceTaskId=%s\n' "$CE_TASK_ID" >> evidence/final-state.txt
fi

exit "$SCAN_RC"
BASH
chmod +x ci/run-analysis.sh

4. Run locally: first asynchronous, then explicit gate enforcement

python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements-ci.txt
python -m pip freeze | tee evidence-packages.txt

export SONAR_HOST_URL="http://localhost:9000"
export SONAR_PROJECT_KEY="sq-ch19-ci-lab"
read -rsp "Disposable Sonar token: " SONAR_TOKEN; echo
export SONAR_TOKEN

# Run 1: default asynchronous scanner behavior.
SONAR_GATE_WAIT=false ./ci/run-analysis.sh
cat evidence/report-task.txt

# Query the saved ceTaskId after preserving the scanner evidence.
CE_TASK_ID="$(sed -n 's/^ceTaskId=//p' evidence/report-task.txt)"
curl -fsS -H "Authorization: Bearer $SONAR_TOKEN" \
  "$SONAR_HOST_URL/api/ce/task?id=$CE_TASK_ID" \
  | tee evidence/ce-task.json

# After CE is SUCCESS, query the gate independently.
curl -fsS -H "Authorization: Bearer $SONAR_TOKEN" \
  "$SONAR_HOST_URL/api/qualitygates/project_status?projectKey=$SONAR_PROJECT_KEY" \
  | tee evidence/gate.json

Run 1 proves the separation: a zero scanner exit does not tell you the gate result until the background task is complete.

# Run 2: make the CI/scanner step wait for the gate.
rm -rf evidence .scannerwork
SONAR_GATE_WAIT=true ./ci/run-analysis.sh
printf 'wait-mode scanner exit=%s\n' "$?"

If the project gate fails, the wait-mode scanner step fails even if analysis/report upload itself succeeded. Preserve scanner.log and report-task.txt before changing policy or rerunning.

5. Translate the contract into GitHub Actions

For Community Build, restrict this executable workflow to main. The SonarSource scan and gate actions are pinned to immutable commits in this teaching example; record the human-readable release next to each SHA so update review is intentional.

name: SonarQube CI

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  analyze:
    runs-on: ubuntu-latest
    env:
      SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
    steps:
      - name: Checkout full history
        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: '3.13'
          cache: 'pip'

      - name: Install test tools
        run: python -m pip install -r requirements-ci.txt

      - name: Test and create coverage before scan
        shell: bash
        run: |
          set -o pipefail
          mkdir -p evidence
          git rev-parse HEAD | tee evidence/revision.txt
          python -m pytest -q \
            --junitxml=evidence/junit.xml \
            --cov=src --cov-report=term \
            --cov-report=xml:coverage.xml \
            2>&1 | tee evidence/test.log
          test -s coverage.xml
          cp coverage.xml evidence/coverage.xml

      - name: SonarQube scan
        uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

      - name: Preserve task identity
        run: |
          mkdir -p evidence
          cp .scannerwork/report-task.txt evidence/report-task.txt
          sed -n 's/^ceTaskId=/ceTaskId=/p' .scannerwork/report-task.txt \
            | tee evidence/ce-task-id.txt

      - name: Enforce Quality Gate
        uses: SonarSource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0
        timeout-minutes: 10
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

      - name: Retain evidence even after a failure
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: sonarqube-ci-evidence-${{ github.run_id }}
          if-no-files-found: warn
          path: |
            evidence/
            coverage.xml
            .scannerwork/report-task.txt
Secret redaction. GitHub reads the Sonar credential from secrets.SONAR_TOKEN. Do not copy the value into YAML, job outputs, artifact names, or debugging echo statements. A self-hosted SonarQube instance must also be reachable from the chosen runner through an authorized network path.

6. Map the same evidence contract to GitLab and Jenkins

State GitLab CI/CD Jenkins
Full history GIT_DEPTH: "0" Configure SCM checkout with adequate history; verify with Git.
Scanner cache SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar", cache .sonar/cache Plugin/tool installation or controlled scanner cache on agents.
Secret Masked/protected SONAR_TOKEN CI variable Jenkins Credentials + configured SonarQube installation
Gate wait sonar.qualitygate.wait=true when the job must fail on gate waitForQualityGate; webhook to /sonarqube-webhook/ is mandatory
Evidence retention artifacts: when: always post { always { archiveArtifacts ... } }

7. Challenge: choose the correct layer

The GitHub job reaches the gate step but fails with “metadata file not found.” Tests and the scan log look successful. What should you inspect?

  1. Check whether the scan ran in the same workspace/base directory as the gate action.
  2. Check whether .scannerwork/report-task.txt was actually created.
  3. Check whether a cleanup or separate job boundary removed the scanner workspace.
  4. Do not lower the Quality Gate or regenerate a token: neither causes a missing metadata file.

Knowledge check

Why pin SonarSource actions to immutable commits in the example?

Why upload evidence with if: always()?

What does the Quality Gate Action read by default?

Why does the workflow run tests before the scan?

Does using GitHub Actions enable Community Build pull-request analysis?

Next lesson

Choose CI integration patterns deliberately

Lesson 3 compares native actions/plugins, direct scanners, gate-wait patterns, caches, and concurrency designs.

Official references and version notes

Version and compatibility note

Rechecked 2026-09-08. Mandatory labs target SonarQube Community Build 26.9.0.129388 and SonarScanner CLI 8.1.0.6389. The GitHub example pins SonarQube Scan Action v8.2.1 to commit 22918119ff8e1ca75a623e15c8296b6ea4fbe28f, Quality Gate Check Action v1.2.0 to cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b, checkout v6.1.0 to d23441a48e516b6c34aea4fa41551a30e30af803, setup-python v6.2.0 to a309ff8b426b58ec0e2a45f0f869d46889d02405, and upload-artifact v4.6.2 to ea165f8d65b6e75b540449e92b4886f43607fa02. Current SonarSource Community Build guidance restricts GitHub analysis to the main branch and recommends full Git history; GitLab examples use GIT_DEPTH: "0". Jenkins requires the SonarQube Scanner plugin (2.11+ per current docs) and a SonarQube webhook for waitForQualityGate. Recheck action/plugin versions, runner requirements, scanner/JRE behavior, provider deprecations, and edition boundaries before copying the examples into another release.

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.