From 7a954b3f326c01193b6a3b1c34e436d41e3e1b4f Mon Sep 17 00:00:00 2001 From: David Helkowski Date: Thu, 22 Jan 2026 21:15:07 +0900 Subject: [PATCH] Enhance macOS build scripts for provisioning profile handling and identity management This commit improves the `find_mas_provisioning_profile.sh` script by expanding the search for provisioning profiles to include both `.provisionprofile` and `.mobileprovision` files. It also introduces a new function to print SHA-1 values for identities, helping to avoid ambiguity when multiple identities share the same display name. Additionally, the `check_signing_identities.sh` script is updated to provide clearer recommendations for using SHA-1 hashes with codesigning and installer identities, enhancing the overall usability and clarity for developers working with macOS builds. --- build_tools/macos/build_mas_pkg.sh | 69 +++--- build_tools/macos/check_signing_identities.sh | 48 ++++ .../macos/find_mas_provisioning_profile.py | 210 ++++++++++++++++++ .../macos/find_mas_provisioning_profile.sh | 49 ++-- build_tools/macos/print_mas_build_cmd.sh | 70 ++++++ 5 files changed, 386 insertions(+), 60 deletions(-) create mode 100644 build_tools/macos/find_mas_provisioning_profile.py create mode 100755 build_tools/macos/print_mas_build_cmd.sh diff --git a/build_tools/macos/build_mas_pkg.sh b/build_tools/macos/build_mas_pkg.sh index 49b5ad28b..47657ab81 100755 --- a/build_tools/macos/build_mas_pkg.sh +++ b/build_tools/macos/build_mas_pkg.sh @@ -7,6 +7,41 @@ lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "${script_dir}/../.." && pwd)" +ensure_keychain_search_list() { + # codesign builds the cert chain using the user keychain search list. + # If the list is missing the System keychain, you can get: + # "unable to build chain to self-signed root" + errSecInternalComponent + local login_kc="$HOME/Library/Keychains/login.keychain-db" + local system_kc="/Library/Keychains/System.keychain" + local roots_kc="/System/Library/Keychains/SystemRootCertificates.keychain" + + local current + current="$(security list-keychains -d user 2>/dev/null | tr -d '"' | tr -d ' ' || true)" + + if echo "$current" | grep -Fq "$system_kc"; then + return 0 + fi + + echo "==> [$(ts)] Note: adding System keychains to the user keychain search list (fixes common codesign chain errors)" + echo " (This changes the user keychain search list; run 'security list-keychains -d user' to view.)" + security list-keychains -d user -s "$login_kc" "$system_kc" "$roots_kc" >/dev/null 2>&1 || true +} + + +preflight_identity() { + local what="$1" + local policy="$2" + local identity="$3" + + # NOTE: security expects "-p " as *two* args; do not pass "-p codesigning" as one string. + if ! security find-identity -p "$policy" -v 2>/dev/null | grep -Fq "$identity"; then + echo "Error: ${what} identity not found/usable in Keychain: $identity" >&2 + echo "Run: ./build_tools/macos/check_signing_identities.sh" >&2 + exit 2 + fi +} + + usage() { cat <<'EOF' Usage: @@ -147,40 +182,10 @@ echo "==> [$(ts)] Embedding provisioning profile" cp -f "$provisionprofile" "${app_path}/Contents/embedded.provisionprofile" ensure_keychain_search_list -preflight_identity "codesign" "-p codesigning" "$codesign_identity" -preflight_identity "installer" "-p basic" "$installer_identity" +preflight_identity "codesign" "codesigning" "$codesign_identity" +preflight_identity "installer" "basic" "$installer_identity" -ensure_keychain_search_list() { - # codesign builds the cert chain using the user keychain search list. - # If the list is missing the System keychain, you can get: - # "unable to build chain to self-signed root" + errSecInternalComponent - local login_kc="$HOME/Library/Keychains/login.keychain-db" - local system_kc="/Library/Keychains/System.keychain" - local roots_kc="/System/Library/Keychains/SystemRootCertificates.keychain" - local current - current="$(security list-keychains -d user 2>/dev/null | tr -d '"' | tr -d ' ' || true)" - - if echo "$current" | grep -Fq "$system_kc"; then - return 0 - fi - - echo "==> [$(ts)] Note: adding System keychains to the user keychain search list (fixes common codesign chain errors)" - echo " (This changes the user keychain search list; run 'security list-keychains -d user' to view.)" - security list-keychains -d user -s "$login_kc" "$system_kc" "$roots_kc" >/dev/null 2>&1 || true -} - -preflight_identity() { - local what="$1" - local predicate="$2" - local identity="$3" - - if ! security find-identity "$predicate" -v 2>/dev/null | grep -Fq "$identity"; then - echo "Error: ${what} identity not found/usable in Keychain: $identity" >&2 - echo "Run: ./build_tools/macos/check_signing_identities.sh" >&2 - exit 2 - fi -} echo "==> [$(ts)] Codesigning app (Mac App Store)" codesign_args=( --force --timestamp --options runtime --sign "$codesign_identity" --entitlements "$entitlements" ) diff --git a/build_tools/macos/check_signing_identities.sh b/build_tools/macos/check_signing_identities.sh index e4b7a40b5..32a30034f 100755 --- a/build_tools/macos/check_signing_identities.sh +++ b/build_tools/macos/check_signing_identities.sh @@ -156,4 +156,52 @@ fi echo "Tip: to pick the right MAS profile for a bundle id, run:" echo " ./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id com.dryark.strawberry" +\n\necho\n +echo "==> [$(ts)] Recommended SHA-1 values to use (avoids ambiguity when names are duplicated)" +cat <<'EOF' +When you have multiple identities with the same display name, prefer using the SHA-1 hash in scripts: + + --codesign-identity "" + --installer-identity "" + +This prevents codesign/productbuild from picking an unexpected identity. +EOF +echo + +extract_identities() { + local policy="$1" # codesigning | basic + # Output: SHA1|LABEL + security find-identity -p "$policy" -v 2>/dev/null \ + | sed -n 's/^[[:space:]]*[0-9][0-9]*[)] \([0-9A-F]\{40\}\) "\(.*\)"$/\1|\2/p' +} + +print_sha_list() { + local title="$1" + local policy="$2" + local label_match="$3" + + echo "$title" + local matches + matches="$(extract_identities "$policy" | grep -F "$label_match" || true)" + if [[ -z "$matches" ]]; then + echo " (none found)" + return 0 + fi + + local first=1 + while IFS='|' read -r sha label; do + [[ -z "$sha" || -z "$label" ]] && continue + if [[ $first -eq 1 ]]; then + echo " recommended: $sha ($label)" + first=0 + else + echo " alternative: $sha ($label)" + fi + done <<<"$matches" +} + +print_sha_list "Mac App Store (app signing) [use with --codesign-identity]:" "codesigning" "Apple Distribution:" +print_sha_list "Mac App Store (pkg signing) [use with --installer-identity]:" "basic" "3rd Party Mac Developer Installer:" +print_sha_list "Developer ID (app signing) [outside App Store]:" "codesigning" "Developer ID Application:" +print_sha_list "Developer ID (pkg signing) [outside App Store]:" "basic" "Developer ID Installer:" diff --git a/build_tools/macos/find_mas_provisioning_profile.py b/build_tools/macos/find_mas_provisioning_profile.py new file mode 100644 index 000000000..d1b7c3f1b --- /dev/null +++ b/build_tools/macos/find_mas_provisioning_profile.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import plistlib + + +@dataclass +class ProfileInfo: + path: Path + uuid: str + name: str + team_id: str + expiration: Optional[dt.datetime] + app_id: str + platforms: List[str] + + +def run(cmd: List[str]) -> Tuple[int, bytes, bytes]: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = p.communicate() + return p.returncode, out, err + + +def decode_profile_plist_bytes(profile_path: Path) -> Optional[bytes]: + # Provisioning profiles are typically CMS/PKCS#7 SignedData blobs whose payload is a plist. + # However, some tools store them as plain XML plists already. Also, LibreSSL/OpenSSL behavior + # differs: LibreSSL usually requires an explicit '-verify' to emit the embedded content. + data = profile_path.read_bytes() + + # Fast path: already a plist (XML). + if b" Dict[str, Any]: + return plistlib.loads(plist_bytes) + + +def iso(dt_obj: Optional[dt.datetime]) -> str: + if not dt_obj: + return "(unknown)" + # Force UTC-ish display if tz-aware, otherwise as-is. + try: + return dt_obj.isoformat().replace("+00:00", "Z") + except Exception: + return str(dt_obj) + + +def safe_str(v: Any) -> str: + if v is None: + return "" + if isinstance(v, bytes): + try: + return v.decode("utf-8", errors="replace") + except Exception: + return repr(v) + return str(v) + + +def profile_info_from_plist(path: Path, p: Dict[str, Any]) -> ProfileInfo: + uuid = safe_str(p.get("UUID", "")) or "(unknown)" + name = safe_str(p.get("Name", "")) or "(unknown)" + team_ids = p.get("TeamIdentifier") or [] + team_id = safe_str(team_ids[0]) if isinstance(team_ids, list) and team_ids else "" + if not team_id: + prefixes = p.get("ApplicationIdentifierPrefix") or [] + team_id = safe_str(prefixes[0]) if isinstance(prefixes, list) and prefixes else "(unknown)" + exp = p.get("ExpirationDate") + expiration = exp if isinstance(exp, dt.datetime) else None + ent = p.get("Entitlements") or {} + app_id = safe_str(ent.get("application-identifier") or ent.get("com.apple.application-identifier") or "") or "(unknown)" + platforms = p.get("Platform") or [] + if isinstance(platforms, str): + platforms = [platforms] + platforms = [safe_str(x) for x in platforms if x is not None] + return ProfileInfo(path=path, uuid=uuid, name=name, team_id=team_id or "(unknown)", expiration=expiration, app_id=app_id, platforms=platforms) + + +def score(profile: ProfileInfo, bundle_id: str, now: dt.datetime) -> Tuple[int, str]: + # Prefer non-expired. + if profile.expiration and profile.expiration < now: + return (-1, "expired") + + score = 0 + reason = [] + + # Prefer exact app id match TEAMID.bundle_id + if profile.team_id != "(unknown)" and profile.app_id != "(unknown)": + exact = f"{profile.team_id}.{bundle_id}" + if profile.app_id == exact: + score += 100 + reason.append(f"exact {profile.app_id}") + elif profile.app_id.endswith(f".{bundle_id}"): + score += 60 + reason.append(f"endswith {profile.app_id}") + elif "*" in profile.app_id and profile.app_id.startswith(f"{profile.team_id}."): + score += 40 + reason.append(f"wildcard {profile.app_id}") + + # Heuristic: name suggests MAS. + n = profile.name.lower() + if "mac app store" in n or "app store" in n or "appstore" in n: + score += 5 + reason.append("name looks like MAS") + + # Prefer macOS platform if present. + plats = [p.lower() for p in profile.platforms] + if any("macos" in p for p in plats): + score += 2 + reason.append("platform macos") + + return (score, ", ".join(reason) if reason else "") + + +def find_profiles() -> List[Path]: + dirs = [ + Path.home() / "Library" / "Developer" / "Xcode" / "UserData" / "Provisioning Profiles", + Path.home() / "Library" / "MobileDevice" / "Provisioning Profiles", + ] + out: List[Path] = [] + for d in dirs: + if not d.is_dir(): + continue + for p in d.iterdir(): + if p.is_file() and (p.name.endswith(".provisionprofile") or p.name.endswith(".mobileprovision")): + out.append(p) + return sorted(out) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--bundle-id", required=True) + args = ap.parse_args() + bundle_id = args.bundle_id + + if not Path("/usr/bin/openssl").exists(): + print("Error: /usr/bin/openssl not found.", file=sys.stderr) + return 1 + + candidates = find_profiles() + if not candidates: + print("No provisioning profiles found in common locations.", file=sys.stderr) + return 1 + + print(f"Scanning {len(candidates)} provisioning profile(s) for bundle id: {bundle_id}") + print() + print(f"{'No.':<4} {'UUID':<36} {'TeamID':<10} {'Expires':<25} {'AppID':<45} Path") + print(f"{'-'*4} {'-'*36} {'-'*10} {'-'*25} {'-'*45} ----") + + infos: List[ProfileInfo] = [] + for i, p in enumerate(candidates, start=1): + plist_bytes = decode_profile_plist_bytes(p) + if not plist_bytes: + continue + try: + pl = parse_plist(plist_bytes) + info = profile_info_from_plist(p, pl) + infos.append(info) + print(f"{i:<4} {info.uuid:<36} {info.team_id:<10} {iso(info.expiration):<25} {info.app_id:<45} {info.path}") + except Exception: + continue + + if not infos: + print("\nCould not decode any provisioning profiles with openssl cms.", file=sys.stderr) + return 2 + + now = dt.datetime.now(dt.timezone.utc) + best: Optional[Tuple[int, str, ProfileInfo]] = None + for info in infos: + sc, why = score(info, bundle_id, now) + if best is None or sc > best[0]: + best = (sc, why, info) + + print() + if best is None or best[0] <= 0: + print(f"Could not confidently auto-select a profile for {bundle_id}.", file=sys.stderr) + print("Pick the profile whose AppID is TEAMID. and is a macOS Mac App Store profile.", file=sys.stderr) + return 2 + + _, why, info = best + print("Recommended profile:") + print(f" {info.path}") + print(f" reason: {why}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/build_tools/macos/find_mas_provisioning_profile.sh b/build_tools/macos/find_mas_provisioning_profile.sh index fa496c105..1756a9fac 100755 --- a/build_tools/macos/find_mas_provisioning_profile.sh +++ b/build_tools/macos/find_mas_provisioning_profile.sh @@ -10,7 +10,7 @@ Usage: What it does: - Scans common macOS provisioning profile locations (new Xcode + legacy) - - Decodes each *.provisionprofile (CMS) into plist + - Uses Apple's `security cms -D` to decode each *.provisionprofile into a plist - Prints a readable table and recommends a best match for the given bundle id Notes: @@ -44,6 +44,12 @@ if ! command -v security >/dev/null 2>&1; then exit 1 fi +plistbuddy_print() { + local keypath="$1" + local plist="$2" + /usr/libexec/PlistBuddy -c "Print :${keypath}" "$plist" 2>/dev/null || true +} + plutil_extract() { local keypath="$1" local plist="$2" @@ -53,37 +59,29 @@ plutil_extract() { find_profiles_in_dir() { local dir="$1" if [[ -d "$dir" ]]; then - find "$dir" -maxdepth 1 -type f -name "*.provisionprofile" 2>/dev/null || true + find "$dir" -maxdepth 1 -type f \( -name "*.provisionprofile" -o -name "*.mobileprovision" \) 2>/dev/null || true fi } declare -a candidates candidates=() - -# Newer Xcode location (as reported by user) while IFS= read -r f; do candidates+=("$f"); done < <(find_profiles_in_dir "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles") - -# Legacy location while IFS= read -r f; do candidates+=("$f"); done < <(find_profiles_in_dir "$HOME/Library/MobileDevice/Provisioning Profiles") if [[ ${#candidates[@]} -eq 0 ]]; then - echo "==> [$(ts)] No .provisionprofile files found in common locations." - echo "Checked:" - echo " - $HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" - echo " - $HOME/Library/MobileDevice/Provisioning Profiles" + echo "==> [$(ts)] No provisioning profiles found in common locations." exit 1 fi echo "==> [$(ts)] Scanning ${#candidates[@]} provisioning profile(s) for bundle id: ${bundle_id}" echo +printf "%-4s %-36s %-10s %-25s %-45s %s\n" "No." "UUID" "TeamID" "Expires" "AppID" "Path" +printf "%s\n" "---- ------------------------------------ ---------- ------------------------- --------------------------------------------- ----" best_score=-1 best_path="" best_reason="" -printf "%-4s %-36s %-10s %-25s %-40s %s\n" "No." "UUID" "TeamID" "Expires" "AppID" "Path" -printf "%s\n" "---- ------------------------------------ ---------- ------------------------- ---------------------------------------- ----" - idx=0 for f in "${candidates[@]}"; do idx=$((idx + 1)) @@ -97,44 +95,42 @@ for f in "${candidates[@]}"; do uuid="$(plutil_extract UUID "$tmp")" name="$(plutil_extract Name "$tmp")" teamid="$(plutil_extract 'TeamIdentifier.0' "$tmp")" + if [[ -z "$teamid" ]]; then + teamid="$(plutil_extract 'ApplicationIdentifierPrefix.0' "$tmp")" + fi exp="$(plutil_extract ExpirationDate "$tmp")" - # Profiles vary in which key they use for the app identifier. - appid="$(plutil_extract 'Entitlements.application-identifier' "$tmp")" + # App identifier lives under Entitlements; use PlistBuddy because some key names contain dots. + appid="$(plistbuddy_print 'Entitlements:application-identifier' "$tmp")" if [[ -z "$appid" ]]; then - appid="$(plutil_extract 'Entitlements.com.apple.application-identifier' "$tmp")" + appid="$(plistbuddy_print 'Entitlements:com.apple.application-identifier' "$tmp")" fi rm -f "$tmp" >/dev/null 2>&1 || true - # Fallbacks for display. [[ -z "$uuid" ]] && uuid="(unknown)" [[ -z "$teamid" ]] && teamid="(unknown)" [[ -z "$exp" ]] && exp="(unknown)" [[ -z "$appid" ]] && appid="(unknown)" - printf "%-4s %-36s %-10s %-25s %-40s %s\n" "$idx" "$uuid" "$teamid" "$exp" "$appid" "$f" + printf "%-4s %-36s %-10s %-25s %-45s %s\n" "$idx" "$uuid" "$teamid" "$exp" "$appid" "$f" - # Score match quality. score=0 reason="" - # Prefer exact team+bundle match. if [[ "$appid" != "(unknown)" && "$teamid" != "(unknown)" ]]; then if [[ "$appid" == "${teamid}.${bundle_id}" ]]; then score=100 reason="exact match (${appid})" - elif [[ "$appid" == "${teamid}."* && "$appid" == *"*"* ]]; then - # Wildcard profile like TEAMID.* - score=60 - reason="wildcard match (${appid})" elif [[ "$appid" == *".${bundle_id}" ]]; then score=50 reason="endswith match (${appid})" + elif [[ "$appid" == "${teamid}."* && "$appid" == *"*"* ]]; then + score=40 + reason="wildcard match (${appid})" fi fi - # Prefer profiles with a plausible name for MAS (heuristic). if [[ "$score" -gt 0 && -n "$name" ]]; then case "$name" in *Mac\ App\ Store*|*App\ Store*|*appstore*|*AppStore*) @@ -161,7 +157,4 @@ fi echo "==> [$(ts)] Recommended profile:" echo " $best_path" echo " reason: $best_reason" -echo -echo "Use it like:" -echo " ./build_tools/macos/build_mas_pkg.sh --run ... --provisionprofile \"$best_path\"" diff --git a/build_tools/macos/print_mas_build_cmd.sh b/build_tools/macos/print_mas_build_cmd.sh new file mode 100755 index 000000000..220c33e96 --- /dev/null +++ b/build_tools/macos/print_mas_build_cmd.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ts() { date +"%H:%M:%S"; } + +usage() { + cat <<'EOF' +Usage: + ./build_tools/macos/print_mas_build_cmd.sh [--bundle-id com.dryark.strawberry] [--profile ] + +What it does: + - Tries to auto-pick a provisioning profile for the bundle id + - Prints an exact build command you can copy/paste for build_mas_pkg.sh + +Notes: + - This helper intentionally does NOT try to auto-pick signing identities by parsing Apple tool output. + Use SHA-1 identities from: + ./build_tools/macos/check_signing_identities.sh +EOF +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: This script is for macOS only." >&2 + exit 1 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/../.." && pwd)" + +bundle_id="com.dryark.strawberry" +profile_path="" +codesign_identity="" +installer_identity="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --bundle-id) bundle_id="${2:-}"; shift 2 ;; + --profile) profile_path="${2:-}"; shift 2 ;; + --codesign-identity) codesign_identity="${2:-}"; shift 2 ;; + --installer-identity) installer_identity="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$profile_path" ]]; then + # Attempt to auto-select profile using the finder script. + finder="${repo_root}/build_tools/macos/find_mas_provisioning_profile.sh" + if [[ -x "$finder" ]]; then + out="$("$finder" --bundle-id "$bundle_id" 2>/dev/null || true)" + # Parse the line after "Recommended profile:" + profile_path="$(printf '%s\n' "$out" | awk 'found{print $1; exit} /^Recommended profile:/{found=1} found && $0 ~ /^ \\// {print $1; exit}' | sed 's/^[[:space:]]*//')" + fi +fi + +echo "==> [$(ts)] Recommended build command:" +echo +echo "./build_tools/macos/build_mas_pkg.sh --run --release --clean \\" +echo " --codesign-identity \"${codesign_identity:-}\" \\" +echo " --installer-identity \"${installer_identity:-}\" \\" +if [[ -n "$profile_path" ]]; then + echo " --provisionprofile \"${profile_path}\"" +else + echo " --provisionprofile \"\"" + echo + echo "Note: could not auto-pick a provisioning profile for bundle id '${bundle_id}'." + echo "Run:" + echo " ./build_tools/macos/find_mas_provisioning_profile.sh --bundle-id ${bundle_id}" +fi +