SfSkills Domains Docs GitHub

Home / devops / gitlab-ci-for-salesforce

gitlab-ci-for-salesforce

Use this skill to set up, review, or troubleshoot GitLab CI/CD pipelines for Salesforce using SFDX JWT Bearer Flow authentication, Docker executor runners, Apex test gates, branch-based deployment jobs, and GitLab CI/CD variables for secret management. Trigger keywords: gitlab-ci.yml, gitlab runner, gitlab ci salesforce, gitlab ci sfdx, gitlab pipeline deploy salesforce, gitlab ci jwt auth. NOT for GitHub Actions (use devops/github-actions-for-salesforce), Bitbucket Pipelines (use devops/bitbucket-pipelines-for-salesforce), Jenkins, Copado, Azure DevOps, or any non-GitLab CI platform.

Open the skill folder on GitHub


GitLab CI for Salesforce

This skill activates when a practitioner needs to build, review, or fix a GitLab CI/CD pipeline that deploys Salesforce metadata and runs Apex tests using the Salesforce CLI and JWT Bearer Flow authentication. It covers all three operating modes: initial setup from scratch, pipeline audit and review, and live troubleshooting of CI failures. It is scoped exclusively to GitLab CI using .gitlab-ci.yml.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

JWT Bearer Flow Authentication in GitLab CI

The Salesforce CLI's sf org login jwt command authenticates non-interactively using a private RSA key and a Connected App. Per the Salesforce DX Developer Guide, the flow requires:

  1. Generate a self-signed X.509 certificate:
    openssl req -x509 -nodes -sha256 -days 365 -newkey rsa:2048 \
      -keyout server.key -out server.crt \
      -subj "/C=US/ST=CA/L=SF/O=MyOrg/CN=ci-service"
  2. Upload server.crt to the Connected App in Salesforce (Setup > App Manager > Edit > Upload Certificate).
  3. Store the content of server.key in a masked GitLab CI/CD variable (e.g., SF_JWT_SERVER_KEY). Store the Connected App consumer key in a second masked variable (e.g., SF_CONSUMER_KEY).
  4. In the pipeline job, write the key content to a temporary file before calling sf org login jwt --jwt-key-file.

The certificate has a finite lifespan (365 days by default with the OpenSSL command above). Expiry is the most common cause of sudden, unexplained CI auth failures. GitLab and Salesforce send no warning when a cert nears expiry.

sf CLI vs. Legacy sfdx Commands

As of Spring '25, Salesforce has unified the CLI under sf (Salesforce CLI v2). The legacy sfdx force:* namespace still works but is deprecated. All new pipelines must use sf project deploy start (not sfdx force:source:deploy) and sf apex run test (not sfdx force:apex:test:run). Mixing old and new namespaces in a single pipeline produces ambiguous output and will break when deprecated commands are removed.

GitLab CI/CD Variables for Secrets

GitLab CI/CD variables come in two sensitivity levels:

Variables are injected as environment variables into every job. Reference them with $VARIABLE_NAME in YAML scripts. Never use echo $SF_JWT_SERVER_KEY in a script block — masked variables are blocked from appearing in logs, but this is best-hygiene avoidance, not a security substitute.

.gitlab-ci.yml Pipeline Structure

GitLab CI uses a declarative YAML file at the repository root. Key concepts for Salesforce CI:


Common Patterns

Mode 1: Set Up GitLab CI from Scratch

When to use: No .gitlab-ci.yml exists yet, or it is a placeholder. Team wants branch-based push-to-deploy with Apex test gating.

How it works:

Step 1 — Generate and upload the JWT cert (done once, outside CI):

openssl req -x509 -nodes -sha256 -days 365 -newkey rsa:2048 \
  -keyout server.key -out server.crt \
  -subj "/C=US/ST=CA/L=SF/O=MyOrg/CN=ci-service"

Upload server.crt to the Connected App. Store server.key content and the consumer key as masked GitLab CI/CD variables. Delete server.key locally after uploading.

Step 2 — Configure GitLab CI/CD variables in the project (Settings > CI/CD > Variables):

