From 5054a3c49c68d708e8557f80eee50b12fcf7424a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 03:57:13 +0000 Subject: [PATCH] Install App Store profiles before the TestFlight archive. Manual signing had no profile, so Xcode 26 stopped the archive asking for App Groups. The workflow now installs one Apple Distribution certificate and App Store profiles for the app and share extension, and points only those targets at them. Co-authored-by: sanylax0 --- .../scripts/install_distribution_signing.py | 578 ++++++++++++++++++ .../test_install_distribution_signing.py | 198 ++++++ .github/workflows/testflight.yml | 96 +-- .gitignore | 4 + Signing/Continuity.release.xcconfig | 3 + Signing/ContinuityShare.release.xcconfig | 3 + project.yml | 18 +- 7 files changed, 846 insertions(+), 54 deletions(-) create mode 100644 .github/scripts/install_distribution_signing.py create mode 100644 .github/scripts/test_install_distribution_signing.py create mode 100644 Signing/Continuity.release.xcconfig create mode 100644 Signing/ContinuityShare.release.xcconfig diff --git a/.github/scripts/install_distribution_signing.py b/.github/scripts/install_distribution_signing.py new file mode 100644 index 0000000..a66efa6 --- /dev/null +++ b/.github/scripts/install_distribution_signing.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +"""Install the Apple Distribution cert and App Store profiles TestFlight archive needs. + +Xcode 26 rejects Automatic signing combined with an Apple Distribution identity, and +plain Automatic signing mints a new Apple Development certificate on every ephemeral +runner until the account hits Apple's cap. Manual signing avoids both, but only if a +profile that actually contains the app group is already installed — Xcode will not +create one for a manually signed target. + +This script uses the existing App Store Connect API key to reuse or create one +Distribution certificate, install App Store profiles for the app and the share +extension (regenerating them until they include the app group), and write the +Release xcconfigs xcodegen reads. Nothing here is committed; the .p12 stays in the +Actions cache. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import plistlib +import shutil +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +API_ROOT = "https://api.appstoreconnect.apple.com" +APP_GROUP = "group.com.sanylax.continuity" +TEAM_ID = "KP832RV67A" +# Names are also the PROVISIONING_PROFILE_SPECIFIER values. No spaces: xcconfig +# treats an unquoted space as a delimiter. +TARGETS = ( + ("com.sanylax.continuity", "ContinuityCIStore", "Signing/Continuity.release.xcconfig"), + ( + "com.sanylax.continuity.share", + "ContinuityShareCIStore", + "Signing/ContinuityShare.release.xcconfig", + ), +) +ROOT = Path(__file__).resolve().parents[2] + + +class APIError(Exception): + def __init__(self, method: str, path: str, status: int, body: str): + super().__init__(f"App Store Connect {method} {path} failed: HTTP {status} {body}") + self.status = status + self.body = body + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _der_length(data: bytes, index: int) -> tuple[int, int]: + first = data[index] + index += 1 + if first & 0x80: + count = first & 0x7F + length = int.from_bytes(data[index : index + count], "big") + return length, index + count + return first, index + + +def der_ecdsa_to_raw(der: bytes) -> bytes: + """Convert an OpenSSL DER ECDSA signature to the raw R||S form JWT ES256 needs.""" + if not der or der[0] != 0x30: + raise ValueError("ECDSA signature is not a DER sequence") + _, index = _der_length(der, 1) + + def read_int(pos: int) -> tuple[bytes, int]: + if der[pos] != 0x02: + raise ValueError("ECDSA signature is missing an integer") + length, start = _der_length(der, pos + 1) + return der[start : start + length], start + length + + r, index = read_int(index) + s, _ = read_int(index) + + def fixed(component: bytes) -> bytes: + component = component.lstrip(b"\x00") + if len(component) > 32: + raise ValueError("ECDSA component is longer than 32 bytes") + return component.rjust(32, b"\x00") + + return fixed(r) + fixed(s) + + +def sign_es256(key_path: Path, message: bytes) -> bytes: + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", str(key_path)], + input=message, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", "replace")) + return der_ecdsa_to_raw(result.stdout) + + +def make_token(key_id: str, issuer_id: str, key_path: Path, now: int | None = None) -> str: + issued = int(time.time()) if now is None else now + header = b64url(json.dumps({"alg": "ES256", "kid": key_id, "typ": "JWT"}, separators=(",", ":")).encode()) + payload = b64url( + json.dumps( + {"iss": issuer_id, "iat": issued, "exp": issued + 15 * 60, "aud": "appstoreconnect-v1"}, + separators=(",", ":"), + ).encode() + ) + signing_input = f"{header}.{payload}".encode() + return f"{header}.{payload}.{b64url(sign_es256(key_path, signing_input))}" + + +def api(token: str, method: str, path: str, body: dict | None = None) -> dict: + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(API_ROOT + path, data=data, method=method) + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read() + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", "replace") + raise APIError(method, path, error.code, detail) from error + if not raw: + return {} + return json.loads(raw) + + +def list_all(token: str, path: str) -> list[dict]: + items: list[dict] = [] + while path: + page = api(token, "GET", path) + items.extend(page.get("data", [])) + nxt = page.get("links", {}).get("next") + if not nxt: + break + marker = "api.appstoreconnect.apple.com" + path = nxt.split(marker, 1)[1] if marker in nxt else None + return items + + +def normalize_serial(serial: str) -> str: + return serial.replace(":", "").replace(" ", "").lower().lstrip("0") + + +def openssl_supports_legacy() -> bool: + result = subprocess.run(["openssl", "pkcs12", "-help"], capture_output=True, text=True) + return "-legacy" in result.stdout or "-legacy" in result.stderr + + +def p12_certificate_der(p12: Path, password: str) -> bytes: + command = [ + "openssl", + "pkcs12", + "-in", + str(p12), + "-nokeys", + "-passin", + f"pass:{password}", + ] + if openssl_supports_legacy(): + command.append("-legacy") + pem = run(command, secret=True) + der = run(["openssl", "x509", "-outform", "DER"], input=pem.stdout) + return der.stdout + + +def certificate_serial(der: bytes) -> str: + result = subprocess.run( + ["openssl", "x509", "-inform", "DER", "-noout", "-serial"], + input=der, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", "replace")) + return normalize_serial(result.stdout.decode().split("=", 1)[-1]) + + +def p12_password(api_key: bytes) -> str: + # Stable for this API key, so a restored cache can be unlocked without another secret. + return hashlib.sha256(b"continuity-distribution-v1\n" + api_key).hexdigest() + + +def matching_certificate_id(token: str, der: bytes) -> str | None: + serial = certificate_serial(der) + certificates = list_all(token, "/v1/certificates?filter[certificateType]=DISTRIBUTION&limit=50") + for certificate in certificates: + attributes = certificate.get("attributes") or {} + if normalize_serial(str(attributes.get("serialNumber", ""))) == serial: + return certificate["id"] + content = attributes.get("certificateContent") + if content and base64.b64decode(content) == der: + return certificate["id"] + return None + + +def run(command: list[str], *, secret: bool = False, **kwargs) -> subprocess.CompletedProcess: + result = subprocess.run(command, capture_output=True, check=False, **kwargs) + if result.returncode != 0: + shown = "openssl (arguments hidden)" if secret else " ".join(command) + detail = result.stderr.decode("utf-8", "replace") + raise RuntimeError(f"command failed: {shown}\n{detail}") + return result + + +def create_distribution_certificate(token: str, signing_dir: Path, password: str) -> tuple[Path, bytes, str]: + existing = list_all(token, "/v1/certificates?filter[certificateType]=DISTRIBUTION&limit=50") + # Leave room under Apple's distribution-certificate cap. A revoked cert cannot be + # downloaded again; creating one we fail to cache would burn a slot. + if len(existing) >= 3: + names = [((item.get("attributes") or {}).get("name") or item.get("id")) for item in existing] + raise SystemExit( + "This team already has " + f"{len(existing)} Apple Distribution certificates ({', '.join(map(str, names))}). " + "Revoke an unused one in Certificates, Identifiers & Profiles, then re-run TestFlight." + ) + key_path = signing_dir / "distribution.key" + csr_path = signing_dir / "distribution.csr" + run(["openssl", "genrsa", "-out", str(key_path), "2048"]) + run( + ["openssl", "req", "-new", "-key", str(key_path), "-out", str(csr_path), "-subj", "/CN=Continuity CI Distribution"] + ) + created = api( + token, + "POST", + "/v1/certificates", + { + "data": { + "type": "certificates", + "attributes": { + "certificateType": "DISTRIBUTION", + "csrContent": csr_path.read_text(), + }, + }, + }, + ) + der = base64.b64decode(created["data"]["attributes"]["certificateContent"]) + der_path = signing_dir / "distribution.cer" + pem_path = signing_dir / "distribution.pem" + p12_path = signing_dir / "distribution.p12" + certificate_id = created["data"]["id"] + der_path.write_bytes(der) + run(["openssl", "x509", "-inform", "DER", "-in", str(der_path), "-out", str(pem_path)]) + export_p12(key_path, pem_path, p12_path, password) + key_path.unlink(missing_ok=True) + os.chmod(p12_path, 0o600) + (signing_dir / "created-new").write_text("new\n") + return p12_path, der, certificate_id + + +def export_p12(key_path: Path, pem_path: Path, p12_path: Path, password: str) -> None: + # 3DES so `security import` on the macOS runner accepts the archive. + run( + [ + "openssl", + "pkcs12", + "-export", + "-inkey", + str(key_path), + "-in", + str(pem_path), + "-out", + str(p12_path), + "-passout", + f"pass:{password}", + "-certpbe", + "PBE-SHA1-3DES", + "-keypbe", + "PBE-SHA1-3DES", + "-macalg", + "sha1", + ], + secret=True, + ) + + +def ensure_certificate(token: str, signing_dir: Path, api_key: bytes) -> tuple[str, bytes]: + password = p12_password(api_key) + p12_path = signing_dir / "distribution.p12" + der: bytes | None = None + if p12_path.exists(): + try: + der = p12_certificate_der(p12_path, password) + except RuntimeError as error: + print(f"Cached distribution certificate could not be read ({error}). Creating a new one.") + der = None + if der is not None: + certificate_id = matching_certificate_id(token, der) + if certificate_id: + print(f"Reusing Apple Distribution certificate {certificate_serial(der)}") + return certificate_id, der + print("Cached distribution certificate is not on the team anymore. Creating a new one.") + _, der, certificate_id = create_distribution_certificate(token, signing_dir, password) + print(f"Created Apple Distribution certificate {certificate_serial(der)}") + return certificate_id, der + + +def decode_provision_plist(data: bytes) -> dict: + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "profile.mobileprovision" + source.write_bytes(data) + commands = [] + if shutil.which("security"): + commands.append(["security", "cms", "-D", "-i", str(source)]) + commands.append(["openssl", "cms", "-inform", "DER", "-verify", "-noverify", "-in", str(source)]) + errors = [] + for command in commands: + result = subprocess.run(command, capture_output=True, check=False) + if result.returncode == 0 and result.stdout.strip(): + return plistlib.loads(result.stdout) + errors.append(result.stderr.decode("utf-8", "replace")) + raise RuntimeError("could not decode provisioning profile: " + " | ".join(errors)) + + +def profile_has_group(plist: dict, group: str) -> bool: + groups = (plist.get("Entitlements") or {}).get("com.apple.security.application-groups") or [] + return group in groups + + +def profile_has_certificate(plist: dict, der: bytes) -> bool: + for item in plist.get("DeveloperCertificates") or []: + if bytes(item) == der: + return True + return False + + +def app_group_settings(group: str, key: str) -> list[dict]: + return [{"key": key, "options": [{"key": group, "enabled": True}]}] + + +def ensure_app_group_capability(token: str, bundle_resource_id: str, group: str) -> None: + capabilities = list_all(token, f"/v1/bundleIds/{bundle_resource_id}/bundleIdCapabilities?limit=50") + current = next( + (item for item in capabilities if (item.get("attributes") or {}).get("capabilityType") == "APP_GROUPS"), + None, + ) + errors = [] + for key in ("APP_GROUPS", "APP_GROUP_IDS"): + settings = app_group_settings(group, key) + try: + if current is None: + api( + token, + "POST", + "/v1/bundleIdCapabilities", + { + "data": { + "type": "bundleIdCapabilities", + "attributes": {"capabilityType": "APP_GROUPS", "settings": settings}, + "relationships": { + "bundleId": {"data": {"type": "bundleIds", "id": bundle_resource_id}} + }, + } + }, + ) + else: + api( + token, + "PATCH", + f"/v1/bundleIdCapabilities/{current['id']}", + { + "data": { + "type": "bundleIdCapabilities", + "id": current["id"], + "attributes": {"capabilityType": "APP_GROUPS", "settings": settings}, + } + }, + ) + print(f"Enabled App Groups ({key}) on bundle resource {bundle_resource_id}") + return + except APIError as error: + errors.append(str(error)) + raise SystemExit("Could not enable App Groups on the App ID.\n" + "\n".join(errors)) + + +def find_bundle_id(token: str, identifier: str) -> str: + quoted = urllib.parse.quote(identifier) + found = list_all(token, f"/v1/bundleIds?filter[identifier]={quoted}&limit=5") + matches = [item for item in found if (item.get("attributes") or {}).get("identifier") == identifier] + if not matches: + raise SystemExit( + f"No App ID {identifier} on this team. The TestFlight app and share extension should already exist." + ) + return matches[0]["id"] + + +def profiles_named(token: str, name: str) -> list[dict]: + quoted = urllib.parse.quote(name) + return list_all( + token, + f"/v1/profiles?filter[name]={quoted}&filter[profileType]=IOS_APP_STORE&limit=20", + ) + + +def profile_bytes(token: str, profile_id: str) -> bytes: + payload = api(token, "GET", f"/v1/profiles/{profile_id}") + return base64.b64decode(payload["data"]["attributes"]["profileContent"]) + + +def delete_profile(token: str, profile_id: str) -> None: + try: + api(token, "DELETE", f"/v1/profiles/{profile_id}") + except APIError as error: + if error.status != 404: + raise + + +def create_profile(token: str, name: str, bundle_resource_id: str, certificate_id: str) -> bytes: + created = api( + token, + "POST", + "/v1/profiles", + { + "data": { + "type": "profiles", + "attributes": {"name": name, "profileType": "IOS_APP_STORE"}, + "relationships": { + "bundleId": {"data": {"type": "bundleIds", "id": bundle_resource_id}}, + "certificates": {"data": [{"type": "certificates", "id": certificate_id}]}, + }, + } + }, + ) + content = (created["data"].get("attributes") or {}).get("profileContent") + if not content: + return profile_bytes(token, created["data"]["id"]) + return base64.b64decode(content) + + +def usable_profile(plist: dict, group: str, der: bytes) -> bool: + return profile_has_group(plist, group) and profile_has_certificate(plist, der) + + +def ensure_profile(token: str, identifier: str, profile_name: str, certificate_id: str, der: bytes) -> bytes: + bundle_resource_id = find_bundle_id(token, identifier) + attempted_capability_fix = False + conflicts = 0 + while True: + for existing in profiles_named(token, profile_name): + data = profile_bytes(token, existing["id"]) + plist = decode_provision_plist(data) + if usable_profile(plist, APP_GROUP, der): + print(f"Reusing profile {profile_name} for {identifier}") + return data + print(f"Replacing profile {profile_name}; it is missing the app group or this certificate.") + delete_profile(token, existing["id"]) + try: + data = create_profile(token, profile_name, bundle_resource_id, certificate_id) + except APIError as error: + if error.status == 409 and conflicts < 2: + # A profile with this name still exists but was not returned by the filter. + conflicts += 1 + for existing in profiles_named(token, profile_name): + delete_profile(token, existing["id"]) + continue + raise + plist = decode_provision_plist(data) + if usable_profile(plist, APP_GROUP, der): + print(f"Created profile {profile_name} for {identifier}") + return data + if attempted_capability_fix: + entitlements = plist.get("Entitlements") or {} + raise SystemExit( + f"Profile {profile_name} still lacks {APP_GROUP}. Entitlements: {json.dumps(entitlements)}" + ) + print(f"Profile {profile_name} has no {APP_GROUP}. Updating the App ID capability and regenerating.") + for existing in profiles_named(token, profile_name): + delete_profile(token, existing["id"]) + ensure_app_group_capability(token, bundle_resource_id, APP_GROUP) + attempted_capability_fix = True + + +def install_profile(data: bytes) -> None: + plist = decode_provision_plist(data) + destination = Path.home() / "Library" / "MobileDevice" / "Provisioning Profiles" + destination.mkdir(parents=True, exist_ok=True) + (destination / f"{plist['UUID']}.mobileprovision").write_bytes(data) + print(f"Installed profile {plist.get('Name')} ({plist['UUID']})") + + +def release_xcconfig(profile_name: str) -> str: + return ( + "CODE_SIGN_STYLE = Manual\n" + "CODE_SIGN_IDENTITY = Apple Distribution\n" + f"PROVISIONING_PROFILE_SPECIFIER = {profile_name}\n" + f"DEVELOPMENT_TEAM = {TEAM_ID}\n" + ) + + +def write_export_options(path: Path, profiles: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + plistlib.dump( + { + "method": "app-store-connect", + "destination": "upload", + "signingStyle": "manual", + "signingCertificate": "Apple Distribution", + "teamID": TEAM_ID, + "manageAppVersionAndBuildNumber": False, + "provisioningProfiles": profiles, + }, + handle, + fmt=plistlib.FMT_XML, + ) + + +def import_certificate(p12: Path, password: str) -> None: + keychain = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "continuity-signing.keychain-db" + keychain_password = hashlib.sha256(os.urandom(32)).hexdigest() + if keychain.exists(): + keychain.unlink() + subprocess.run(["security", "create-keychain", "-p", keychain_password, str(keychain)], check=True) + subprocess.run(["security", "set-keychain-settings", "-lut", "21600", str(keychain)], check=True) + subprocess.run(["security", "unlock-keychain", "-p", keychain_password, str(keychain)], check=True) + subprocess.run( + ["security", "import", str(p12), "-k", str(keychain), "-P", password, "-T", "/usr/bin/codesign", "-T", "/usr/bin/security"], + check=True, + ) + subprocess.run( + [ + "security", + "set-key-partition-list", + "-S", + "apple-tool:,apple:,codesign:", + "-s", + "-k", + keychain_password, + str(keychain), + ], + check=True, + ) + subprocess.run( + ["security", "list-keychains", "-d", "user", "-s", str(keychain), "/Library/Keychains/System.keychain"], + check=True, + ) + subprocess.run(["security", "default-keychain", "-s", str(keychain)], check=True) + identities = subprocess.run( + ["security", "find-identity", "-v", "-p", "codesigning", str(keychain)], + capture_output=True, + text=True, + check=True, + ) + print(identities.stdout) + if "Apple Distribution" not in identities.stdout: + raise SystemExit("The distribution certificate imported, but codesign cannot see an Apple Distribution identity.") + + +def main() -> None: + key_id = os.environ["ASC_KEY_ID"] + issuer_id = os.environ["ASC_ISSUER_ID"] + key_path = Path(os.environ.get("ASC_KEY_PATH", Path.home() / "private_keys" / f"AuthKey_{key_id}.p8")) + api_key = key_path.read_bytes() + signing_dir = ROOT / ".signing" + signing_dir.mkdir(parents=True, exist_ok=True) + token = make_token(key_id, issuer_id, key_path) + certificate_id, der = ensure_certificate(token, signing_dir, api_key) + profiles: dict[str, str] = {} + for identifier, profile_name, xcconfig_rel in TARGETS: + data = ensure_profile(token, identifier, profile_name, certificate_id, der) + install_profile(data) + xcconfig = ROOT / xcconfig_rel + xcconfig.parent.mkdir(parents=True, exist_ok=True) + xcconfig.write_text(release_xcconfig(profile_name)) + profiles[identifier] = profile_name + write_export_options(ROOT / "Signing" / "ExportOptions.plist", profiles) + import_certificate(signing_dir / "distribution.p12", p12_password(api_key)) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_install_distribution_signing.py b/.github/scripts/test_install_distribution_signing.py new file mode 100644 index 0000000..7e52fea --- /dev/null +++ b/.github/scripts/test_install_distribution_signing.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Offline checks for the TestFlight signing helper. No network, no API key.""" + +import importlib.util +import plistlib +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("install_distribution_signing.py") +spec = importlib.util.spec_from_file_location("install_distribution_signing", SCRIPT) +assert spec and spec.loader +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class SigningHelperTests(unittest.TestCase): + def test_es256_signature_round_trips_through_openssl(self): + with tempfile.TemporaryDirectory() as tmp: + key = Path(tmp) / "key.pem" + subprocess.run( + ["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", str(key)], + check=True, + capture_output=True, + ) + message = b"continuity-signing" + raw = signing.sign_es256(key, message) + self.assertEqual(len(raw), 64) + header = signing.b64url(b'{"alg":"ES256"}') + self.assertNotIn("=", header) + token = signing.make_token("KEYID", "ISSUER", key, now=1_700_000_000) + signing_input, sig_b64 = token.rsplit(".", 1) + padded = sig_b64 + "=" * (-len(sig_b64) % 4) + import base64 + + raw_sig = base64.urlsafe_b64decode(padded) + self.assertEqual(len(raw_sig), 64) + der = raw_to_der(raw_sig) + der_path = Path(tmp) / "sig.der" + public = Path(tmp) / "public.pem" + der_path.write_bytes(der) + subprocess.run( + ["openssl", "pkey", "-in", str(key), "-pubout", "-out", str(public)], + check=True, + capture_output=True, + ) + verified = subprocess.run( + ["openssl", "dgst", "-sha256", "-verify", str(public), "-signature", str(der_path)], + input=signing_input.encode(), + capture_output=True, + check=False, + ) + self.assertEqual(verified.returncode, 0, verified.stderr.decode()) + + def test_profile_accepts_only_the_app_group_and_our_certificate(self): + der = b"\x30\x03cert" + plist = { + "Entitlements": {"com.apple.security.application-groups": [signing.APP_GROUP]}, + "DeveloperCertificates": [der], + } + self.assertTrue(signing.usable_profile(plist, signing.APP_GROUP, der)) + self.assertFalse(signing.usable_profile(plist, signing.APP_GROUP, b"other")) + plist["Entitlements"]["com.apple.security.application-groups"] = ["group.other"] + self.assertFalse(signing.profile_has_group(plist, signing.APP_GROUP)) + + def test_cms_profile_decodes_to_its_entitlements(self): + plist = { + "Name": "ContinuityCIStore", + "UUID": "00000000-0000-0000-0000-000000000001", + "Entitlements": {"com.apple.security.application-groups": [signing.APP_GROUP]}, + } + with tempfile.TemporaryDirectory() as tmp: + xml = Path(tmp) / "profile.plist" + key = Path(tmp) / "key.pem" + cert = Path(tmp) / "cert.pem" + signed = Path(tmp) / "profile.mobileprovision" + with xml.open("wb") as handle: + plistlib.dump(plist, handle, fmt=plistlib.FMT_XML) + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=test", + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "cms", + "-sign", + "-nodetach", + "-binary", + "-signer", + str(cert), + "-inkey", + str(key), + "-in", + str(xml), + "-outform", + "DER", + "-out", + str(signed), + ], + check=True, + capture_output=True, + ) + decoded = signing.decode_provision_plist(signed.read_bytes()) + self.assertEqual(decoded["Name"], "ContinuityCIStore") + self.assertTrue(signing.profile_has_group(decoded, signing.APP_GROUP)) + + def test_release_xcconfig_and_export_options_name_both_targets(self): + text = signing.release_xcconfig("ContinuityCIStore") + self.assertIn("CODE_SIGN_STYLE = Manual\n", text) + self.assertIn("CODE_SIGN_IDENTITY = Apple Distribution\n", text) + self.assertIn("PROVISIONING_PROFILE_SPECIFIER = ContinuityCIStore\n", text) + self.assertNotIn('"', text) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "ExportOptions.plist" + signing.write_export_options( + path, + { + "com.sanylax.continuity": "ContinuityCIStore", + "com.sanylax.continuity.share": "ContinuityShareCIStore", + }, + ) + with path.open("rb") as handle: + options = plistlib.load(handle) + self.assertEqual(options["signingStyle"], "manual") + self.assertEqual(options["signingCertificate"], "Apple Distribution") + self.assertEqual(options["provisioningProfiles"]["com.sanylax.continuity.share"], "ContinuityShareCIStore") + + def test_serial_normalization_ignores_colons_and_padding(self): + self.assertEqual(signing.normalize_serial("00:AB:0C"), "ab0c") + + def test_p12_export_round_trips_the_certificate(self): + with tempfile.TemporaryDirectory() as tmp: + key = Path(tmp) / "key.pem" + cert = Path(tmp) / "cert.pem" + p12 = Path(tmp) / "cert.p12" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=Continuity CI Distribution", + ], + check=True, + capture_output=True, + ) + password = "test-password" + signing.export_p12(key, cert, p12, password) + der = signing.p12_certificate_der(p12, password) + self.assertTrue(der.startswith(b"\x30")) + self.assertTrue(signing.certificate_serial(der)) + + def test_password_is_stable_and_not_the_key_itself(self): + key = b"not-a-real-p8" + self.assertEqual(signing.p12_password(key), signing.p12_password(key)) + self.assertNotIn("not-a-real-p8", signing.p12_password(key)) + + +def raw_to_der(raw: bytes) -> bytes: + def encode(component: bytes) -> bytes: + component = component.lstrip(b"\x00") or b"\x00" + if component[0] & 0x80: + component = b"\x00" + component + return bytes([0x02, len(component)]) + component + + body = encode(raw[:32]) + encode(raw[32:]) + return bytes([0x30, len(body)]) + body + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 42df7e2..fb0682f 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -1,21 +1,21 @@ # Publishes every merge to main to internal TestFlight. # -# Signing is CLOUD-MANAGED: xcodebuild -allowProvisioningUpdates + an App Store Connect API -# key mints/updates certificates and profiles on the fly — no certificates or provisioning -# profiles are stored in the repo or in secrets. Three repository secrets are required: +# Signing uses the App Store Connect API key (Admin role) to install one Apple +# Distribution certificate and two App Store profiles that include the app group. +# No certificates or profiles are stored in the repo. Three repository secrets: # # ASC_KEY_ID App Store Connect API key ID (App Store Connect → Users and # ASC_ISSUER_ID App Store Connect API issuer ID Access → Integrations → App Store # ASC_KEY_P8 the .p8 private key, base64-encoded Connect API → create a key with -# "App Manager" role) +# Admin role) # base64 encode: base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy # -# Archive signing is per-target (Continuity + ContinuityShare: Manual + Apple Distribution -# in project.yml Release). Do NOT pass CODE_SIGN_IDENTITY / CODE_SIGN_STYLE as xcodebuild -# xcargs — Xcode applies those to every target, including YouTubeKit's resource bundle, -# and Xcode 26 treats Automatic + a forced Distribution identity as a hard error -# ("conflicting provisioning settings"). Cloud signing (-allowProvisioningUpdates + -# the ASC API key) mints the App Store profiles. The export step re-signs the IPA. +# Do NOT pass CODE_SIGN_IDENTITY / CODE_SIGN_STYLE as xcodebuild xcargs — Xcode applies +# those to every target, including YouTubeKit's resource bundle, and Xcode 26 treats +# Automatic + a forced Distribution identity as a hard error. The install script writes +# Manual signing into the two app targets' Release xcconfigs only. Plain Automatic +# signing is also wrong here: ephemeral runners minted a new Apple Development +# certificate every run until the account hit the cap. # # CFBundleVersion is 1000 + github.run_number. TestFlight already has build 130 (Xcode Cloud, # Jul 18) while run_number is still in the 30s — a raw run_number would be rejected as older. @@ -40,6 +40,9 @@ jobs: if: ${{ vars.TESTFLIGHT_ENABLED != 'false' }} runs-on: macos-26 timeout-minutes: 90 + permissions: + contents: read + actions: write steps: - uses: actions/checkout@v4 @@ -49,9 +52,6 @@ jobs: - name: Install XcodeGen run: brew install xcodegen - - name: Generate project - run: xcodegen generate - - name: Write App Store Connect API key env: ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} @@ -65,26 +65,49 @@ jobs: mkdir -p "$HOME/private_keys" echo "$ASC_KEY_P8" | base64 --decode > "$HOME/private_keys/AuthKey_${ASC_KEY_ID}.p8" - - name: Archive + - name: Restore distribution certificate + uses: actions/cache/restore@v4 + with: + path: .signing/distribution.p12 + key: continuity-distribution-${{ github.run_id }} + restore-keys: continuity-distribution- + + - name: Install App Store signing env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: python3 .github/scripts/install_distribution_signing.py + + - name: Generate project + run: xcodegen generate + + - name: Archive run: | - set -eu + set -euo pipefail BUILD_NUMBER=$((1000 + ${{ github.run_number }})) echo "CFBundleVersion=${BUILD_NUMBER}" + xcodebuild -showBuildSettings \ + -project Continuity.xcodeproj \ + -scheme Continuity \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + | tee /tmp/continuity-settings.txt + xcodebuild -showBuildSettings \ + -project Continuity.xcodeproj \ + -target ContinuityShare \ + -configuration Release \ + | tee /tmp/share-settings.txt + grep -E "CODE_SIGN_STYLE|CODE_SIGN_IDENTITY|PROVISIONING_PROFILE_SPECIFIER" /tmp/continuity-settings.txt /tmp/share-settings.txt + grep -q "CODE_SIGN_STYLE = Manual" /tmp/continuity-settings.txt + grep -q "PROVISIONING_PROFILE_SPECIFIER = ContinuityCIStore" /tmp/continuity-settings.txt + grep -q "PROVISIONING_PROFILE_SPECIFIER = ContinuityShareCIStore" /tmp/share-settings.txt xcodebuild archive \ -project Continuity.xcodeproj \ -scheme Continuity \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath build/Continuity.xcarchive \ - CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ - DEVELOPMENT_TEAM=KP832RV67A \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$HOME/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ - -authenticationKeyID "$ASC_KEY_ID" \ - -authenticationKeyIssuerID "$ASC_ISSUER_ID" + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" - name: Upload to TestFlight env: @@ -92,31 +115,18 @@ jobs: ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} run: | set -eu - cat > ExportOptions.plist <<'EOF' - - - - - method - app-store-connect - destination - upload - signingStyle - automatic - signingCertificate - Apple Distribution - teamID - KP832RV67A - manageAppVersionAndBuildNumber - - - - EOF xcodebuild -exportArchive \ -archivePath build/Continuity.xcarchive \ - -exportOptionsPlist ExportOptions.plist \ + -exportOptionsPlist Signing/ExportOptions.plist \ -exportPath build/export \ - -allowProvisioningUpdates \ -authenticationKeyPath "$HOME/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ -authenticationKeyID "$ASC_KEY_ID" \ -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + - name: Save distribution certificate + if: always() && hashFiles('.signing/created-new') != '' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: .signing/distribution.p12 + key: continuity-distribution-${{ github.run_id }} diff --git a/.gitignore b/.gitignore index 58910b1..aa8a73e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ xcuserdata/ *.m4a *.caf +# TestFlight CI signing material. The .p12 stays in the Actions cache. +.signing/ +Signing/ExportOptions.plist + # Python (ASC listing script) __pycache__/ *.py[cod] diff --git a/Signing/Continuity.release.xcconfig b/Signing/Continuity.release.xcconfig new file mode 100644 index 0000000..46a2191 --- /dev/null +++ b/Signing/Continuity.release.xcconfig @@ -0,0 +1,3 @@ +// Local archives stay on Automatic signing from project.yml. +// The TestFlight workflow overwrites this file with Manual signing and an +// App Store profile name before it runs xcodegen. diff --git a/Signing/ContinuityShare.release.xcconfig b/Signing/ContinuityShare.release.xcconfig new file mode 100644 index 0000000..46a2191 --- /dev/null +++ b/Signing/ContinuityShare.release.xcconfig @@ -0,0 +1,3 @@ +// Local archives stay on Automatic signing from project.yml. +// The TestFlight workflow overwrites this file with Manual signing and an +// App Store profile name before it runs xcodegen. diff --git a/project.yml b/project.yml index 5e70c24..763868d 100644 --- a/project.yml +++ b/project.yml @@ -72,6 +72,11 @@ targets: gatherCoverageData: false archive: config: Release + # Release signing lives in this xcconfig so it does not land on YouTubeKit. + # The committed file is empty (local archives stay Automatic). TestFlight + # overwrites it with Manual + an App Store profile before xcodegen. + configFiles: + Release: Signing/Continuity.release.xcconfig settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.sanylax.continuity @@ -81,13 +86,6 @@ targets: ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ENABLE_PREVIEWS: YES SWIFT_EMIT_LOC_STRINGS: YES - configs: - # Per-target so Distribution does not leak onto YouTubeKit's resource bundle. - # Automatic + "Apple Distribution" is a hard error in Xcode 26 - # ("conflicting provisioning settings"). - Release: - CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: Apple Distribution # onnxruntime SPM ships a *static* .framework (Mach-O archive, not a dylib). Xcode still # copies a broken ~50 KB stub into Continuity.app/Frameworks. That stub: # - fails code-sign / ITMS MinimumOSVersion checks (microsoft/onnxruntime#27396) @@ -145,13 +143,11 @@ targets: # WebURL miss Spotify/YouTube shares that also attach image/text. Runtime still # rejects non-music links via ContinuityCore before showing success. NSExtensionActivationRule: 'SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text").@count >= 1).@count >= 1' + configFiles: + Release: Signing/ContinuityShare.release.xcconfig settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.sanylax.continuity.share GENERATE_INFOPLIST_FILE: NO TARGETED_DEVICE_FAMILY: "1" SWIFT_EMIT_LOC_STRINGS: YES - configs: - Release: - CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: Apple Distribution