Stop Merging Permission Sets. Start With a Golden Set.
Security metadata is the worst thing to merge
If you have spent any time doing Salesforce deployments through Git, you will have discovered that Permission Sets are a special kind of pain. Two perfectly legitimate changes get merged, Salesforce adds another pile of CRUD into the mix, and you end up trying to work out whether the resulting Permission Set is remotely what anybody intended.
At some point you notice what is actually happening. Merging security metadata does not resolve the disagreement between two branches. It just makes the disagreement happen automatically, at three in the afternoon, on the way to production, with nobody watching.
Sometimes the right answer is not a better merge strategy. It is deciding that there is nothing to merge.
So do not merge them. Keep an approved Golden Set in its own Git branch and make that branch the authority. When Copado creates the promotion branch, intercept it before deployment, replace the Permission Set files with the Golden Set, commit, and let Copado carry on as normal. The critical part is that the overwrite happens before Salesforce ever sees the metadata, so the compiler is never handed two competing versions to reconcile.
Setting it up
First, create a dedicated branch holding the approved Permission Sets. In this example it is called data/profiles, which is a slightly unfortunate name for a branch full of Permission Sets, but it is the one we use so I will keep it honest. Your Salesforce source should already be in source format, so there is nothing to convert before the overwrite.
Then open the Copado Job Template you use for deployment and add a custom step that runs before the Salesforce deployment step. Copado orders job steps numerically, so if your deployment step is order 20, make the Golden Set step order 10.
The step runs this:
#!/bin/bash
set -euo pipefail
PROMOTION_BRANCH=$(git rev-parse --abbrev-ref HEAD)
PROFILE_SOURCE_BRANCH="data/profiles"
PS_PATH="force-app/main/default/permissionsets"
echo "=== Intercepting promotion branch: $PROMOTION_BRANCH ==="
# CI containers often have no committer identity configured.
git config user.email "[email protected]"
git config user.name "Copado Golden Set"
# Get the latest Golden Set
git fetch origin "$PROFILE_SOURCE_BRANCH"
# Clear the directory FIRST so anything not in the Golden Set is removed,
# not merely overwritten. Without this, a Permission Set added in a feature
# branch survives the override and deploys anyway.
git rm -r -q --ignore-unmatch "$PS_PATH"
# Lay down the approved Golden Set
git checkout "origin/$PROFILE_SOURCE_BRANCH" -- "$PS_PATH"
git add -A "$PS_PATH"
# Commit and publish only if something actually changed
if git diff-index --quiet HEAD -- "$PS_PATH"; then
echo "Permission Sets already match the Golden Set."
else
git commit -m "Automated Copado override: enforced Golden Set Permission Sets"
git push origin "HEAD:$PROMOTION_BRANCH"
echo "Permission Sets replaced with the Golden Set."
fi
Two things that will bite you
A plain overwrite is not an overwrite. The obvious version of this script is just git checkout origin/branch -- permissionsets/, and it looks like it does the job. It does not, quite. That command updates the files that exist in the Golden Set and leaves everything else alone. A brand new Permission Set created in a feature branch is not in the Golden Set, so it is not touched, so it survives and deploys.
I tested this rather than assuming it. Start with a Golden Set containing A and B, add a modified A and a new ROGUE in the feature branch, then run the checkout. Afterwards A is correctly reverted to the Golden version, and ROGUE is still sitting there, entirely intact, on its way to production. Which means the Golden Set is not actually the source of truth. It is the source of truth for Permission Sets somebody already thought of.
The git rm -r --ignore-unmatch before the checkout is what closes it. Clear the directory, lay down the approved set, and what deploys is exactly the Golden Set and nothing else.
What this actually changes
The result is deliberately boring. Copado creates the promotion branch, the custom step replaces the Permission Sets with the approved versions, and the normal deployment step takes over. Developers can still change Permission Sets in their working branches, and those changes still get reviewed. What they cannot do any more is become accidental security architecture because two branches happened to merge in a particular order.
The same trick works for Profiles, with the same caveat, and Profiles arguably need it more.
One thing you have to accept
If the Golden Set branch is the authority for who can see and do what in your Salesforce org, then that branch is now your security architecture. Not a deployment convenience. Your access model.
Which means it needs a real gate in front of it: a reviewed pull request, a named approver who understands the access model, and a record of who changed what and why. Put the Golden Set behind a branch anyone can push to and you have not solved the merge problem, you have relocated it somewhere quieter and harder to audit. That is worse, because at least a bad merge is visible in the diff.
Done properly, though, this is one of those rare changes that makes deployment simpler and control tighter at the same time. You stop arbitrating between competing versions of your permission model on every promotion, and you start deploying the one version somebody actually approved.