Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions scripts/sign-release-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
138 changes: 112 additions & 26 deletions scripts/verify-provision-profile.sh
Original file line number Diff line number Diff line change
@@ -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 <app-path>" >&2
Expand All @@ -18,50 +32,122 @@ 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", "<unknown>")
expiry = profile.get("ExpirationDate")
entitlements = profile.get("Entitlements", {})
granted = profile.get("Entitlements", {}) or {}

print(f"Profile name: {name}")
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
Loading