4 of 4
production deploys succeeded on 14 Oct 2026, the workflow's first day
57–75 s
per deploy run, from push to main moving
14
metadata files across those deploys; the one with Apex ran local tests
Source: GitHub Actions run history and git log of our Salesforce repository, read 16 October 2026.
This is the release path for our own production Salesforce org, the one that runs our lead handling and reporting. We switched to it on 14 October 2026, and the first change through it was a description edit on one custom field. Everything below comes from that repository: the workflow, its run history, and the heavier pipeline it replaced. The YAML is further down, with our org's values swapped for placeholders.
What do you need before the first deploy?
Six parts, and only the GitHub secrets row holds secret values. Our repository is private on GitHub's free plan.
| Part | What we use | Why it is there |
|---|---|---|
| Source format | An SFDX project: sfdx-project.json and a force-app folder holding the metadata we author | The workflow deploys files by path, so the repository must hold source-format metadata |
| Release branch | integration, cut from main; main always equals Production | People push to one branch, and main stays an exact copy of what Production runs |
| Deployment identity | An External Client App using the OAuth 2.0 client credentials flow, running as a dedicated deployment user | No password and no stored refresh token or auth URL; Salesforce issues no refresh token in this flow |
| GitHub secrets | SF_PROD_CLIENT_ID and SF_PROD_CLIENT_SECRET, the app's consumer key and secret | The only secret values in the setup |
| GitHub variables | SF_PROD_INSTANCE_URL, SF_PROD_ORG_ID, SF_PROD_USERNAME | The workflow refuses to deploy unless the signed-in org and user match all three |
| Runner and CLI | GitHub-hosted ubuntu-latest, Salesforce CLI pinned to 2.150.6 | A pinned CLI changes only when someone changes the file |
The push-to-deploy procedure, in six steps
Sandbox
Build and check
Click through the change as its users would, and list every component as Type:ApiName.
GitHub
Commit and push
Commit only the files you changed and push them to the release branch.
GitHub Actions
Deploy job
Diff against main and refuse deletions · sign in and check the org ID and user · deploy the changed files, with local tests if Apex changed · query the org again.
Production
Deployed
main moves to the pushed commit, so main equals Production again.
Any failed step: Salesforce rolls the whole deploy back, main stays where it was, and the run summary says which check stopped it. Fix the change in the sandbox and push again.
- Build it in a sandbox. Ours is a Partial Copy named UAT. Click through the change as the people who will use it, and write down every component you touched as
Type:ApiName, test classes included. - Bring exactly those files into the repository. Retrieve them by name, for example
sf project retrieve start --metadata ApexClass:InvoiceService --metadata ApexClass:InvoiceServiceTest --target-org uat, or commit the source you wrote locally. A release holds only what changed, so never retrieve the whole org into one. - Commit on a branch cut from main. One change per commit.
- Push to the release branch. The push is the release decision, and on our team one person makes it.
- Let the workflow deploy. It refuses a push that has drifted from main or deletes metadata, signs in, checks the org ID and username, deploys the changed files with
RunLocalTestswhen Apex is among them, and queries the org again to confirm it is Production. - Record the result. main now points at the deployed commit. Put the commit SHA and the Salesforce deploy ID from the run summary on the ticket. If the run failed, Production and main are both unchanged: fix the change in the sandbox and push again.
A rollback takes the same path: a commit that restores the previous file. Claude drafted this workflow and the first dashboard change in our repository, and a person pushed both.
Why is the deploy the test?
Salesforce already checks everything a deploy touches: it compiles Apex and validates each component and its references. For a production org the deploy option rollbackOnError must be true, so one failure rolls back the whole deploy (Metadata API deploy() reference, read 16 October 2026). A failed production deploy leaves Production as it was.
Tests follow the package. When a production deploy contains Apex classes or triggers and sets no test level, Salesforce runs every local test; when it contains no Apex, it runs none (Running Tests in a Deployment, Winter '27 docs). Our workflow passes RunLocalTests when Apex changed and no test level otherwise. Apex still needs at least 75% test coverage, with every test passing, before Salesforce accepts it (Apex Developer Guide).
That is why we dropped the separate validation before merge. Our earlier pipeline ran the same checks three times for each change: a dry run against Production before merge, a second dry run after merge, then the deploy. With one deploy, a failure costs one fix-forward push.
Which workflows did we try, and which one runs now?
We built the heavy version first. In August 2026 the repository carried six workflows, a design with five GitHub Environments for approvals, a separate UAT branch, and content hashes carried between every stage. Here is each workflow with its run history from GitHub, read 16 October 2026.
| Workflow | Trigger | What it does | Why we had it | Runs |
|---|---|---|---|---|
| Deploy to Production (in use) | Push to integration | Deploys the changed files, checks the org, fast-forwards main | The release path today | 4 pushes on 14 Oct, all succeeded |
| AI metadata review | Pull request to main or the UAT branch | Builds a sanitized inventory of the changed metadata; no Salesforce access | Let an AI reviewer read a change without credentials or raw sensitive metadata | 40, all succeeded |
| Production PR gate | Pull request to main | Dry run against Production before merge, revalidate after merge, wait for approval, deploy | Validate before anyone merges | 57: 38 succeeded, 11 failed, 8 skipped |
| Partial Copy UAT gate | Pull request to the UAT branch | Dry run against the UAT sandbox, approved deploy, recorded UAT sign-off | Make UAT a required stop on the way to Production | 10: none succeeded, 6 failed, 4 skipped |
| Developer sandbox lifecycle | Manual, with a typed confirmation | Creates, resumes or deletes one Developer sandbox per ticket | Give each feature its own org | 0 |
| Developer sandbox feature deploy | Manual, pinned to one commit | Deploys that commit to its Developer sandbox | Prove the exact commit before UAT | 0 |
| Supervised UAT exception | Manual, pinned to one pull request | Validates or deploys that one change to UAT, checked against its commit, file count and content hashes | Move one stuck change into UAT while the UAT gate could not pass | 4: 2 succeeded, 2 failed |
After the August pipeline went in, 13 more pull requests merged into main, none with Salesforce metadata; they were the pipeline's own fixes and documentation. The one metadata change that entered it, 48 files of lead conversion tracking, reached UAT through the supervised exception and never moved on to main. The GitHub plan was part of the reason: our repository is private on GitHub Free, where environments exist only for public repositories (GitHub Docs), and the API answers a branch protection request with “Upgrade to GitHub Pro or make this repository public”. The approvals the design relied on could never be switched on.
In September we designed two lighter actions, Ship to Prod and Sync from Prod: a form that pulled named components from UAT into a pull request, and a daily read-only retrieve that opened a pull request when Production had been edited by hand. We closed that pull request on 14 October without running it. The AI review workflow runs only on pull requests, so it sits idle, and the deploy credential still lives only in GitHub's secret store.
The workflow file you can copy
This is our deploy workflow with every org-specific value replaced. Save it as .github/workflows/deploy.yml in a repository whose force-app folder holds your metadata. The one value to type in is YOUR_MY_DOMAIN_HOST; the branch name sits next to a RELEASE_BRANCH comment, and secrets and variables appear by name only.
name: Deploy to Production
# A push to the release branch is the release. The job deploys the force-app
# files that changed since main, then fast-forwards main to the pushed commit.
# If anything fails, main does not move, so main always equals Production.
#
# Apex in the change: RunLocalTests. No Apex: no test level is passed, and
# Salesforce's production default for a package without Apex runs no tests.
# Deletions and renames are refused. Remove metadata by hand, then commit.
on:
push:
branches:
- integration # RELEASE_BRANCH: the branch your team pushes releases to
permissions:
contents: write # lets the last step fast-forward main
concurrency:
group: deploy-production
cancel-in-progress: false
env:
SF_CLI_VERSION: "2.150.6" # pin a version you have tested
EXPECTED_HOST: "YOUR_MY_DOMAIN_HOST" # PLACEHOLDER: host from Setup > My Domain, no https://
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout the pushed commit
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Work out what changed since main
id: scope
shell: bash
run: |
set -euo pipefail
git fetch --quiet origin main
if ! git merge-base --is-ancestor origin/main "$GITHUB_SHA"; then
echo "::error::The release branch has diverged from main. Rebase onto origin/main and push again."
exit 1
fi
# --no-renames turns a rename into a delete plus an add, so it is refused here too.
deleted="$(git diff --no-renames --name-only --diff-filter=D origin/main "$GITHUB_SHA" -- force-app || true)"
if [[ -n "$deleted" ]]; then
echo "::error::This push deletes or renames metadata. Destructive changes are manual:"
printf '%s\n' "$deleted"
exit 1
fi
git diff --no-renames --name-only --diff-filter=AM origin/main "$GITHUB_SHA" -- force-app > "$RUNNER_TEMP/changed.txt"
count="$(wc -l < "$RUNNER_TEMP/changed.txt" | tr -d ' ')"
has_apex=false
if grep -qE '\.(cls|trigger)(-meta\.xml)?$' "$RUNNER_TEMP/changed.txt"; then has_apex=true; fi
echo "has_metadata=$([[ "$count" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "has_apex=$has_apex" >> "$GITHUB_OUTPUT"
{
echo "### Scope"
echo
echo "Deploying $count file(s). Apex tests: $has_apex"
sed 's/^/- /' "$RUNNER_TEMP/changed.txt"
} >> "$GITHUB_STEP_SUMMARY"
- name: Install the pinned Salesforce CLI
if: steps.scope.outputs.has_metadata == 'true'
run: npm install --global "@salesforce/cli@$SF_CLI_VERSION"
- name: Authenticate to Production with client credentials
id: authenticate
if: steps.scope.outputs.has_metadata == 'true'
shell: bash
env:
SF_CLIENT_ID: ${{ secrets.SF_PROD_CLIENT_ID }}
SF_CLIENT_SECRET: ${{ secrets.SF_PROD_CLIENT_SECRET }}
SF_INSTANCE_URL: ${{ vars.SF_PROD_INSTANCE_URL }}
EXPECTED_ORG_ID: ${{ vars.SF_PROD_ORG_ID }}
EXPECTED_USERNAME: ${{ vars.SF_PROD_USERNAME }}
run: |
set -euo pipefail
url="${SF_INSTANCE_URL%/}"
# The repository variable must name the org this file was written for.
[[ "$url" == "https://$EXPECTED_HOST" ]] || {
echo "::error::SF_PROD_INSTANCE_URL does not match EXPECTED_HOST."
exit 1
}
umask 077
jq -nr --arg id "$SF_CLIENT_ID" --arg secret "$SF_CLIENT_SECRET" \
'"grant_type=client_credentials&client_id=\($id|@uri)&client_secret=\($secret|@uri)"' \
> "$RUNNER_TEMP/token-request"
status="$(curl --silent --show-error --proto '=https' \
--output "$RUNNER_TEMP/token.json" --write-out '%{http_code}' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-binary "@$RUNNER_TEMP/token-request" \
"$url/services/oauth2/token")"
rm -f "$RUNNER_TEMP/token-request"
[[ "$status" == "200" ]] || { echo "::error::Salesforce rejected the token request."; exit 1; }
token="$(jq -r '.access_token // empty' "$RUNNER_TEMP/token.json")"
rm -f "$RUNNER_TEMP/token.json"
[[ -n "$token" ]] || { echo "::error::No access token in the response."; exit 1; }
echo "::add-mask::$token"
SF_ACCESS_TOKEN="$token" sf org login access-token \
--instance-url "$url" --alias prod --no-prompt --json > /dev/null
unset token
sf org display --target-org prod --json > "$RUNNER_TEMP/org.json"
jq -e --arg id "$EXPECTED_ORG_ID" --arg user "$EXPECTED_USERNAME" --arg url "$url" \
'.result.id == $id and .result.username == $user and (.result.instanceUrl | rtrimstr("/")) == $url' \
"$RUNNER_TEMP/org.json" > /dev/null || {
echo "::error::Signed in to the wrong org or as the wrong user."
exit 1
}
- name: Deploy the changed files
if: steps.scope.outputs.has_metadata == 'true'
shell: bash
env:
HAS_APEX: ${{ steps.scope.outputs.has_apex }}
run: |
set -euo pipefail
result="$RUNNER_TEMP/deploy.json"
source_args=()
while IFS= read -r path; do source_args+=(--source-dir "$path"); done < "$RUNNER_TEMP/changed.txt"
test_args=()
if [[ "$HAS_APEX" == "true" ]]; then test_args=(--test-level RunLocalTests); fi
set +e
sf project deploy start "${source_args[@]}" "${test_args[@]}" \
--target-org prod --wait 240 --json > "$result" 2>/dev/null
cli_status=$?
set -e
if (( cli_status != 0 )) || ! jq -e '.status == 0 and .result.success == true' "$result" > /dev/null; then
# Counts only: full errors are in Setup > Deployment Status, not in a log.
{
echo "**Deploy failed. Production is unchanged and main did not move.**"
echo
echo "Component errors: $(jq -r '.result.numberComponentErrors // "unknown"' "$result")"
echo "Test failures: $(jq -r '.result.numberTestErrors // "unknown"' "$result")"
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::Production deployment failed."
exit 1
fi
{
echo "**Deployed.** Deploy ID $(jq -r '.result.id' "$result")"
echo "Components: $(jq -r '.result.numberComponentsDeployed // 0' "$result"), tests run: $(jq -r '.result.numberTestsCompleted // 0' "$result")"
} >> "$GITHUB_STEP_SUMMARY"
- name: Confirm the deploy landed in Production
if: steps.scope.outputs.has_metadata == 'true'
shell: bash
env:
EXPECTED_ORG_ID: ${{ vars.SF_PROD_ORG_ID }}
run: |
set -euo pipefail
sf data query --target-org prod \
--query "SELECT Id, IsSandbox FROM Organization LIMIT 1" \
--json > "$RUNNER_TEMP/org-check.json"
jq -e --arg id "$EXPECTED_ORG_ID" \
'.result.records[0].Id == $id and .result.records[0].IsSandbox == false' \
"$RUNNER_TEMP/org-check.json" > /dev/null || {
echo "::error::Post-deploy identity check failed."
exit 1
}
- name: Log out of Production
if: always() && steps.authenticate.outcome != 'skipped'
run: sf org logout --target-org prod --no-prompt > /dev/null 2>&1 || true
- name: Fast-forward main to this commit
shell: bash
run: |
set -euo pipefail
# If main is a protected branch, allow this workflow to push to it.
git push origin "${GITHUB_SHA}:refs/heads/main"
echo "main is now $GITHUB_SHA." >> "$GITHUB_STEP_SUMMARY"It differs from the file in our repository in three places. Ours calls a shared script for sign-in and a sanitizer for failure output; here both are inline, and the failure summary prints counts only. Ours pins the instance URL as a literal string; here that is the EXPECTED_HOST line. And this version adds git's --no-renames flag, so a renamed file counts as a deletion and is refused. Without it, a rename deploys the new name and leaves the old component in Production; ours does not have the flag yet.
How do you set it up in your own org?
- Create the deployment user. A dedicated Production user that only the workflow uses, with a permission set granting API Enabled, View Setup and Configuration, Modify Metadata Through Metadata API Functions, and Author Apex, the set our own deployment user has.
- Create an External Client App. Turn on OAuth and the client credentials flow, and set the deployment user as its run-as user. Salesforce documents the flow for all editions and warns that anyone holding the consumer key and secret can get a token, so rotate the secret on a schedule (Salesforce Help, read 16 October 2026). Because nobody signs in through the login page, Salesforce lists this flow as unaffected by its passkey requirement for admins.
- Add two repository secrets.
SF_PROD_CLIENT_IDholds the consumer key andSF_PROD_CLIENT_SECRETthe consumer secret. This path needs no SFDX auth URL secret, so do not create one. - Add three repository variables.
SF_PROD_INSTANCE_URLis your My Domain URL,SF_PROD_ORG_IDthe 18-character org ID, andSF_PROD_USERNAMEthe deployment user. Put the same host inEXPECTED_HOSTin the workflow file. - Commit a baseline. Retrieve the metadata your team authors from Production into
force-appand commit it to main, so the first diff is your first real change. Keep profiles, org settings, connected apps, named credentials and certificates out with.forceignore; we never deploy those from git. - Create the release branch and decide who can push. On a private repository on GitHub Free, write access is the control, because branch protection is listed as a private-repository feature of the paid plans (GitHub's plans). On Pro or Team, protect the release branch, require a reviewed pull request, and allow the workflow to push to main.
- Name the sandboxes so nobody has to guess. One build sandbox per purpose, its name in the CLI alias (ours is
uat), and credentials that only work there. The production workflow's host and org checks name one org. - Ship something harmless first. Our first push changed one field description. Read the run summary, then check the change in Setup.
What does this pipeline not do?
- Delete or rename metadata. The workflow refuses both. Removals are a manual, reviewed destructive deployment, tried in UAT first, with git updated to match afterwards.
- Notice hand edits in Production. Nothing compares Production with main on a schedule, so a change made directly in Setup leaves main wrong until someone retrieves it. If you add one thing to this file, make it a scheduled read-only retrieve that opens a pull request when Production differs from main, the job our Sync from Prod design did.
- Review the change for you. On our team the person who pushes is the reviewer. A second reviewer means a pull request in front of the release branch.
- Deploy profiles, settings or credentials. Those stay out of the repository, and the same least-privilege rules we use for AI access apply to the deployment user.
What does it cost compared with Gearset, Copado and DevOps Center?
Published list prices, read on 16 October 2026. Seat counts and contracts change what you pay.
| Option | Published price | What the vendor page lists |
|---|---|---|
| GitHub Actions (our setup) | 2,000 included minutes a month for private repositories on GitHub Free; standard runners are free for public repositories | Our four production deploys used about 4 minutes; all 118 workflow runs since 15 August took about 127 minutes of run time |
| Gearset | Core: Starter $215 and Teams $320 per Gearset user per month. Automation: Starter $550 and Teams $1,100 per team per month | Compare and deploy, deployment history; rollback on Teams; CI/CD and pipelines in the automation tiers |
| Copado | No price published; Essentials is listed as free to get started, Enterprise by consultation | Enterprise lists native CI/CD, robotic testing and AI automation |
| Salesforce Lifecycle Center (DevOps Center until October 2026) | No price on the Help page; listed for Enterprise, Performance, Professional, Unlimited and Developer Editions | Change and release management on source control, as an alternative to change sets |
Sources: GitHub Actions billing, Gearset pricing, Copado pricing, Salesforce Help on Lifecycle Center, all read 16 October 2026; our run times from GitHub's run history.
The table leaves out the expensive part, which is people. A paid tool buys a rollback screen, change monitoring and a support line; the GitHub route buys a file you own and maintain. If nobody on your team will read a failed run summary, buy the tool. If you want the file and someone to own it with you, that is the release work in our managed services model.
Frequently asked questions
Can GitHub Actions deploy to Salesforce without storing a password or auth URL?
Yes. With the OAuth 2.0 client credentials flow, the workflow trades an External Client App's consumer key and secret for an access token on every run. Salesforce issues no refresh token in this flow, the token is never printed, and the job logs out of the org when it finishes. The only stored secrets are the consumer key and secret, in GitHub's secret store.
Do you need a pull request to deploy Salesforce from GitHub?
No. In our setup the push to the release branch is the release, which suits a team where one person makes the release call. When more than one person ships, put a pull request with a required review in front of the release branch. On a private repository that takes GitHub Pro or Team, because branch protection is a paid feature there.
Does a Salesforce deploy from GitHub Actions run Apex tests?
Only when the deploy asks for them or contains Apex. Our workflow passes RunLocalTests when an Apex class or trigger changed. With no test level set, Salesforce runs all local tests for a production deploy that contains Apex and none for one that does not.
How do you roll back a Salesforce deployment made from GitHub?
Push a commit that restores the previous version of the files; it deploys like any other change. A failed deploy needs no rollback, because a production deploy rolls back completely on any error. Removing components is the exception: deletions are a manual, reviewed step.
What happened to Salesforce DevOps Center?
Salesforce is renaming it Lifecycle Center from October 2026, and the managed-package DevOps Center keeps working. Salesforce Help lists Lifecycle Center for Enterprise, Performance, Professional, Unlimited and Developer Editions as an alternative to change sets. A GitHub Actions workflow suits teams who already work in git and want the release rules in a file they can read.