SfSkills Domains Docs GitHub

Home / devops / github-actions-for-salesforce

github-actions-for-salesforce

Use this skill to set up, review, or troubleshoot GitHub Actions CI/CD pipelines for Salesforce using SFDX JWT Bearer Flow authentication, Apex test gates, and branch-conditional deployments. Trigger keywords: github actions, CI pipeline, jwt auth, sfdx ci, workflow yaml, github secrets, apex coverage threshold. NOT for GitLab CI — use devops/gitlab-ci-for-salesforce. NOT for Bitbucket Pipelines — use devops/bitbucket-pipelines-for-salesforce.

Open the skill folder on GitHub


GitHub Actions for Salesforce

This skill activates when a practitioner needs to build, review, or fix a GitHub Actions 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/review, and live troubleshooting of CI failures.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

JWT Bearer Flow Authentication

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

  1. A self-signed X.509 certificate is generated (openssl req -x509 -nodes -sha256 -days 365 -newkey rsa:2048 -keyout server.key -out server.crt).
  2. The .crt file is uploaded to a Connected App in Salesforce (Setup > App Manager > Edit > Upload Certificate).
  3. The server.key is stored as a GitHub Secret (e.g., SF_JWT_SERVER_KEY). The consumer key (client ID) from the Connected App is stored as a second secret (SF_CONSUMER_KEY).
  4. In the workflow, the key content is written to a temporary file and passed via --jwt-key-file. The --username flag must match the pre-authorized user on the Connected App.

The certificate has a finite lifespan (default 365 days with the OpenSSL command above). Expiry is the most common source of sudden, unexplained CI auth failures.

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 should 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 command namespaces in a single workflow causes confusing output and will break when the deprecated commands are removed.

GitHub Secrets and Environment Variables

GitHub Secrets are available to workflow steps as environment variables. They are masked in logs. The correct pattern for injecting a multi-line secret (like a private key) is:

- name: Write JWT key to file
  run: echo "$SF_JWT_SERVER_KEY" > /tmp/server.key
  env:
    SF_JWT_SERVER_KEY: ${{ secrets.SF_JWT_SERVER_KEY }}

Do not commit server.key to the repository. Do not use GitHub Variables (unencrypted) for any credential. Repository-level secrets are available to all workflows; use Environment secrets (under Settings > Environments) if you need per-environment credential isolation.

Apex Test Gating with Coverage Thresholds

The sf apex run test command supports --code-coverage to collect per-class coverage. Deployment via sf project deploy start enforces the platform's 75% org-wide threshold at the time of deploy if you use --test-level RunLocalTests or RunAllTestsInOrg. For finer control in CI — fail the build before deployment if a specific threshold is not met — run tests separately first, parse the JSON results, and use a shell check to fail the step.

