Fix iOS App Store upload validation
This commit is contained in:
parent
fd9ea84ea5
commit
0924e40463
7 changed files with 115 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue