---
cuppy_hub: salesforce
cuppy_path: patterns/github-actions-deploy-with-jwt.md
cuppy_section: docs
cuppy_doc: PZ12-5NJH
cuppy_doc_uuid: 68e6d929-be8f-459d-8746-5f82387ab207
cuppy_permalink: https://cuppy.io/salesforce/d/PZ12-5NJH
cuppy_version: 1
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/salesforce/docs/patterns/github-actions-deploy-with-jwt
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: fbded432702c05df847970b729b1fedda4678517e940bcfd0a4de9ac42132f8e
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# GitHub Actions to Salesforce with JWT

## Set up once

1. **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`).
2. **GitHub secrets** (never in the repo):
   - `SF_JWT_KEY`: the contents of `server.key`;
   - `SF_CLIENT_ID`: the consumer key;
   - `SF_USERNAME`: the deploy user;
   - `SF_INSTANCE_URL`: the My Domain URL.

## The workflow

```yaml
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 |


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 376 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/salesforce/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
