diff --git a/.forgejo/workflows/apple-distribute-testers.yml b/.forgejo/workflows/apple-distribute-testers.yml index 7997e17..f27ad96 100644 --- a/.forgejo/workflows/apple-distribute-testers.yml +++ b/.forgejo/workflows/apple-distribute-testers.yml @@ -458,11 +458,19 @@ jobs: find publish -type f -print | sort exit 1 fi + upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-ios-${BUILD_NUMBER}.log" + set +e xcrun altool --upload-app \ --type ios \ --file "$ipa" \ --apiKey "$ASC_API_KEY_ID" \ - --apiIssuer "$ASC_API_ISSUER_ID" + --apiIssuer "$ASC_API_ISSUER_ID" 2>&1 | tee "$upload_log" + altool_status=${PIPESTATUS[0]} + set -e + if (( altool_status != 0 )) || grep -Eq 'UPLOAD FAILED|Validation failed|ERROR:' "$upload_log"; then + echo "::error ::altool reported upload validation failure for $ipa" >&2 + exit 1 + fi - name: Distribute iOS Build To TestFlight shell: bash diff --git a/.forgejo/workflows/publish-store-uploads.yml b/.forgejo/workflows/publish-store-uploads.yml index e1994a4..1d59954 100644 --- a/.forgejo/workflows/publish-store-uploads.yml +++ b/.forgejo/workflows/publish-store-uploads.yml @@ -212,11 +212,19 @@ jobs: if [[ "$artifact" == *.pkg ]]; then upload_type="macos" fi + upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-${upload_type}-$(basename "$artifact").log" + set +e xcrun altool --upload-app \ --type "$upload_type" \ --file "$artifact" \ --apiKey "$ASC_API_KEY_ID" \ - --apiIssuer "$ASC_API_ISSUER_ID" + --apiIssuer "$ASC_API_ISSUER_ID" 2>&1 | tee "$upload_log" + altool_status=${PIPESTATUS[0]} + set -e + if (( altool_status != 0 )) || grep -Eq 'UPLOAD FAILED|Validation failed|ERROR:' "$upload_log"; then + echo "::error ::altool reported upload validation failure for $artifact" >&2 + exit 1 + fi done release-ios-testflight: diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index ab3f374..71de427 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; }; + D0D4E5812C8D9C94007F820A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0D4E4A12C8D921A007F820A /* Assets.xcassets */; }; D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; }; D0D4E58A2C8D9C9E007F820A /* BurrowUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D0D4E58B2C8D9CA4007F820A /* BurrowConfiguration.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -605,6 +606,7 @@ buildActionMask = 2147483647; files = ( D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */, + D0D4E5812C8D9C94007F820A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Scripts/apple/profile-entitlements.py b/Scripts/apple/profile-entitlements.py index 6e52d70..26bd10d 100755 --- a/Scripts/apple/profile-entitlements.py +++ b/Scripts/apple/profile-entitlements.py @@ -9,6 +9,14 @@ import subprocess import sys from pathlib import Path +PROFILE_REQUIRED_KEYS = { + "application-identifier", + "beta-reports-active", + "com.apple.developer.team-identifier", + "com.apple.developer.default-data-protection", + "keychain-access-groups", +} + def decode_profile(path: Path) -> dict: commands = [ @@ -33,10 +41,66 @@ def decode_profile(path: Path) -> dict: raise RuntimeError(f"unable to decode provisioning profile {path}: {'; '.join(errors)}") +def load_entitlements_template(path: Path) -> dict: + value = plistlib.loads(path.read_bytes()) + if not isinstance(value, dict): + raise RuntimeError(f"entitlements template is not a dictionary: {path}") + return value + + +def _has_build_setting(value: object) -> bool: + if isinstance(value, str): + return "$(" in value + if isinstance(value, list): + return any(_has_build_setting(item) for item in value) + if isinstance(value, dict): + return any(_has_build_setting(item) for item in value.values()) + return False + + +def _filter_list(template_value: list, profile_value: object) -> list: + if not isinstance(profile_value, list): + return template_value + profile_set = set(profile_value) + filtered = [item for item in template_value if item in profile_set] + return filtered + + +def filter_entitlements(profile_entitlements: dict, template_entitlements: dict) -> dict: + filtered = { + key: profile_entitlements[key] + for key in PROFILE_REQUIRED_KEYS + if key in profile_entitlements + } + + for key, template_value in template_entitlements.items(): + if key not in profile_entitlements: + print( + f"warning: entitlement {key} requested by template but absent from provisioning profile; omitting", + file=sys.stderr, + ) + continue + + profile_value = profile_entitlements[key] + if _has_build_setting(template_value): + filtered[key] = profile_value + elif isinstance(template_value, list): + filtered[key] = _filter_list(template_value, profile_value) + else: + filtered[key] = template_value + + return dict(sorted(filtered.items())) + + def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("profile", type=Path) parser.add_argument("--output", "-o", type=Path, required=True) + parser.add_argument( + "--template", + type=Path, + help="Optional entitlements template used to filter profile entitlements for a target.", + ) args = parser.parse_args(argv) profile = decode_profile(args.profile) @@ -44,6 +108,9 @@ def main(argv: list[str]) -> int: if not isinstance(entitlements, dict): raise RuntimeError(f"profile has no Entitlements dictionary: {args.profile}") + if args.template: + entitlements = filter_entitlements(entitlements, load_entitlements_template(args.template)) + args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML)) return 0 diff --git a/Scripts/apple/sign-release-artifacts-kms.sh b/Scripts/apple/sign-release-artifacts-kms.sh index 2c3284a..526c6d6 100755 --- a/Scripts/apple/sign-release-artifacts-kms.sh +++ b/Scripts/apple/sign-release-artifacts-kms.sh @@ -66,7 +66,12 @@ require_tool() { extract_profile_entitlements() { local profile="$1" local output="$2" - Scripts/apple/profile-entitlements.py "$profile" --output "$output" + local template="${3:-}" + local args=("$profile" --output "$output") + if [[ -n "$template" ]]; then + args+=(--template "$template") + fi + Scripts/apple/profile-entitlements.py "${args[@]}" } sign_bundle() { @@ -128,8 +133,8 @@ sign_ios() { cp "$ext_profile" "${ext}/embedded.mobileprovision" app_entitlements="${work_dir}/app-entitlements.plist" ext_entitlements="${work_dir}/extension-entitlements.plist" - extract_profile_entitlements "$app_profile" "$app_entitlements" - extract_profile_entitlements "$ext_profile" "$ext_entitlements" + extract_profile_entitlements "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements + extract_profile_entitlements "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" sign_bundle ios "$ext" "$ext_entitlements" diff --git a/Scripts/ci/package-apple-artifacts.sh b/Scripts/ci/package-apple-artifacts.sh index 87eed26..c910f0d 100755 --- a/Scripts/ci/package-apple-artifacts.sh +++ b/Scripts/ci/package-apple-artifacts.sh @@ -71,7 +71,12 @@ profile_for() { generate_entitlements_from_profile() { local profile="$1" local output="$2" - Scripts/apple/profile-entitlements.py "$profile" --output "$output" + local template="${3:-}" + local args=("$profile" --output "$output") + if [[ -n "$template" ]]; then + args+=(--template "$template") + fi + Scripts/apple/profile-entitlements.py "${args[@]}" } apple_signing_available() { @@ -331,8 +336,8 @@ build_ios_kms_release() { app_entitlements="${apple_root}/ios-kms/app-entitlements.plist" ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist" - generate_entitlements_from_profile "$app_profile" "$app_entitlements" - generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" + generate_entitlements_from_profile "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements + generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" kms_sign_bundle ios "$ext" "$ext_entitlements" diff --git a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md index cb32d8c..30e8a37 100644 --- a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md +++ b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md @@ -233,6 +233,18 @@ The same change also makes the forge itself the release authority: release tags - KMS-backed Apple certificates do not satisfy the existing Xcode/keychain import path. Shipping with these certificates requires a signer that can delegate Apple code-signing operations to Google KMS. +- iOS KMS signing must filter provisioning-profile entitlements through the + checked-in iOS entitlement templates before signing. App Store Connect + validates the bundle signature, not just the profile, and rejects profile-only + capabilities such as system extensions or unsupported NetworkExtension values + when they leak into an iOS app or extension signature. +- The App target must keep `Assets.xcassets` in its Resources phase. Xcode + derives the `CFBundleIcons` metadata and standalone App Store icon PNGs from + that resource membership during iOS builds. +- App Store Connect upload steps must fail on `altool` validation output as + well as non-zero process exits. Apple can emit a textual upload failure before + the workflow reaches TestFlight processing, and downstream tester waits must + not run unless the IPA upload was actually accepted. - If the first key ring or Developer ID key is bootstrapped before the OpenTofu remote state backend is available locally, the live resources must be imported into `infra/identity` before enabling managed identity applies.