ENGINEERING NOTE

Preflight iOS entitlements and signatures on a cloud Mac

Preflight iOS entitlements and signatures on a cloud Mac

An archive can succeed in Xcode yet fail during upload because the push environment, associated domains, or application identifier does not match. The problem is usually not compilation itself, but a lack of alignment among three sources: the entitlements declared by the project, the entitlements permitted by the provisioning profile, and the entitlements written into the final App signature. Because scripts routinely reuse cloud Macs, a single node may process multiple branches and environments. Checking the .entitlements file in the repository is therefore not enough; the preflight must inspect the .xcarchive that is about to be delivered.

Define the artifact to inspect

This workflow takes an existing archive as its input. Start by locating the main App. Do not inspect temporary products in DerivedData directly, because they may not have received their final signature yet.

ARCHIVE_PATH="$HOME/builds/MyApp.xcarchive"
APP_PATH="$ARCHIVE_PATH/Products/Applications/MyApp.app"

test -d "$APP_PATH"
test -f "$APP_PATH/embedded.mobileprovision"
codesign --verify --strict --verbose=2 "$APP_PATH"

If the App contains extensions, validate every .appex separately. A successful check of the main App does not guarantee that a notification extension, Widget, or sharing extension uses a compatible application identifier and set of entitlements.

find "$APP_PATH/PlugIns" -type d -name '*.appex' -print0 2>/dev/null |
while IFS= read -r -d '' item; do
  codesign --verify --strict --verbose=2 "$item"
done

Preflight decisions should be based on the artifact that will actually be uploaded, not on whether the project appears to be configured correctly. The former is the result; the latter is only an input.

Extract the two effective entitlement sets

codesign reads the entitlements in the final signature, while security cms decodes the provisioning profile embedded in the App. Save both as plist files so that later comparisons do not depend on the Xcode interface.

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

codesign -d --entitlements :- "$APP_PATH" \
  > "$WORK_DIR/app-entitlements.plist" 2>/dev/null

security cms -D -i "$APP_PATH/embedded.mobileprovision" \
  > "$WORK_DIR/profile.plist"

plutil -lint "$WORK_DIR/app-entitlements.plist"
plutil -lint "$WORK_DIR/profile.plist"

Do not compare the App directly with top-level fields in the provisioning profile. The permitted entitlements are stored in the Entitlements dictionary. Fields such as the creation date and name can help identify a profile, but they do not represent the signing result.

Compare critical fields automatically

The script below checks five common classes of failure. String values support the * wildcard ranges used by provisioning profiles. For array entitlements, every value declared by the App must be permitted by the profile. The script returns a nonzero status on failure, making it suitable for execution immediately after the archive step.

python3 - "$WORK_DIR/app-entitlements.plist" "$WORK_DIR/profile.plist" <<'PY'
import fnmatch
import plistlib
import sys

with open(sys.argv[1], "rb") as f:
    app = plistlib.load(f)

with open(sys.argv[2], "rb") as f:
    profile = plistlib.load(f)["Entitlements"]

keys = [
    "application-identifier",
    "com.apple.developer.team-identifier",
    "aps-environment",
    "com.apple.developer.associated-domains",
    "get-task-allow",
]

def allowed(actual, expected):
    if isinstance(actual, list) and isinstance(expected, list):
        return all(any(fnmatch.fnmatch(str(v), str(rule)) for rule in expected) for v in actual)
    if isinstance(actual, str) and isinstance(expected, str):
        return fnmatch.fnmatch(actual, expected)
    return actual == expected

failed = []
for key in keys:
    if key not in app:
        continue
    if key not in profile or not allowed(app[key], profile[key]):
        failed.append(key)

if failed:
    print("Entitlement mismatch:", ", ".join(failed))
    raise SystemExit(1)

print("Entitlement preflight passed")
PY

The script compares only fields that the App actually declares. If project policy requires a particular entitlement—for example, if production builds must contain aps-environment—add a list of required fields and fail the check when any of them are missing.

Trace configuration sources by symptom

Symptom Check first Common root cause
Push notifications do not work correctly aps-environment The archive environment does not match the intended use of the provisioning profile
Universal Links fail com.apple.developer.associated-domains The domain entry is absent from the final signature, or an extension uses different settings
Upload reports an identifier mismatch application-identifier Bundle Identifier, team prefix, or target configuration has been mixed across builds
A distribution build remains debuggable get-task-allow The build uses a configuration that is unsuitable for delivery
An extension fails to install Entitlements of the main App and .appex The extension identifier, App Group, or provisioning profile combination is incompatible

When a check fails, preserve the archive and both plist files before inspecting CODE_SIGN_ENTITLEMENTS, PRODUCT_BUNDLE_IDENTIFIER, and the active build configuration. Do not immediately re-sign and overwrite the evidence, or the most useful record of the mismatch will be lost.

Associated domains and App Groups are arrays whose order is usually irrelevant, so compare them using set-containment semantics. Boolean fields must be read as actual Boolean values; the string "false" must not be treated as false. This is why the check uses plistlib instead of grep.

Integrate the preflight into cloud build acceptance

Save the script as Scripts/verify_entitlements.sh and run it after xcodebuild archive succeeds but before export or upload. Use a separate archive directory for every job, and retain the check results, the App signature summary, and any failed field names as build artifacts. Logs should contain only field names and redacted identifiers; never print private key material or complete access credentials.

Split acceptance into four steps: validate the bundle structure, extract the signed entitlements, decode the provisioning profile, and apply the policy comparison. Stop delivery if any step fails, but retain the .xcarchive for investigation. In projects with multiple targets, run the process independently for the main App and every extension. Results from the main App cannot be reused for its extensions.

Finish with a manual spot check: confirm that the job used the expected Scheme and Configuration, that get-task-allow is false in the production artifact, that the push environment matches the delivery target, that no test addresses appear among the associated domains, and that every extension passes independent signature verification. This moves failures that would otherwise surface during upload into a repeatable, traceable build check.

Frequently asked questions

Why is checking the project entitlements file not enough?

It is only a build input. Build settings, the provisioning profile, and signing can alter the final result, so the archived signed app is the source of truth.

Which entitlements should be automated first?

Start with application-identifier, the team identifier, aps-environment, associated domains, and get-task-allow.

Dedicated physical nodes

Run your next build on a cloud Mac.

Compare three Apple Silicon configurations and choose the region that best fits your current workflow across five overseas nodes.

Choose a rental plan