VariableValueMaskedProtected
SF_JWT_SERVER_KEY_DEVcontents of server.key for dev orgYesNo
SF_CONSUMER_KEY_DEVConnected App consumer key for devYesNo
SF_USERNAME_DEVpre-authorized username in dev orgYesNo
SF_JWT_SERVER_KEY_PRODcontents of server.key for prodYesYes
SF_CONSUMER_KEY_PRODConnected App consumer key for prodYesYes
SF_USERNAME_PRODpre-authorized username in productionYesYes

Step 3 — Create .gitlab-ci.yml at the repository root:

# .gitlab-ci.yml
image: node:20

stages:
  - validate
  - test
  - deploy

variables:
  SF_INSTANCE_URL_SANDBOX: "https://test.salesforce.com"
  SF_INSTANCE_URL_PROD: "https://login.salesforce.com"

before_script:
  - npm install --global @salesforce/cli@latest --quiet

# ── Feature branches: metadata validation only ────────────────────────
validate-metadata:
  stage: validate
  script:
    - echo "$SF_JWT_SERVER_KEY_DEV" > /tmp/server.key
    - sf org login jwt
        --client-id "$SF_CONSUMER_KEY_DEV"
        --jwt-key-file /tmp/server.key
        --username "$SF_USERNAME_DEV"
        --instance-url "$SF_INSTANCE_URL_SANDBOX"
        --alias dev-org
        --set-default
    - sf project deploy validate
        --target-org dev-org
        --source-dir force-app
        --test-level RunLocalTests
        --wait 30
  after_script:
    - rm -f /tmp/server.key
  rules:
    - if: '$CI_COMMIT_BRANCH =~ /^feature\//'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# ── Develop branch: run Apex tests ───────────────────────────────────
apex-tests:
  stage: test
  script:
    - echo "$SF_JWT_SERVER_KEY_DEV" > /tmp/server.key
    - sf org login jwt
        --client-id "$SF_CONSUMER_KEY_DEV"
        --jwt-key-file /tmp/server.key
        --username "$SF_USERNAME_DEV"
        --instance-url "$SF_INSTANCE_URL_SANDBOX"
        --alias sandbox-org
        --set-default
    - sf apex run test
        --target-org sandbox-org
        --test-level RunLocalTests
        --code-coverage
        --result-format json
        --output-dir test-results
        --wait 20
    - python3 -c "
        import json, sys
        with open('test-results/test-result.json') as f:
            data = json.load(f)
        pct = data.get('summary', {}).get('orgWideCoverage', '0%').replace('%','')
        assert float(pct) >= 75, f'Coverage {pct}% is below 75% threshold'
        print(f'Coverage OK: {pct}%')
      "
  after_script:
    - rm -f /tmp/server.key
  artifacts:
    when: always
    paths:
      - test-results/
    expire_in: 7 days
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

# ── Develop branch: deploy to sandbox ────────────────────────────────
deploy-sandbox:
  stage: deploy
  script:
    - echo "$SF_JWT_SERVER_KEY_DEV" > /tmp/server.key
    - sf org login jwt
        --client-id "$SF_CONSUMER_KEY_DEV"
        --jwt-key-file /tmp/server.key
        --username "$SF_USERNAME_DEV"
        --instance-url "$SF_INSTANCE_URL_SANDBOX"
        --alias sandbox-org
        --set-default
    - sf project deploy start
        --target-org sandbox-org
        --source-dir force-app
        --test-level NoTestRun
        --wait 30
  after_script:
    - rm -f /tmp/server.key
  environment:
    name: sandbox
  needs:
    - apex-tests
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

# ── Main branch: deploy to production ────────────────────────────────
deploy-production:
  stage: deploy
  script:
    - echo "$SF_JWT_SERVER_KEY_PROD" > /tmp/server.key
    - sf org login jwt
        --client-id "$SF_CONSUMER_KEY_PROD"
        --jwt-key-file /tmp/server.key
        --username "$SF_USERNAME_PROD"
        --instance-url "$SF_INSTANCE_URL_PROD"
        --alias prod-org
        --set-default
    - sf project deploy start
        --target-org prod-org
        --source-dir force-app
        --test-level RunLocalTests
        --wait 60
  after_script:
    - rm -f /tmp/server.key
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

