Fix iOS App Store upload validation
Some checks failed
Build Rust / Cargo Test (push) Successful in 4m1s
Lint Governance / BEP Metadata (push) Successful in 1s
Build Site / Next.js Build (push) Failing after 4s

This commit is contained in:
Conrad Kramer 2026-06-07 20:11:38 -07:00
parent fd9ea84ea5
commit 0924e40463
7 changed files with 115 additions and 8 deletions

View file

@ -458,11 +458,19 @@ jobs:
find publish -type f -print | sort find publish -type f -print | sort
exit 1 exit 1
fi fi
upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-ios-${BUILD_NUMBER}.log"
set +e
xcrun altool --upload-app \ xcrun altool --upload-app \
--type ios \ --type ios \
--file "$ipa" \ --file "$ipa" \
--apiKey "$ASC_API_KEY_ID" \ --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 - name: Distribute iOS Build To TestFlight
shell: bash shell: bash

View file

@ -212,11 +212,19 @@ jobs:
if [[ "$artifact" == *.pkg ]]; then if [[ "$artifact" == *.pkg ]]; then
upload_type="macos" upload_type="macos"
fi fi
upload_log="${RUNNER_TEMP:-/tmp}/burrow-altool-${upload_type}-$(basename "$artifact").log"
set +e
xcrun altool --upload-app \ xcrun altool --upload-app \
--type "$upload_type" \ --type "$upload_type" \
--file "$artifact" \ --file "$artifact" \
--apiKey "$ASC_API_KEY_ID" \ --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 done
release-ios-testflight: release-ios-testflight:

View file

@ -37,6 +37,7 @@
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; }; D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; };
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; }; D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; };
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.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 */; }; 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, ); }; }; 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, ); }; }; D0D4E58B2C8D9CA4007F820A /* BurrowConfiguration.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -605,6 +606,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */, D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */,
D0D4E5812C8D9C94007F820A /* Assets.xcassets in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -9,6 +9,14 @@ import subprocess
import sys import sys
from pathlib import Path 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: def decode_profile(path: Path) -> dict:
commands = [ commands = [
@ -33,10 +41,66 @@ def decode_profile(path: Path) -> dict:
raise RuntimeError(f"unable to decode provisioning profile {path}: {'; '.join(errors)}") 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: def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("profile", type=Path) parser.add_argument("profile", type=Path)
parser.add_argument("--output", "-o", type=Path, required=True) 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) args = parser.parse_args(argv)
profile = decode_profile(args.profile) profile = decode_profile(args.profile)
@ -44,6 +108,9 @@ def main(argv: list[str]) -> int:
if not isinstance(entitlements, dict): if not isinstance(entitlements, dict):
raise RuntimeError(f"profile has no Entitlements dictionary: {args.profile}") 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.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML)) args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML))
return 0 return 0

View file

@ -66,7 +66,12 @@ require_tool() {
extract_profile_entitlements() { extract_profile_entitlements() {
local profile="$1" local profile="$1"
local output="$2" 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() { sign_bundle() {
@ -128,8 +133,8 @@ sign_ios() {
cp "$ext_profile" "${ext}/embedded.mobileprovision" cp "$ext_profile" "${ext}/embedded.mobileprovision"
app_entitlements="${work_dir}/app-entitlements.plist" app_entitlements="${work_dir}/app-entitlements.plist"
ext_entitlements="${work_dir}/extension-entitlements.plist" ext_entitlements="${work_dir}/extension-entitlements.plist"
extract_profile_entitlements "$app_profile" "$app_entitlements" extract_profile_entitlements "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements
extract_profile_entitlements "$ext_profile" "$ext_entitlements" extract_profile_entitlements "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements
rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature"
sign_bundle ios "$ext" "$ext_entitlements" sign_bundle ios "$ext" "$ext_entitlements"

View file

@ -71,7 +71,12 @@ profile_for() {
generate_entitlements_from_profile() { generate_entitlements_from_profile() {
local profile="$1" local profile="$1"
local output="$2" 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() { apple_signing_available() {
@ -331,8 +336,8 @@ build_ios_kms_release() {
app_entitlements="${apple_root}/ios-kms/app-entitlements.plist" app_entitlements="${apple_root}/ios-kms/app-entitlements.plist"
ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist" ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist"
generate_entitlements_from_profile "$app_profile" "$app_entitlements" generate_entitlements_from_profile "$app_profile" "$app_entitlements" Apple/App/App-iOS.entitlements
generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" generate_entitlements_from_profile "$ext_profile" "$ext_entitlements" Apple/NetworkExtension/NetworkExtension-iOS.entitlements
rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature" rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature"
kms_sign_bundle ios "$ext" "$ext_entitlements" kms_sign_bundle ios "$ext" "$ext_entitlements"

View file

@ -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 - KMS-backed Apple certificates do not satisfy the existing Xcode/keychain
import path. Shipping with these certificates requires a signer that can import path. Shipping with these certificates requires a signer that can
delegate Apple code-signing operations to Google KMS. 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 - 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 OpenTofu remote state backend is available locally, the live resources must be
imported into `infra/identity` before enabling managed identity applies. imported into `infra/identity` before enabling managed identity applies.