sf-org-sync/
├── .github/
│ ├── CODEOWNERS # requires review on config/manifest/workflow changes
│ └── workflows/
│ ├── sf-org-sync.yml # the pipeline
│ └── validate-sync-config.yml # validates config/sync-config.yml on every PR
├── config/
│ └── sync-config.yml # the full/partial flag — changed via PR, read by the pipeline
├── force-app/
│ └── main/default/ # retrieved metadata lands here (source format)
├── manifest/
│ ├── package.xml # scope for "partial" syncs
│ ├── package-full.xml # auto-generated by "full" runs
│ └── excluded-metadata.txt # metadata types NEVER touched, even in full mode
├── scripts/
│ └── filter_manifest.py # strips excluded types out of an auto-generated full manifest
├── certs/ # LOCAL ONLY — JWT keys, never committed (see .gitignore)
├── logs/ # deploy result JSON, one per run, committed for audit trail
├── sfdx-project.json
├── .forceignore
├── .gitignore
└── README.md # this file
- Create a self-signed certificate locally (keep the
.keyoff any shared drive):openssl req -x509 -newkey rsa:2048 -keyout certs/server.key -out certs/server.crt -days 365 -nodes - Setup → App Manager → New Connected App → enable OAuth → check "Use digital signatures" → upload
server.crt. - Note the Consumer Key.
- Create a dedicated integration user (not your personal login) with only the permissions retrieve/deploy needs. Assign it the Connected App's permitted profile/permission set.
- Repeat for the other org. You'll end up with two certs, two Connected Apps, two integration users.
Settings → Secrets and variables → Actions → New repository secret:
| Secret | Value |
|---|---|
SOURCE_SF_JWT_KEY |
contents of source server.key (full PEM block) |
SOURCE_SF_CONSUMER_KEY |
source Connected App consumer key |
SOURCE_SF_USERNAME |
source integration user username |
SOURCE_SF_INSTANCE_URL |
e.g. https://yourdomain.my.salesforce.com |
TARGET_SF_JWT_KEY |
contents of target server.key |
TARGET_SF_CONSUMER_KEY |
target Connected App consumer key |
TARGET_SF_USERNAME |
target integration user username |
TARGET_SF_INSTANCE_URL |
target org instance URL |
SLACK_WEBHOOK_URL |
(optional) incoming webhook for failure alerts |
This is split into two independent workflows so a human always reviews what's about to be deployed:
sf-pull-and-open-pr.yml (runs on schedule, 10 AM IST daily)
- Authenticates to the source org.
- Retrieves metadata per
config/sync-config.yml(fullorpartial). - If anything changed, commits it to a new
pull/<mode>-<timestamp>branch and opens a Pull Request againstmain— it does NOT merge anything itself. - If nothing changed, it just logs that and stops. No PR, no noise.
Your team reviews the PR like any other PR — reads the metadata diff, decides what's safe to ship, and either:
- Merges it as-is,
- Requests changes / manually edits the branch to strip out anything risky, then merges,
- Or closes it without merging if none of it should go out.
sf-deploy-on-merge.yml (fires automatically the moment a PR is merged into main)
- Triggers on
pushtomain— but only when files underforce-app/ormanifest/actually changed (a merge that only touches, say,README.mdwon't trigger a deploy). - Waits for approval on the
productionGitHub Environment (same reviewer gate as before). - Runs
sf project deploy validateas a dry run. - Deploys for real, logs the result into
logs/, and pushes that log back tomain. - Posts to Slack on failure.
Net effect: nothing ever reaches the target org without a human having merged a PR that a human could read first. The schedule only controls how often a proposal shows up — not when anything actually deploys.
Two independent flags live in config/sync-config.yml:
retrieve_mode: partial # or: full
manifest_flavor: package # or: custom (only used when retrieve_mode is partial)retrieve_mode: fullignoresmanifest_flavorentirely and auto-generates a manifest from everything in the source org (filtered bymanifest/excluded-metadata.txt).retrieve_mode: partialuses one of two hand-maintained manifests, chosen bymanifest_flavor:package->manifest/package.xml(currently:Payment__cand its fields only)custom->manifest/package-custom.xml(currently:Payment__cANDAccount, plus their fields)
To change either flag, or to change what's actually inside package.xml / package-custom.xml (e.g. adding more objects to the custom manifest):
- Open a PR editing
config/sync-config.ymland/or the manifest file(s) directly. validate-sync-config.ymlchecks both flag values are valid and comments on the PR with what will result.CODEOWNERSrequires review before merge.- Once merged, the next scheduled pull (or a manual run left on
use-config) picks up whatever is now onmain— both which manifest is used AND what's inside it.
A manual workflow_dispatch run can still override retrieve_mode for a one-off test without touching the config file, but manifest_flavor is always read from the config file directly (there's no dropdown override for it, since it's expected to change less often than a one-off test run would need).
- GitHub Environment gate: the job specifies
environment: production. Go to Settings → Environments → New environment → name itproduction→ add required reviewers. This means every run (including the scheduled one) pauses and waits for a human to click "Approve" before it touches your target org. Remove this if you want it fully unattended — but for a production target, keep it. - Validate before deploy:
sf project deploy validateruns as a dry-run before the real deploy, so a bad deployment is caught without touching the org. - Full-mode filtering:
scripts/filter_manifest.pystrips anything listed inmanifest/excluded-metadata.txt(Profiles, PermissionSets, sharing rules, security settings, etc.) out of an auto-generated full manifest before it's ever retrieved or deployed. Edit that file to tune what's excluded. - Concurrency lock: only one sync can run at a time, so a manual trigger can't collide with the scheduled run.
- Branch-per-run audit trail: every pull and every deploy gets its own branch (
pull/partial-20260709-1030,deploy/full-20260709-1030) before merging intomain, so you can always trace exactly what changed and when. - Deploy logs committed: each deploy's JSON result lands in
logs/, committed tomain, so deploy IDs and test results are searchable in git history. - Slack alert on failure: any step failing posts a link to the failed run.
Settings → Branches → Add rule for main:
- Require status checks to pass (once you add a lint/validate-only job for PRs).
- Restrict who can push directly — the bot pushes via
contents: writepermission on theGITHUB_TOKEN, which is fine; just make sure no human pushes untested changes straight tomain.
- Run the workflow manually (
workflow_dispatch) withretrieve_mode: partialagainst a sandbox target first, not production. - Approve the environment gate when prompted.
- Confirm the
pull/anddeploy/branches appear, and thatlogs/has a deploy result committed. - Only then point
TARGET_SF_INSTANCE_URLat production and let the cron schedule take over.
- Add new metadata types to
manifest/package.xmlas your org grows — this is the file that governs the day-to-day scheduled sync. - Review
manifest/excluded-metadata.txtwhenever you run afullsync for the first time in a new org, since orgs vary in what metadata types are risky to move unattended. - Rotate the JWT certificates annually (or per your org's security policy) and update the GitHub secrets accordingly.