Flow tests in CI (Winter '26+). Autolaunched Flow tests are no longer Flow-Builder-only. Add a parallel gate with sf flow run test --target-org target-org --code-coverage --tests FlowTesting.MyFlowTest (requires View All Data) or use the Beta unified runner sf logic run test --test-category Flow. Flow tests use the FlowTesting namespace prefix in --tests / --class-names. See apex/tooling-api-patterns for Tooling API discovery details.


Common Patterns

Mode 1: Set Up GitHub Actions from Scratch

When to use: No .github/workflows/ directory exists yet, or the existing workflow is a placeholder. Team wants 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 in Salesforce. Store server.key content and the Connected App consumer key in GitHub Secrets.

Step 2 — Create .github/workflows/deploy.yml. The minimal structure:

name: Salesforce CI/CD

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

jobs:
  validate-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Salesforce CLI
        run: npm install --global @salesforce/cli@latest

      - name: Write JWT server key
        run: echo "$SF_JWT_SERVER_KEY" > /tmp/server.key
        env:
          SF_JWT_SERVER_KEY: ${{ secrets.SF_JWT_SERVER_KEY }}

      - name: Authenticate to target org
        run: |
          sf org login jwt \
            --client-id "$SF_CONSUMER_KEY" \
            --jwt-key-file /tmp/server.key \
            --username "$SF_USERNAME" \
            --alias target-org \
            --set-default
        env:
          SF_CONSUMER_KEY: ${{ secrets.SF_CONSUMER_KEY }}
          SF_USERNAME: ${{ secrets.SF_USERNAME }}

      - name: Run Apex tests with coverage
        run: |
          sf apex run test \
            --target-org target-org \
            --test-level RunLocalTests \
            --code-coverage \
            --result-format json \
            --output-dir test-results \
            --wait 20

      - name: Enforce 75% coverage threshold
        run: |
          COVERAGE=$(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('%','')
          print(pct)
          ")
          echo "Org-wide coverage: ${COVERAGE}%"
          python3 -c "assert float('${COVERAGE}') >= 75, 'Coverage below 75%'"

      - name: Deploy to target org (main branch only)
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: |
          sf project deploy start \
            --target-org target-org \
            --source-dir force-app \
            --test-level NoTestRun \
            --wait 30

      - name: Remove JWT key file
        if: always()
        run: rm -f /tmp/server.key

Why not a simpler approach: Using sfdx auth:jwt:grant (old namespace) or hard-coding credentials as plain text environment variables violates the Connected App isolation model and creates credential-in-log risks.

Mode 2: Reviewing/Auditing an Existing Pipeline

When to use: A pipeline exists but the team suspects it has security gaps, uses deprecated commands, or has flaky test 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. Secrets hygiene — Are credentials referenced via ${{ secrets.X }}? Are any credentials echoed to stdout in run: blocks without masking?
  3. CLI version — Is sfdx force:* (deprecated) or sf (current) used?
  4. Test level — Is RunLocalTests or RunAllTestsInOrg specified, or is --test-level omitted (defaults to NoTestRun, which skips coverage)?
  5. Branch conditions — Does the if: guard on the deploy step correctly target only the intended branch and event type?
  6. Key cleanup — Is there an if: always() step to delete the temporary server.key file even when earlier steps fail?
  7. Certificate expiry — When was the certificate generated? Check the Connected App in Setup for the expiry date.

Mode 3: Troubleshooting CI Failures

When to use: A previously working pipeline starts failing.

Common failure patterns and fixes:

SymptomRoot CauseFix
INVALID_SESSION_ID or expired access/refresh tokenJWT cert expired (365-day default)Regenerate cert, re-upload to Connected App, update secret
No org found for alias target-orgAuth step failed silently; later steps reference unregistered aliasAdd --set-default to login step; check sf org list in a debug step
Deploy failed: Test coverage of classes is 0%--test-level NoTestRun used on production deployUse RunLocalTests or pre-validate coverage in a separate step
ECONNREFUSED or timeout on sf apex run testScratch org Daily limit hit or org was deletedSwitch to sandbox-based testing; check scratch org limits in Dev Hub
Error: Can't set default orgUsername not authorized on Connected AppVerify pre-authorized users in the Connected App's OAuth Policies section

Decision Guidance

SituationRecommended ApproachReason
Pull request validation only (no deploy)sf project deploy validate with --dry-runValidates metadata and runs tests without committing to the org; idempotent
Deploy on merge to main (production)sf project deploy start with --test-level RunLocalTestsEnforces platform 75% threshold at deploy time; no double-test overhead
Feature branch with ephemeral scratch orgCreate scratch org, run tests, delete orgOrg-per-PR isolation; be mindful of daily scratch org limits
Sandbox promotion (develop → staging)sf project deploy start with --target-org sandbox-alias and JWT auth per environmentUse separate GitHub Environment secrets per environment for credential isolation
Rollback after failed production deployTrigger a revert PR and re-run the pipelineSalesforce does not support automated rollback via CLI; rollback is a new deploy

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

  1. Gather context — confirm the org edition, relevant objects, and current configuration state
  2. Review official sources — check the references in this skill's well-architected.md before making changes
  3. Implement or advise — apply the patterns from Core Concepts and Common Patterns sections above
  4. Validate — run the skill's checker script and verify against the Review Checklist below
  5. Document — record any deviations from standard patterns and update the template if needed

Review Checklist

Run through these before marking the pipeline complete:


Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems:

  1. Scratch org daily limits exhaust silently — Developer Edition Dev Hub allows only 6 scratch orgs per day across all users and CI runs. If your on: pull_request trigger fires frequently, you will hit this limit mid-day. The error (LIMIT_EXCEEDED: You have reached the daily scratch org creation limit) appears in the auth/create step and is easy to misread as a credential error. Prevention: use scratch orgs only for isolated unit tests; use a persistent sandbox for integration tests in high-activity repos.
  2. JWT certificate expiry has no warning — Salesforce does not send any notification when a JWT certificate uploaded to a Connected App is nearing expiry. The pipeline simply starts returning INVALID_SESSION_ID on the renewal date. Prevention: set a calendar reminder or GitHub Actions scheduled job to alert 30 days before the cert's 365-day (or configured) lifespan ends. Rotate the cert and update GitHub Secrets before expiry.
  3. Parallel jobs writing to the same alias conflict — When a workflow has multiple jobs running in parallel (e.g., unit-tests job and integration-tests job both run sf org login jwt --alias target-org), they share the ~/.sf/ credential store on the runner. The second login overwrites the first alias entry. Fix: use unique aliases per job (--alias target-org-${{ github.run_id }}-unit) or use matrix strategy with per-job environment isolation.
  4. NoTestRun is the silent default for sf project deploy start — If --test-level is omitted from a deploy command targeting a production org, the CLI defaults to RunSpecifiedTests or NoTestRun depending on context, and Salesforce enforces 75% at the platform level only if tests are actually run. Omitting the flag on a sandbox deploy means zero tests run and zero coverage is checked. Always specify --test-level explicitly.
  5. Connected App "Pre-authorize users" must be configured — JWT authentication silently fails if the org's Connected App does not have the authenticating user listed under "Manage Connected Apps > OAuth Policies > Pre-authorize users" (Permitted Users: Admin approved users are pre-authorized). Adding the user to the profile or permission set assigned to the Connected App is not sufficient — the explicit pre-authorization step is required.

Output Artifacts

ArtifactDescription
.github/workflows/deploy.ymlComplete GitHub Actions workflow YAML with JWT auth, test gate, branch-conditional deploy, and cleanup
test-results/ directoryJSON and JUnit XML Apex test output produced by sf apex run test --output-dir
Pipeline audit findingsOrdered list of security and best-practice gaps found in an existing workflow file
Troubleshooting diagnosisRoot cause and fix for a specific CI failure symptom

Dependencies

Package files