Why not a simpler approach: Using sf org login web requires browser interaction, which is impossible in a non-interactive runner. Using SFDP URL auth stores a live refresh token in the variable; if leaked, it grants immediate org access with no expiry signal. JWT Bearer Flow generates a new short-lived access token per run and provides predictable cert-based expiry. The when: manual gate on the production job requires an explicit human approval click in GitLab before production deployment proceeds — preventing accidental pushes to production.

Mode 2: Reviewing/Auditing an Existing Pipeline

When to use: A .gitlab-ci.yml exists but the team suspects security gaps, deprecated commands, or flaky failures.

How it works:

Check for these items in order:

  1. Auth method — Is sf org login jwt used (correct) or sf org login web/sfdx auth:sfdxurl:store (wrong for CI)?
  2. Variable masking — Are all credentials in masked GitLab CI/CD variables? Are production credentials also marked "Protected"? Are any variables echoed with echo $VAR in job scripts?
  3. CLI namespace — Is sfdx force:* (deprecated) used instead of sf?
  4. Test level — Is --test-level explicitly set on every sf project deploy start call?
  5. Rules vs. only/except — Is the older only:/except: syntax used? Migrate to rules: for correctness and future compatibility. GitLab CI's only: has subtle matching quirks with branch patterns.
  6. after_script: cleanup — Is rm -f /tmp/server.key in after_script: (runs on failure too), not just in the main script: block?
  7. Certificate expiry — When was the JWT certificate generated? Check the Connected App in Setup for the expiry date.
  8. Per-environment credential isolation — Are separate masked+protected variables used for production vs. sandbox? Are sandbox variables protected=false (allows feature branch jobs) and production variables protected=true (allows only protected branches)?
  9. when: manual on production — Is the production deployment job gated with when: manual? Automated production deploys on every main push are a common configuration risk.
  10. Artifact retention — Are Apex test result files captured in artifacts: paths: so they can be reviewed after a job completes?

Mode 3: Troubleshooting CI Failures

When to use: A previously working pipeline starts failing.

SymptomRoot CauseFix
INVALID_SESSION_ID or expired access/refresh tokenJWT cert expired (365-day default)Regenerate cert, re-upload to Connected App, update masked variable
No org found for alias sandbox-orgAuth step failed silently; later steps reference unregistered aliasAdd --set-default to login step; add sf org list in a debug script
Deploy failed: Test coverage of classes is 0%--test-level NoTestRun used on production deployUse RunLocalTests for production; run a separate test job first
Error: ENOTFOUND login.salesforce.com or connection timeoutWrong --instance-url for org typeUse https://test.salesforce.com for sandbox, https://login.salesforce.com for production
LIMIT_EXCEEDED: scratch org creation limitDaily scratch org allocation exhaustedSwitch to sandbox-based testing; check limits in Dev Hub
Error: Can't set default org / user not authorizedConnected App OAuth pre-authorization not setVerify pre-authorized users in Connected App > OAuth Policies
Job never triggers on main pushrules: branch condition or protected variable mismatchCheck branch name, variable protection level; use GitLab CI lint tool
masked variable value rejectedVariable value contains characters that cannot be masked (e.g., $, newlines)Base64-encode the private key value; decode it in the job script before writing to file

Decision Guidance

