GitHub Actions to Salesforce — JWT login with an External Client App, validate on pull request, quick deploy on merge, agents and their actions in one manifest
An end-to-end CI pattern for a Salesforce DX project with Agentforce metadata — an External Client App with JWT bearer and a pre-authorized integration user, the private key held as a GitHub secret and written to a file only for the job, sf org login jwt, validate with RunLocalTests on pull requests, quick deploy of the validated job on merge, agent actions deployed before agents, and the known-bad setups that break it.
markdownpatternv1PZ12-5NJHstage: deploytrust: officialservice: dxinterface: cliGitHub Actions SalesforceCI/CD Salesforce DXsf org login jwt in CISF_JWT_KEY secretnpm install @salesforce/cliSF_DISABLE_TELEMETRYSF_AUTOUPDATE_DISABLEsf project deploy validate pull requestsf project deploy quick--use-most-recentRunLocalTests CIagent deploy CIweb login not available in CIactivate agent before tests
GitHub Actions to Salesforce with JWT
Set up once
- ECA with JWT bearer in the target org: Enable JWT Bearer Flow, upload
server.crt, Permitted Users = Admin approved users are pre-authorized, and pre-authorize a dedicated deploy user's permission set (guides/auth/server-to-server-from-typescript.md). The deploy user needs a full Salesforce licence: an API-only integration licence generally can't deploy flows, permissions or Apex (reference/dx/deploy-errors.md). - GitHub secrets (never in the repo):
SF_JWT_KEY: the contents ofserver.key;SF_CLIENT_ID: the consumer key;SF_USERNAME: the deploy user;SF_INSTANCE_URL: the My Domain URL.
The workflow
name: salesforce
on:
pull_request:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
SF_DISABLE_TELEMETRY: "true"
SF_AUTOUPDATE_DISABLE: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install --global @salesforce/cli
- name: Log in with JWT
run: |
echo "${{ secrets.SF_JWT_KEY }}" > server.key
sf org login jwt --username "${{ secrets.SF_USERNAME }}" --jwt-key-file server.key \
--client-id "${{ secrets.SF_CLIENT_ID }}" --instance-url "${{ secrets.SF_INSTANCE_URL }}" --alias target
rm server.key
- name: Validate (pull requests)
if: github.event_name == 'pull_request'
run: sf project deploy validate --manifest manifest/package.xml --test-level RunLocalTests --target-org target
- name: Quick deploy the validated job (main)
if: github.event_name == 'push'
run: sf project deploy quick --use-most-recent --target-org target || sf project deploy start --manifest manifest/package.xml --test-level RunLocalTests --target-org target
The quick deploy reuses the most recent validation, which is valid for 10 days. If there isn't one, or it has expired, the fallback runs a full deploy with tests. The hub's reading: --use-most-recent picks the org's latest validation, which may not be the merged commit's. Teams that merge often should pass the PR's job ID (--job-id) through an artifact instead.
Agents in CI
- Deploy actions (Apex, flows, prompt templates) before or with the agents that call them, using one manifest (
reference/dx/agentforce-metadata-types.md). - Agent tests (
sf agent test run) need the agent published and activated in the target first. Their failed assertions exit 0, so gate on the results file (guides/agentforce/test-an-agent.md). In CI, "Tests Pass Locally but Fail" is usually a missing JWT login or an inactive agent.
Known-bad
| Setup | What happens |
|---|---|
sf org login web in CI | Needs a browser: hangs or fails. Use JWT |
Username-password OAuth or SOAP login() | Retiring (February 20, 2027 and Summer '27): invalid_grant |
| The key written into the workspace and left there | It can leak into caches or artifacts. Write it, log in, delete it |
--ignore-errors on production | Partial deploys and an inconsistent org |
| A Dev Hub job creating scratch orgs with only an ECA | Scratch org and sandbox creation still needs a connected app on the Dev Hub |