I'm currently building a CI/CD pipeline in GitLab, and I'm running into trouble with integrating a code coverage check.
What I’ve Got So Far:
A working GitLab Runner (on Windows).
An Apex class and a corresponding Apex test class, which locally produce 100% code coverage.
The pipeline already runs the tests successfully.
What I Need:
I'd like the pipeline to fail automatically if the code coverage drops below 80%. I want to enforce this threshold as part of our quality gate during the build process.
Where I’m Stuck:
I can’t seem to figure out:
How to retrieve and evaluate Apex code coverage from within the pipeline.
How to integrate that check into .gitlab-ci.yml.
Whether this requires any special Salesforce CLI commands, scripting, or third-party tools.
If anyone has implemented something similar or has insights on how to do this (especially in a GitLab + Windows + Apex context), I’d really appreciate your help.
Thanks in advance!
stages:
- build-testing
- prod-deploy
variables:
SF_INSTANCE_URL: "https://test.salesforce.com"
TEST_RESULTS_DIR: "artifacts/test-results"
LOG_DIR: "artifacts/logs"
default:
tags:
- meh-playground
before_script:
- '$ErrorActionPreference = "Stop"'
- 'Write-Host "Using PowerShell on Windows Runner"'
- 'New-Item -ItemType Directory -Force -Path "assets","$Env:TEST_RESULTS_DIR","$Env:LOG_DIR" | Out-Null'
# SERVER_KEY ist eine GitLab File-Variable → Pfad in $Env:SERVER_KEY
- 'if (!(Test-Path $Env:SERVER_KEY)) { throw "SERVER_KEY File Variable not found!" }'
- 'Copy-Item -Path $Env:SERVER_KEY -Destination "server.key"'
# CLI bereitstellen + Übersicht
- 'npx -y @salesforce/cli --version'
- 'npx -y @salesforce/cli plugins --core'
after_script:
# Clean-up immer versuchen
- 'if (Test-Path "server.key") { Remove-Item "server.key" -Force -ErrorAction SilentlyContinue }'
# --- Build & Test auf Scratch-Org ---
build-testing:
stage: build-testing
script:
- |
try {
Write-Host "JWT login to $Env:SF_USERNAME_SANDBOX via $Env:SF_INSTANCE_URL"
npx -y @salesforce/cli org login jwt `
--instance-url "$Env:SF_INSTANCE_URL" `
--client-id "$Env:SF_CONSUMER_KEY_SANDBOX" `
--jwt-key-file "server.key" `
--username "$Env:SF_USERNAME_SANDBOX" `
--alias HubOrg `
--set-default-dev-hub
npx -y @salesforce/cli org whoami --target-org HubOrg
npx -y @salesforce/cli org create scratch `
--target-dev-hub HubOrg `
--definition-file "config/project-scratch-def.json" `
--alias ApexHours `
--set-default `
--wait 20 `
--duration-days 7
npx -y @salesforce/cli org display --target-org ApexHours
# Metadaten deployen
npx -y @salesforce/cli project deploy start `
--target-org ApexHours `
--source-dir "force-app" `
--ignore-conflicts
# Apex-Tests ausführen mit Coverage-Validierung
Write-Host "Running Apex tests with code coverage validation (minimum 80%)"
# JUnit-Format für GitLab Test Reports
npx -y @salesforce/cli apex test run `
--target-org ApexHours `
--wait 30 `
--result-format junit `
--output-dir "$Env:TEST_RESULTS_DIR" `
--loglevel warn
# JSON-Format für Coverage-Analyse
$testOutput = npx -y @salesforce/cli apex test run `
--target-org ApexHours `
--wait 30 `
--result-format json `
--loglevel warn
# Test-Output in Datei speichern
$testOutput | Out-File -FilePath "$Env:TEST_RESULTS_DIR\apex-tests.json" -Encoding UTF8
# Coverage-Validierung
try {
$testJson = $testOutput | ConvertFrom-Json
$orgWideCoverageStr = $testJson.result.summary.orgWideCoverage
# Entferne das %-Zeichen und konvertiere zu Double
if ($orgWideCoverageStr -match '(\d+(?:\.\d+)?)%?') {
$coverage = [double]$matches[1]
Write-Host "Org-wide coverage: $coverage%"
if ($coverage -lt 80) {
throw "Code coverage ($coverage%) is below the required minimum of 80%"
} else {
Write-Host "✓ Code coverage requirement met: $coverage% >= 80%"
}
} else {
Write-Warning "Could not parse coverage value: $orgWideCoverageStr"
}
} catch {
Write-Warning "Coverage validation failed: $($_.Exception.Message)"
Write-Host "Test output for debugging:"
Write-Host $testOutput
}
# (Optional) Org-Logs
npx -y @salesforce/cli org display --target-org ApexHours | Out-File -FilePath "$Env:LOG_DIR\org-display.txt" -Encoding UTF8
}
finally {
# Scratch-Org aufräumen, auch bei Fehlern
try {
npx -y @salesforce/cli org delete scratch --target-org ApexHours --no-prompt
} catch {
Write-Host "Scratch org cleanup skipped/failed."
}
}
artifacts:
when: always
expire_in: 2 weeks
reports:
junit:
- artifacts/test-results/**/*.xml
paths:
- artifacts/
# --- Manueller Deploy-Job ---
prod-deploy:
stage: prod-deploy
when: manual
needs: ["build-testing"]
resource_group: prod-deploy
script:
- |
Write-Host "JWT login to $Env:SF_USERNAME_SANDBOX via $Env:SF_INSTANCE_URL"
npx -y @salesforce/cli org login jwt `
--instance-url "$Env:SF_INSTANCE_URL" `
--client-id "$Env:SF_CONSUMER_KEY_SANDBOX" `
--jwt-key-file "server.key" `
--username "$Env:SF_USERNAME_SANDBOX" `
--alias HubOrg `
--set-default-dev-hub
# Hinweis: hier wird direkt in die angegebene Org deployed (aktuell: Sandbox/HubOrg)
npx -y @salesforce/cli project deploy start `
--target-org HubOrg `
--source-dir "force-app/main/default" `
--ignore-conflicts
I also tried this, but now he cant convert Apex to JSON:
stages:
- build-testing
- prod-deploy
variables:
SF_INSTANCE_URL: "https://test.salesforce.com"
default:
tags: [meh-playground]
before_script:
- '$ErrorActionPreference = "Stop"'
- 'Write-Host "Using PowerShell on Windows Runner"'
- 'if (!(Test-Path "assets")) { New-Item -ItemType Directory -Path "assets" | Out-Null }'
- 'if (!(Test-Path $Env:SERVER_KEY)) { throw "SERVER_KEY File Variable not found!" }'
- 'Copy-Item -Path $Env:SERVER_KEY -Destination "server.key"'
- 'npx -y @salesforce/cli --version'
- 'npx -y @salesforce/cli plugins --core'
build-testing:
stage: build-testing
script:
- |
# region PowerShell
$ScratchAlias = if ($Env:CI_PIPELINE_ID) { "ApexHours_{0}" -f $Env:CI_PIPELINE_ID } elseif ($Env:CI_JOB_ID) { "ApexHours_{0}" -f $Env:CI_JOB_ID } else { "ApexHours_ci" }
$ScratchCreated = $false
try {
Write-Host "JWT login to $Env:SF_USERNAME_SANDBOX via $Env:SF_INSTANCE_URL"
& npx -y @salesforce/cli org login jwt `
--instance-url "$Env:SF_INSTANCE_URL" `
--client-id "$Env:SF_CONSUMER_KEY_SANDBOX" `
--jwt-key-file "server.key" `
--username "$Env:SF_USERNAME_SANDBOX" `
--alias HubOrg `
--set-default-dev-hub
if ($LASTEXITCODE -ne 0) { throw "JWT login failed (exit $LASTEXITCODE)" }
# --- Scratch-Org erstellen ---
$createOut = (& npx -y @salesforce/cli org create scratch `
--target-dev-hub HubOrg `
--definition-file "config/project-scratch-def.json" `
--alias $ScratchAlias `
--set-default `
--wait 10 `
--duration-days 7 `
--json) 2>&1
$createCode = $LASTEXITCODE
if ($createCode -ne 0) {
$createTxt = $createOut | Out-String
if ($createTxt -match 'LIMIT_EXCEEDED') {
Write-Warning "Daily scratch org signup limit reached. Skipping deploy & tests for this run."
# Wenn du hart fehlschlagen willst, ersetze die nächste Zeile durch: throw "Scratch org daily limit reached"
exit 0
} else {
Write-Error $createTxt
throw "Scratch org creation failed (exit $createCode)"
}
} else {
$ScratchCreated = $true
}
if ($ScratchCreated) {
& npx -y @salesforce/cli org display `
--target-org $ScratchAlias
if ($LASTEXITCODE -ne 0) { throw "org display failed (exit $LASTEXITCODE)" }
& npx -y @salesforce/cli project deploy start `
--target-org $ScratchAlias `
--source-dir "force-app"
if ($LASTEXITCODE -ne 0) { throw "project deploy failed (exit $LASTEXITCODE)" }
$testResult = (& npx -y @salesforce/cli apex test run `
--target-org $ScratchAlias `
--wait 10 `
--code-coverage `
--json) 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error ($testResult | Out-String)
throw "Apex test run failed (exit $LASTEXITCODE)"
}
if (-not $testResult) { throw "Apex test run produced no output." }
$json = $testResult | ConvertFrom-Json
if (-not $json -or -not $json.result) { throw "Failed to parse Apex test JSON." }
$coverageItems = $json.result.codeCoverage
if ($coverageItems) {
$coverage = ($coverageItems | Measure-Object -Property coveragePercent -Average).Average
Write-Host ("Average Code Coverage: {0}%" -f [math]::Round($coverage,2))
if ($coverage -lt 80) { throw ("Code coverage is below 80%. Actual: {0}%" -f $coverage) }
} else {
Write-Warning "No code coverage details returned."
}
}
}
finally {
try {
if ($ScratchCreated) {
& npx -y @salesforce/cli org delete scratch `
--target-org $ScratchAlias `
--no-prompt
} else {
Write-Host "No scratch org was created; skipping deletion."
}
} catch {
Write-Host "Scratch org cleanup skipped/failed: $($_.Exception.Message)"
}
if (Test-Path "server.key") {
Remove-Item "server.key" -Force -ErrorAction SilentlyContinue
}
}
# endregion
prod-deploy:
stage: prod-deploy
when: manual
needs: ["build-testing"]
script:
- 'Write-Host "JWT login to $Env:SF_USERNAME_SANDBOX via $Env:SF_INSTANCE_URL"'
- |
& npx -y @salesforce/cli org login jwt `
--instance-url "$Env:SF_INSTANCE_URL" `
--client-id "$Env:SF_CONSUMER_KEY_SANDBOX" `
--jwt-key-file "server.key" `
--username "$Env:SF_USERNAME_SANDBOX" `
--alias HubOrg `
--set-default-dev-hub
if ($LASTEXITCODE -ne 0) { throw "JWT login failed (exit $LASTEXITCODE)" }
- |
& npx -y @salesforce/cli project deploy start `
--target-org HubOrg `
--source-dir "force-app/main/default"
if ($LASTEXITCODE -ne 0) { throw "project deploy failed (exit $LASTEXITCODE)" }
after_script:
- 'if (Test-Path "server.key") { Remove-Item "server.key" -Force -ErrorAction SilentlyContinue }'