
On a developer Mac, the first time codesign reaches into the login keychain for a signing identity, macOS asks whether /usr/bin/codesign is allowed to use that private key and the developer clicks Always Allow. On a headless CI Mac there is no desktop session to show the dialog: the step waits for an answer that never arrives, the runner eventually kills it on timeout, and the log shows no explanation.
The fix is not a flag on xcodebuild. It is two keychain-level facts: the signing key has to live in an unlocked keychain that is on codesign's search path, and the key's access control list has to already name the tools that may use it. The security CLI does all of that non-interactively. A pre-build script creates a throwaway keychain, imports the .p12, calls set-key-partition-list to grant the Apple toolchain partition access, and deletes the keychain when the job exits. Provisioning profiles are a separate file install; they are not stored in Keychain.
A good pre-authorization suppresses the prompt for codesign and xcodebuild specifically. It is not a general promise that no keychain prompt ever appears from any tool — a different binary that is not listed in -T or that does not belong to the Apple partition will still trip the check.
security subcommand | Role in the flow |
|---|---|
create-keychain -p PASS NAME | Make an empty keychain file |
set-keychain-settings -t 7200 -u NAME | Idle-lock after 7200 s (2 h); omit -l so sleep does not lock |
unlock-keychain -p PASS NAME | Open it for the job |
list-keychains -d user -s NAME ... | Set the user search path; must include previously present keychains |
import FILE -k NAME -P P12PASS -T /usr/bin/codesign -T /usr/bin/xcodebuild | Load the .p12 and name trusted tools |
set-key-partition-list -S apple-tool:,apple: -s -k PASS NAME | Grant Apple toolchain ACL on the just-imported key |
delete-keychain NAME | Remove the keychain at teardown |
Everything below runs inside a single job and a single shell step. That matters: a separate step is a separate shell and loses the mktemp path, and a trap only fires in the shell that installed it. The script derives its keychain path from mktemp -d, so uniqueness comes from the filesystem and not from GITHUB_RUN_ID — that variable is shared across the legs of a matrix and repeats on reruns, so it is not a unique keychain name. The user keychain search list is captured with Python shlex so quoted paths round-trip correctly, and the captured state is restored on exit whether or not it was empty.
Review the script against your own project and your own secret manager before trusting it. It has not been executed end-to-end as part of this article.
#!/usr/bin/env bash
# keychain-setup.sh -- headless Mac CI signing keychain, single job.
set -euo pipefail
umask 077
# 0. Validate every required input BEFORE touching any Mac state.
: "${DISTRIBUTION_CERT_P12_BASE64:?required}"
: "${DISTRIBUTION_CERT_P12_PASSWORD:?required}"
: "${CI_KEYCHAIN_PASSWORD:?required}"
: "${WORKSPACE_PATH:?required}"
: "${SCHEME_NAME:?required}"
: "${EXPORT_OPTIONS_PLIST:?required}"
# 1. Per-run paths. mktemp -d is the source of uniqueness, not GITHUB_RUN_ID.
WORKDIR="$(mktemp -d -t ci-sign)"
KEYCHAIN="${WORKDIR}/ci.keychain-db"
P12="${WORKDIR}/cert.p12"
# 2. Snapshot the existing user search list; shlex handles the quoted paths
# security prints (and preserves spaces inside Library/Keychains paths).
security list-keychains -d user > "$WORKDIR/keychains.txt"
/usr/bin/python3 -c 'import shlex,sys; print("\n".join(shlex.split(open(sys.argv[1]).read())))' \
"$WORKDIR/keychains.txt" > "$WORKDIR/search-path.txt"
PREV_LIST=()
while IFS= read -r entry; do
[ -n "$entry" ] && PREV_LIST+=("$entry")
done < "$WORKDIR/search-path.txt"
# Refuse an unusual empty search list before changing global user settings.
[ "${#PREV_LIST[@]}" -gt 0 ] || { rm -rf "$WORKDIR"; exit 1; }
cleanup() {
# Always restore the captured list. If it was empty, reset the user domain
# to the system default rather than leave our keychain in place.
if [ "${#PREV_LIST[@]}" -gt 0 ]; then
security list-keychains -d user -s "${PREV_LIST[@]}" >/dev/null 2>&1 || true
else
security list-keychains -d user -s >/dev/null 2>&1 || true
fi
security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true
rm -rf "$WORKDIR"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# 3. Create, configure, open.
security create-keychain -p "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -t 7200 -u "$KEYCHAIN"
security unlock-keychain -p "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN"
# 4. Prepend our keychain; keep every entry that was there before.
if [ "${#PREV_LIST[@]}" -gt 0 ]; then
security list-keychains -d user -s "$KEYCHAIN" "${PREV_LIST[@]}"
else
security list-keychains -d user -s "$KEYCHAIN"
fi
# 5. Decode the base64 p12 with a bash builtin so it never appears in ps argv.
printf '%s' "$DISTRIBUTION_CERT_P12_BASE64" | base64 --decode > "$P12"
# 6. Import FIRST, then set the key ACL. set-key-partition-list configures an
# existing key; running it before import has nothing to configure.
security import "$P12" \
-k "$KEYCHAIN" \
-P "$DISTRIBUTION_CERT_P12_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/xcodebuild
rm -f "$P12"
security set-key-partition-list \
-S apple-tool:,apple: -s \
-k "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
# 7. Build in the same shell so the trap can tear down on failure.
security find-identity -v -p codesigning "$KEYCHAIN"
xcodebuild archive \
-workspace "$WORKSPACE_PATH" \
-scheme "$SCHEME_NAME" \
-configuration Release \
-archivePath build/MyApp.xcarchive
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-exportPath build/
The traps on EXIT, INT, and TERM cover normal exit, errors under set -e, and the signals a GitHub Actions or Buildkite cancellation typically sends to the step process. They cannot cover SIGKILL, loss of the host, power-off of a VM, or every cancellation path a CI UI chooses — that is a limit of trap-based cleanup, not of this script. Clean up abandoned state by hand on persistent agents if you see it, but do not script a cron sweep that deletes other ci-*.keychain-db files: from outside the owning process you cannot tell an orphan from a running job.
Concurrency: the user keychain search list is a user-global resource. Two jobs running under the same macOS user still race on security list-keychains -d user -s ... even though each keychain file is unique. The simplest guarantee is one signing job per macOS user. If you must run more than one at a time, wrap the whole script in a lock (for example a flock-equivalent held for the duration of the job) and accept that a dead host can leave the lock behind. Do not treat unique keychain names as concurrency safety.
Before you merge the pipeline change, run these three commands against a disposable test project on the same CI host:
env DISTRIBUTION_CERT_P12_BASE64=... DISTRIBUTION_CERT_P12_PASSWORD=... \
CI_KEYCHAIN_PASSWORD="$(uuidgen)" WORKSPACE_PATH=Probe.xcworkspace \
SCHEME_NAME=Probe EXPORT_OPTIONS_PLIST=ci/ExportOptions.plist \
bash ./scripts/keychain-setup.sh
security list-keychains -d user # should match pre-run list
ls /tmp/ci-sign.* # should print nothing
| Symptom | Likely cause |
|---|---|
security find-identity prints 0 valid identities | Wrong .p12 passphrase, a non-codesigning certificate, or a revoked identity; verify in the Apple Developer portal |
errSecInternalComponent during codesign | set-key-partition-list was skipped or run before import |
| Prompt still appears on first signing | The imported key's ACL does not include the tool running; add it with -T |
| Other developer keychains disappeared on a persistent agent | The script was run without preserving PREV_LIST; restore from a security list-keychains snapshot |
GitHub Actions needs the whole thing in one job and one step so the trap owns the process. Pass secrets through the step env: block only; do not export them in an interactive shell where they land in history.
jobs:
sign-and-archive:
runs-on: self-hosted
env:
DISTRIBUTION_CERT_P12_BASE64: ${{ secrets.DISTRIBUTION_CERT_P12_BASE64 }}
DISTRIBUTION_CERT_P12_PASSWORD: ${{ secrets.DISTRIBUTION_CERT_P12_PASSWORD }}
CI_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }}
WORKSPACE_PATH: MyApp.xcworkspace
SCHEME_NAME: MyApp
EXPORT_OPTIONS_PLIST: ci/ExportOptions.plist
steps:
- uses: actions/checkout@v4
- name: Sign, archive, and export
run: bash ./scripts/keychain-setup.sh
Replace WORKSPACE_PATH and SCHEME_NAME with whatever xcodebuild -list prints for your repository. Provisioning profiles install as files; per the Xcode 16 release notes, Xcode 16 stores downloaded profiles at ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ and still loads previously downloaded profiles from the previous location, so copy your profile into the new path and keep the older location around if you reuse a build image across Xcode versions.
If your team already uses Fastlane Match, delete steps 5 and 6 from the script and let Match install the certificate and the profile. Set MATCH_KEYCHAIN_NAME to $KEYCHAIN and MATCH_KEYCHAIN_PASSWORD to $CI_KEYCHAIN_PASSWORD, and run with readonly: true on CI so a build cannot create or revoke anything. Match configures the partition list itself unless you pass skip_set_partition_list: true; if you do skip it, run the security set-key-partition-list call after Match has imported the key, not before — ACL updates need a key to update.
The $29/month virtual Intel macOS plan (2 vCPU, 8 GiB RAM, 80 GiB disk) is a real macOS environment, and a compatible Intel Xcode runs codesign, xcodebuild archive, and xcodebuild -exportArchive from this script against supported targets. Per Apple's porting guidance, use Xcode 26 or earlier on an Intel processor; Xcode 27 requires Apple silicon, and ARM execution or visionOS tooling needs an M-series host. The VM runs under Linux/KVM, has no GPU acceleration and no audio, and is reached through a browser desktop console. Inbound SSH to the $29 VM is not currently offered; outbound CI agents (GitHub, Buildkite, GitLab) register normally. VM capacity is being verified, so delivery is confirmed in the quote.
Figment M-series Macs start at $95/month; the exact chip, RAM, and disk at the entry tier are confirmed in the quote, not fixed on this page. Choose M-series for Xcode 27, native ARM execution, simulators that require Apple silicon, visionOS, or sustained release-pipeline throughput. Managed Build Mac is $249/month with one M-series Mac, CI setup, backups, monitoring, and 30 minutes of monthly support. Managed Mac Team is $999/month with two M-series Macs, two CI runners, team setup, backups, monitoring, and one hour of monthly support. Physical M-series capacity is request-queued; confirm configuration and timing in the quote before a cutover.
See the full catalog on Figment Mac hosting, then start your Figment Mac for headless iOS CI.
Related guides: