From 8ebefc4b9f942c227bfda9687c69e0cceb9bc234 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 29 Jul 2026 13:28:14 -0300 Subject: [PATCH] fix: stop a missing signing profile from shipping a dead app The app needs a provisioning profile from Apple to use iCloud. Without one it still builds, still gets signed, still passes Apple's checks, and then refuses to open on every machine it lands on. The check meant to catch that was written back when the app used no Apple services, so it said a missing profile was fine and passed the build. That stopped being true this morning. It now reads what the app actually asks for and stops the build when the profile is missing, or when the profile it has does not cover it. --- .github/workflows/release.yml | 14 ++- scripts/sign-release-app.sh | 5 +- scripts/verify-provision-profile.sh | 138 ++++++++++++++++++++++------ 3 files changed, 124 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b86c394..c588a833 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -398,13 +398,17 @@ jobs: env: APPLE_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }} run: | - # Optional secret: only set once a Developer ID provisioning profile exists for - # a restricted entitlement (e.g. CloudKit). See plans/golden-tumbling-gray.md M3 - # for the one-time Developer portal setup this unblocks. Absent today by design — - # when unset, this step is a no-op and the release pipeline (and the signing - # script it feeds) behaves exactly as it does today: no profile, no iCloud. + # REQUIRED now, despite the name of the check below. The app declares + # restricted entitlements (CloudKit), and an app carrying those with no + # embedded profile is killed by AMFI at launch for every user (POSIX 163) + # after signing and notarizing cleanly. This step still tolerates the + # secret being absent so the failure is reported by + # verify-provision-profile.sh with a full explanation, rather than as a + # bare base64 error here — that script is the gate, and it fails the + # build. Do not "fix" a red verify step by removing it. if [ -z "$APPLE_PROVISION_PROFILE_BASE64" ]; then echo "No APPLE_PROVISION_PROFILE_BASE64 secret set; skipping profile embedding." + echo "The build will fail verification: this app declares restricted entitlements." echo "PROGRAMA_PROVISION_PROFILE=" >> "$GITHUB_ENV" exit 0 fi diff --git a/scripts/sign-release-app.sh b/scripts/sign-release-app.sh index ba306236..cc8f1110 100755 --- a/scripts/sign-release-app.sh +++ b/scripts/sign-release-app.sh @@ -57,8 +57,9 @@ sign_if_present "$app_path/Contents/Resources/bin/ghostty" # exact AMFI-kill failure mode this comment is warning about. # # When PROGRAMA_PROVISION_PROFILE is unset or the file doesn't exist, this is -# a silent no-op: the current no-profile, no-iCloud build keeps working -# exactly as it does today. +# a no-op here — but it is no longer harmless, because the app does now declare +# restricted entitlements. scripts/verify-provision-profile.sh runs after this +# and fails the build in that case rather than letting an AMFI-killed app ship. if [[ -n "${PROGRAMA_PROVISION_PROFILE:-}" && -f "${PROGRAMA_PROVISION_PROFILE:-}" ]]; then echo "Embedding provisioning profile from $PROGRAMA_PROVISION_PROFILE" cp "$PROGRAMA_PROVISION_PROFILE" "$app_path/Contents/embedded.provisionprofile" diff --git a/scripts/verify-provision-profile.sh b/scripts/verify-provision-profile.sh index c9e92c1c..12954fc5 100755 --- a/scripts/verify-provision-profile.sh +++ b/scripts/verify-provision-profile.sh @@ -1,14 +1,28 @@ #!/usr/bin/env bash set -euo pipefail -# Reports on an app bundle's embedded Developer ID provisioning profile, if any. +# Verifies that a signed app bundle's embedded provisioning profile actually +# covers the restricted entitlements the app declares. # -# A missing profile is NOT an error today: Programa currently ships with no -# restricted entitlements (no iCloud), so no profile is expected or required. -# This script exists to verify the profile once one is introduced (see -# plans/golden-tumbling-gray.md M3) — it exits non-zero only when a profile -# IS present but is expired or unreadable, since either of those means the -# app would be killed by AMFI at launch despite passing notarization. +# Why this is a hard gate and not a report: Apple evaluates the embedded +# profile against the app's entitlements at every launch. An app carrying a +# restricted entitlement (anything under com.apple.developer.*) with no +# profile — or with a profile that does not grant that entitlement — signs +# cleanly, notarizes cleanly, and is then killed by AMFI the moment a user +# opens it (POSIX 163). Notarization does not catch this class of failure; +# only a real launch does, and by then it has shipped to everyone. +# +# This script previously treated a missing profile as fine, on the stated +# grounds that "Programa ships no restricted entitlements." That stopped +# being true when CloudKit (com.apple.developer.icloud-services / +# icloud-container-identifiers) landed, which left the one check that exists +# to catch an AMFI brick asserting the brick was expected. It now derives the +# answer from the bundle itself rather than from a comment that can go stale. +# +# Exit codes: +# 0 no restricted entitlements and no profile, or profile covers them all +# 1 restricted entitlements with no/expired/corrupt/insufficient profile +# 64 usage error if [[ "$#" -ne 1 ]]; then echo "usage: $0 " >&2 @@ -18,32 +32,91 @@ fi app_path="$1" profile_path="$app_path/Contents/embedded.provisionprofile" -if [[ ! -f "$profile_path" ]]; then - echo "No embedded.provisionprofile found at $profile_path." - echo "This is expected for the current build (no restricted entitlements, no profile)." - exit 0 +if [[ ! -d "$app_path" ]]; then + echo "verify-provision-profile: no app bundle at $app_path" >&2 + exit 64 fi -plist_xml="$(security cms -D -i "$profile_path" 2>/dev/null)" || { - echo "Embedded provisioning profile is present but could not be decoded (corrupt or unsigned): $profile_path" >&2 +# Read the entitlements actually sealed into the signature, not the source +# .entitlements file — those can differ, and the signature is what AMFI reads. +if ! app_entitlements="$(codesign -d --entitlements - --xml "$app_path" 2>/dev/null)"; then + echo "verify-provision-profile: could not read entitlements from $app_path." >&2 + echo "The bundle must be signed before this runs; refusing to guess." >&2 exit 1 -} +fi + +if [[ -f "$profile_path" ]]; then + if ! profile_plist="$(security cms -D -i "$profile_path" 2>/dev/null)"; then + echo "Embedded provisioning profile is present but could not be decoded (corrupt or unsigned): $profile_path" >&2 + exit 1 + fi +else + profile_plist="" +fi -python3 - "$plist_xml" <<'PYEOF' +python3 - "$app_entitlements" "$profile_plist" <<'PYEOF' import datetime import plistlib import sys -xml = sys.argv[1].encode("utf-8") +app_xml, profile_xml = sys.argv[1], sys.argv[2] + +try: + app_entitlements = plistlib.loads(app_xml.encode("utf-8")) or {} +except Exception as exc: # noqa: BLE001 - any parse failure is fatal here + print(f"Could not parse the app's signed entitlements: {exc}", file=sys.stderr) + sys.exit(1) + +# Most of com.apple.developer.* is App-ID-level capability that Apple gates +# through a provisioning profile, so the namespace is the right starting point +# and unknown keys are treated as restricted — a false positive is a loud, +# one-line fix, a false negative ships an app that dies on launch for everyone. +# +# team-identifier is the one blanket exception. codesign injects it into +# essentially every Developer-ID-signed bundle from the signing certificate, so +# treating it as restricted would fail every build. Verified by sampling signed +# apps: IINA, Dato and Xcode all ship with no embedded profile at all, and Dato +# and Xcode carry other com.apple.developer.* keys (usernotifications.*, +# aps-environment) without one — those are deliberately NOT excluded here, since +# Programa does not use them and guessing wrong in that direction is the +# expensive mistake. If one is ever added and this fires, confirm against Apple's +# capability docs before widening the exception. +IGNORED = {"com.apple.developer.team-identifier"} + +restricted = sorted( + k for k in app_entitlements + if k.startswith("com.apple.developer.") and k not in IGNORED +) + +if not restricted: + print("No restricted entitlements declared; no provisioning profile required.") + if profile_xml: + print("(An embedded profile is present anyway, which is harmless.)") + sys.exit(0) + +print("Restricted entitlements declared by this build:") +for key in restricted: + print(f" {key}") + +if not profile_xml: + print("", file=sys.stderr) + print("FAIL: this build declares restricted entitlements but embeds no", file=sys.stderr) + print("provisioning profile. It would pass notarization and then be killed", file=sys.stderr) + print("by AMFI at launch (POSIX 163) for every user.", file=sys.stderr) + print("", file=sys.stderr) + print("Set the APPLE_PROVISION_PROFILE_BASE64 secret, or remove the", file=sys.stderr) + print("restricted entitlements from programa.entitlements.", file=sys.stderr) + sys.exit(1) + try: - profile = plistlib.loads(xml) -except Exception as exc: + profile = plistlib.loads(profile_xml.encode("utf-8")) +except Exception as exc: # noqa: BLE001 print(f"Embedded provisioning profile is present but unreadable: {exc}", file=sys.stderr) sys.exit(1) name = profile.get("Name", "") expiry = profile.get("ExpirationDate") -entitlements = profile.get("Entitlements", {}) +granted = profile.get("Entitlements", {}) or {} print(f"Profile name: {name}") print(f"Expiration: {expiry}") @@ -51,17 +124,30 @@ print(f"Expiration: {expiry}") if isinstance(expiry, datetime.datetime): now = datetime.datetime.now(expiry.tzinfo) if expiry.tzinfo else datetime.datetime.utcnow() if expiry < now: - print("Provisioning profile is EXPIRED.", file=sys.stderr) + print(f"FAIL: provisioning profile '{name}' is EXPIRED.", file=sys.stderr) sys.exit(1) else: print("Warning: could not read ExpirationDate from profile; skipping expiry check.", file=sys.stderr) +# Presence, not value equality: the profile grants a capability, while the app's +# own entitlement carries the concrete value (a container id, a domain). A value +# mismatch is worth seeing but is not on its own an AMFI kill; a missing key is. +missing = [key for key in restricted if key not in granted] +if missing: + print("", file=sys.stderr) + print(f"FAIL: profile '{name}' does not grant every restricted entitlement", file=sys.stderr) + print("this build declares. Missing:", file=sys.stderr) + for key in missing: + print(f" {key}", file=sys.stderr) + print("", file=sys.stderr) + print("AMFI checks the app's entitlements against this profile at launch,", file=sys.stderr) + print("so the app would be killed for every user. Regenerate the profile", file=sys.stderr) + print("against an App ID that has the matching capability enabled.", file=sys.stderr) + sys.exit(1) + print("Entitlements granted by this profile:") -if entitlements: - for key, value in entitlements.items(): - print(f" {key} = {value}") -else: - print(" (none found)") +for key, value in granted.items(): + print(f" {key} = {value}") -print(f"Provisioning profile verified: {name}") +print(f"Provisioning profile verified: {name} covers all {len(restricted)} restricted entitlement(s).") PYEOF