# Deploying Salesforce metadata with GitHub Actions

Canonical: https://shivanshsen.com/blogs/github-actions-salesforce-metadata

Keep the reviewed revision, target org, and deployment evidence together.

A green workflow tells me a command finished. For a Salesforce release, I also need to know which metadata it sent, which org received it, which tests ran, and who approved that change. GitHub Actions can hold those pieces together if the pipeline makes them explicit.

Consider a Salesforce DX repository with sfdx-project.json, force-app, and a reviewed manifest/package.xml. The example below validates metadata in a sandbox. It is a starting point to adapt and test, not evidence of a working production release.

## Separate checks from credentials

Run formatting, static analysis, and local tests on pull requests without org credentials. A forked pull request normally does not receive repository secrets. Preserve that boundary. Do not use pull_request_target to check out and execute untrusted pull request code with privileged credentials.

[GitHub’s guidance on secure workflow use](https://docs.github.com/en/actions/reference/security/secure-use)

A trusted job can validate against an org after review. Keep its target fixed in an environment configuration rather than accepting an arbitrary org from a pull request. Give the workflow contents: read unless a specific step needs more. Serialize release jobs for the same org so competing releases do not overwrite each other.

## Prepare an unattended identity

Use a dedicated integration user with the permissions needed for the selected metadata. Configure an External Client App for OAuth JWT bearer authentication, upload the public certificate, and pre-authorize the user through the app’s policies. Keep the private key in an environment secret and rotate it under your organization’s policy.

New Connected App creation is restricted as of Spring ’26. Existing Connected Apps may still support an established pipeline, but an old tutorial’s “create a Connected App” step needs a current check.

[Salesforce’s current OAuth app guidance](https://developer.salesforce.com/docs/platform/api-rest/guide/intro-oauth-and-connected-apps.html)

The authentication job needs SF_CLIENT_ID, SF_USERNAME, SF_INSTANCE_URL, and SF_PRIVATE_KEY. Confirm the instance URL and user against the intended org before enabling the job. Authentication grants access; it does not prove that the release target is correct.

[Salesforce’s CI authentication setup](https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/authenticate-sforg.html)

## Start with a sandbox validation

This workflow runs only when manually dispatched from main. Before using it, configure the sf-sandbox environment, its secrets and variables, and a tested Salesforce CLI version in SF_CLI_VERSION. The source-format project and manifest must already exist. Pin third-party actions to reviewed full commit SHAs for your release policy; the version tag here keeps the example readable.

```yaml
name: Validate Salesforce sandbox
on: workflow_dispatch
permissions:
  contents: read
concurrency:
  group: salesforce-sandbox
  cancel-in-progress: false
jobs:
  validate:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: sf-sandbox
    timeout-minutes: 90
    env:
      SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
      SF_USERNAME: ${{ secrets.SF_USERNAME }}
      SF_INSTANCE_URL: ${{ secrets.SF_INSTANCE_URL }}
      SF_PRIVATE_KEY: ${{ secrets.SF_PRIVATE_KEY }}
      SF_CLI_VERSION: ${{ vars.SF_CLI_VERSION }}
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Install tested CLI
        run: |
          test -n "$SF_CLI_VERSION"
          npm install --global "@salesforce/cli@$SF_CLI_VERSION"
      - name: Authenticate and validate
        shell: bash
        run: |
          set -euo pipefail
          umask 077
          key_path=$(mktemp)
          trap 'rm -f "$key_path"' EXIT
          printf '%s' "$SF_PRIVATE_KEY" > "$key_path"
          sf org login jwt --client-id "$SF_CLIENT_ID" \
            --username "$SF_USERNAME" --jwt-key-file "$key_path" \
            --instance-url "$SF_INSTANCE_URL" --alias ci-target
          sf project deploy start --dry-run \
            --manifest manifest/package.xml --target-org ci-target \
            --test-level RunLocalTests --wait 60 --json > validation.json
          jq -e '.result.done == true and .result.success == true' validation.json
```

The last check matters: --wait returning control does not guarantee the asynchronous deployment finished. If the operation remains pending, this example fails closed. Retain its job ID and use project deploy report or resume to observe the final result. Archive a reviewed, sanitized validation report and the workflow revision; never upload CLI auth state or the key.

## Validation and deployment are distinct decisions

For a sandbox, the CLI recommends project deploy start --dry-run with an explicit test level. For production, project deploy validate runs tests without deploying and returns a validation ID. A successful, eligible validation can feed project deploy quick --job-id with the same target org.

```bash
sf project deploy validate --manifest manifest/package.xml \
  --target-org production --test-level RunLocalTests --wait 60 --json

# After successful validation and release approval:
sf project deploy quick --job-id "$VALIDATION_ID" \
  --target-org production --wait 60 --json
```

Quick deploy skips rerunning Apex tests and uses the validated payload. The CLI documents a ten-day validation window. Bind the ID to the org, commit, manifest, successful result, and approval. A newer commit needs its own validation; checking out new files does not change the payload behind an older ID.

RunLocalTests excludes tests from installed managed and unlocked packages. RunSpecifiedTests has a different coverage rule: the selected tests must provide at least 75% coverage for each deployed class and trigger individually. Choose the test level deliberately; passing a few named tests does not establish org-wide coverage.

[Salesforce deployment test levels and coverage requirements](https://developer.salesforce.com/docs/platform/salesforce-cli-reference/guide/cli_reference_project_deploy_start.html)

[Salesforce CLI deployment command reference](https://github.com/salesforcecli/plugin-deploy-retrieve)

## Make release approval real

Use a separate production environment for the deployment job, restricted release branches, and available approval controls. Required reviewers and wait timers depend on repository visibility and GitHub plan. Naming an environment “production” alone does not create an approval gate. Verify the repository’s available protections before trusting the workflow.

[GitHub environment availability and protections](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments)

Keep destructive manifests out of this initial example. Review deletions separately, name pre- or post-deployment ordering, check dependencies, and define recovery before using destructive-change flags. A deleted metadata component can have consequences beyond a successful API response.

After release, inspect the final deployment report and check the changed behavior in the target org. Retain the commit, validation and deployment IDs, test evidence, and approval record. The examples here have not authenticated to an org or deployed metadata.

## Sources

- https://docs.github.com/en/actions/reference/security/secure-use

- https://developer.salesforce.com/docs/platform/api-rest/guide/intro-oauth-and-connected-apps.html

- https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/authenticate-sforg.html

- https://github.com/salesforcecli/plugin-deploy-retrieve

- https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments

- https://developer.salesforce.com/docs/platform/salesforce-cli-reference/guide/cli_reference_project_deploy_start.html