SituationRecommended ApproachReason
Pull request / merge request validation onlysf project deploy validate — no source deployValidates metadata and runs tests without committing; idempotent; safe on MR events
Deploy on merge to develop (sandbox)sf project deploy start with --test-level NoTestRun after a separate passing test jobAvoids double-running all tests; rely on the dedicated test job for coverage gate
Deploy on merge to main (production)sf project deploy start with --test-level RunLocalTests and when: manual gateEnforces 75% threshold; manual gate prevents accidental production deploy
Faster CI on large orgs (Spring '26 Beta)sf project deploy start --test-level RunRelevantTests plus @IsTest(critical=true) / @IsTest(testFor='ClassName') on must-run testsRuns only tests tied to the deployment payload instead of all local tests
Feature branch with ephemeral scratch orgCreate scratch org in job, run tests, delete in after_script:Org-per-branch isolation; watch daily limits (6/day for Developer Edition Dev Hub)
Base64-encoded private key`echo "$SF_JWT_KEY_B64"base64 -d > /tmp/server.key`Avoids GitLab masking rejection for multi-line values with special characters
Rollback after failed production deployRevert branch and re-run the pipelineSalesforce does not support automated rollback via CLI; rollback is a new deployment
JWT vs. SFDP URL authJWT Bearer Flow preferredSFDP URL embeds a refresh token that can expire unpredictably; JWT cert expiry is predictable and rotatable

Step-by-step instructions for an AI agent or practitioner activating this skill:

  1. Gather context — Confirm whether a .gitlab-ci.yml exists, identify the target org type (sandbox/production), check whether a Connected App with JWT is already configured, and determine the team's branch naming convention.
  2. Check auth prerequisites — Verify the Connected App exists with a valid non-expired certificate uploaded, the pre-authorized username is listed in the Connected App's OAuth Policies, and the masked GitLab CI/CD variables are set for each environment.
  3. Build or review the pipeline — Apply the Mode 1 (setup), Mode 2 (review), or Mode 3 (troubleshoot) pattern from Common Patterns above based on what the practitioner needs. Ensure rules: conditions, stage ordering, after_script: cleanup, and when: manual on production are all correct.
  4. Validate the YAML locally — Run gitlab-ci-lint or use the GitLab CI/CD > Editor lint tool to catch syntax errors before pushing. Check that masked variable names match exactly between the YAML and the project's CI/CD Variables settings.
  5. Run the skill's checker script — python3 scripts/check_gitlab_ci_for_salesforce.py --gitlab-ci-file .gitlab-ci.yml to catch common structural issues.
  6. Review checklist — Walk through the Review Checklist below before marking complete.
  7. Document deviations — Record any non-standard patterns (e.g., shell executor instead of Docker, base64-encoded key) in the template notes section.

Review Checklist

Run through these before marking the pipeline complete:


Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems:

  1. GitLab masked variables reject multi-line values — GitLab CI/CD variable masking fails silently if the variable value contains newlines or certain special characters ($, [, ]). A private key (server.key) contains newlines, which prevents masking. Workaround: base64-encode the key before storing it in the variable (base64 -w 0 server.key) and decode it in the job script (echo "$SF_JWT_KEY_B64" | base64 -d > /tmp/server.key). GitLab documents this limitation in the CI/CD variable masking requirements.
  2. JWT certificate expiry has no built-in notification — Salesforce does not send any warning when a JWT certificate on a Connected App is nearing expiry. The pipeline returns INVALID_SESSION_ID on the expiry date with no advance notice. Prevention: record the cert generation date in a team calendar; set a reminder 30 days before the 365-day lifespan ends; rotate before expiry. Extend the lifespan to 730 days with -days 730 to reduce rotation frequency.
  3. when: manual jobs block dependent needs: jobs — GitLab CI needs: creates a DAG dependency: if deploy-production needs apex-tests, the production job will wait for apex-tests to succeed. However, if deploy-production is also when: manual, GitLab requires a manual trigger first — the job stays in a "manual" (blocked) state even after all needs: dependencies pass. This is expected behavior, not a bug: the manual trigger requires explicit user action even when all upstream jobs succeed.
  4. only:/except: branch regex matches differently than expected — The legacy only: branches with a regex like only: [/^feature\//] matches against the full ref path in some GitLab versions, not just the branch name. This causes feature branch jobs to unexpectedly skip or fire. Migrate to rules: - if: '$CI_COMMIT_BRANCH =~ /^feature\//' which reliably matches against the branch name only.
  5. Connected App OAuth pre-authorization is separate from profile assignment — Adding the CI service user to the profile or permission set on the Connected App is not sufficient for JWT auth. The Connected App's OAuth Policies must have "Permitted Users: Admin approved users are pre-authorized" and the specific user (or their profile) must be listed under "Manage Connected Apps > Pre-Authorize Users". Skipping the pre-authorization step produces a cryptic auth failure that looks identical to a wrong credential.

Output Artifacts

ArtifactDescription
.gitlab-ci.ymlComplete GitLab CI pipeline with stage-based JWT auth, Apex test gate, branch-conditional deploy, when: manual production gate, and after_script: cleanup
test-results/ directoryJSON and JUnit XML Apex test output produced by sf apex run test --output-dir, retained as a GitLab job artifact
Pipeline audit findingsOrdered list of security and best-practice gaps found in an existing .gitlab-ci.yml
Troubleshooting diagnosisRoot cause and fix for a specific CI failure symptom

Dependencies

Package files