fix(ios): harden Xcode Cloud store builds and auto-upload

Use Automatic signing and Cloud lifecycle scripts so App Store IPAs get
correct build numbers and reach ASC even when secondary exports fail.
This commit is contained in:
tianma-if committed 2026-08-07 22:51:36 +08:00
1 parent 7ceed4ea16
commit 73d3bad9a3
13 files changed
+521 -76

No files matched your search

+7 -1
View File
@@ -1,5 +1,11 @@
// Bump MARKETING_VERSION with monorepo release X.Y.Z when shipping App Store builds.
// CURRENT_PROJECT_VERSION must increase for every store upload (TestFlight / App Store).
// Build 30+ marks the native SwiftUI client (RN EAS builds ended at 29).
//
// Xcode Cloud overwrites CFBundleVersion with its managed "next build number"
// (App Store Connect → Xcode Cloud → Settings → Build Number). Keep that value
// ≥ this file and ≥ the latest ASC build. ci_pre_xcodebuild.sh also stamps
// CI_BUILD_NUMBER into the archive when present.
// Last ASC store build: 32 (2026-08-07, Cloud + altool). Floor for next Cloud run: 33+.
MARKETING_VERSION = 1.12.6
CURRENT_PROJECT_VERSION = 31
CURRENT_PROJECT_VERSION = 33
+9 -8
View File
@@ -524,9 +524,11 @@
};
7B4AF236874DD8459683F02B = {
DevelopmentTeam = 9KA3NM38B6;
ProvisioningStyle = Automatic;
};
E55B18ED6114D23320B85FA0 = {
DevelopmentTeam = 9KA3NM38B6;
ProvisioningStyle = Automatic;
};
};
};
@@ -667,15 +669,13 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9KA3NM38B6;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = "org.edgeever.mobile.expo-sharing-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "*[expo] org.edgeever.mobile.expo-sharing-extension AppStore 2026-08-01T04:09:04.014Z";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -718,7 +718,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 9KA3NM38B6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -748,8 +748,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = EdgeEver/Resources/EdgeEver.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9KA3NM38B6;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -760,7 +760,6 @@
);
PRODUCT_BUNDLE_IDENTIFIER = org.edgeever.mobile;
PRODUCT_NAME = EdgeEver;
PROVISIONING_PROFILE_SPECIFIER = "*[expo] org.edgeever.mobile AppStore 2026-08-01T04:08:38.265Z";
SDKROOT = iphoneos;
SWIFT_STRICT_CONCURRENCY = complete;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -803,7 +802,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 9KA3NM38B6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -838,6 +837,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9KA3NM38B6;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
@@ -891,6 +891,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = EdgeEver/Resources/EdgeEver.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9KA3NM38B6;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -0,0 +1,9 @@
{
"id" : "2b362ad9-6c95-487d-ab5d-1789d9155dc7",
"targets" : [
{
"id" : "54d8f7fd-92fc-4182-bb3f-83b8d3a89163",
"name" : "EdgeEver"
}
]
}
+16 -3
View File
@@ -83,7 +83,9 @@ xcrun simctl launch booted org.edgeever.mobile \
| Field | File | Notes |
| --- | --- | --- |
| `MARKETING_VERSION` | `Config/Version.xcconfig` | Align with monorepo release `X.Y.Z` on store submissions |
| `CURRENT_PROJECT_VERSION` | `Config/Version.xcconfig` | Monotonic build number for every TestFlight / App Store upload |
| `CURRENT_PROJECT_VERSION` | `Config/Version.xcconfig` | Local floor for build numbers |
**Xcode Cloud** stamps `CFBundleVersion` from App Store Connect → Xcode Cloud → **Build Number** (not only this file). Before a Cloud store run, set that counter **above** the latest ASC build (`Scripts/ensure-xcode-cloud-build-number.sh` prints the recommended value).
## App Store archive
@@ -92,8 +94,18 @@ xcrun simctl launch booted org.edgeever.mobile \
If the Mac runs **macOS beta**, local archives are rejected by App Store Connect (**ITMS-90111**) even with release Xcode. Day-to-day development stays local; **only store / TestFlight binaries** should use **Xcode Cloud** (25 compute hours/month with Apple Developer Program).
- Setup and workflow: **[`docs/ios-xcode-cloud.md`](../../docs/ios-xcode-cloud.md)**
- Cloud prepare script: `ci_scripts/ci_post_clone.sh` (EditorBundle + `xcodegen`)
- Cloud hooks: `ci_scripts/ci_post_clone.sh` → `ci_pre_xcodebuild.sh` → `ci_post_xcodebuild.sh`
- Workflow start condition must stay **Manual** so hours are not burned on every push
- Put ASC API secrets in Xcode Cloud **shared environment variables** so `ci_post_xcodebuild.sh` auto-uploads the App Store IPA
```sh
# Before Start Build (optional but recommended)
cd apps/ios
bash Scripts/ensure-xcode-cloud-build-number.sh
# If Cloud produced ARCHIVE_EXPORT but ASC has no build:
bash Scripts/upload-app-store-ipa.sh /path/to/EdgeEver-*-app-store.zip
```
### Local archive (release macOS only)
@@ -102,11 +114,12 @@ Use the stable Xcode app (not beta) on a **non-beta** host OS:
```sh
cd apps/ios
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer bash Scripts/archive-app-store.sh
bash Scripts/upload-app-store-ipa.sh build/export/EdgeEver.ipa
```
On a beta host the script **exits** unless you set `EDGE_EVER_IOS_ALLOW_BETA_HOST=1` (upload will still likely fail ASC).
The script builds the TipTap EditorBundle, regenerates the Xcode project, archives with the App Store distribution profiles, and exports `build/export/EdgeEver.ipa`. Upload with `xcrun altool` / Transporter (or use Cloud upload), then submit the exact build with:
Signing in the Xcode project is **Automatic** (Cloud-friendly). Local export still uses `ExportOptions.plist` with App Store profiles. Then submit the exact build with:
```sh
cd apps/ios
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Ensure App Store Connect "Xcode Cloud next build number" is high enough
# before starting a Cloud archive. Cloud overwrites CFBundleVersion with that
# value; if it is lower than the latest ASC build, the IPA cannot be used.
#
# Required env:
# APP_STORE_CONNECT_API_KEY_ID
# APP_STORE_CONNECT_API_ISSUER_ID
# APP_STORE_CONNECT_API_KEY_P8_BASE64 OR ~/.appstoreconnect/private_keys/AuthKey_<id>.p8
#
# Optional:
# APP_STORE_CONNECT_APP_ID (default: 6792625631 EdgeEver)
# EDGE_EVER_IOS_MIN_BUILD (override floor; else Config/Version.xcconfig)
# EDGE_EVER_IOS_APPLY=1 (default) also rewrite Version.xcconfig to recommended next
#
# Usage (from apps/ios):
# bash Scripts/ensure-xcode-cloud-build-number.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP_ID="${APP_STORE_CONNECT_APP_ID:-6792625631}"
KEY_ID="${APP_STORE_CONNECT_API_KEY_ID:?APP_STORE_CONNECT_API_KEY_ID required}"
ISSUER="${APP_STORE_CONNECT_API_ISSUER_ID:?APP_STORE_CONNECT_API_ISSUER_ID required}"
APPLY="${EDGE_EVER_IOS_APPLY:-1}"
KEY_DIR="${HOME}/.appstoreconnect/private_keys"
mkdir -p "$KEY_DIR"
KEY_FILE="${KEY_DIR}/AuthKey_${KEY_ID}.p8"
if [[ ! -f "$KEY_FILE" ]]; then
P8_B64="${APP_STORE_CONNECT_API_KEY_P8_BASE64:?Need $KEY_FILE or APP_STORE_CONNECT_API_KEY_P8_BASE64}"
printf '%s' "$P8_B64" | base64 -d >"$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
FLOOR="${EDGE_EVER_IOS_MIN_BUILD:-}"
if [[ -z "$FLOOR" && -f "$ROOT/Config/Version.xcconfig" ]]; then
FLOOR="$(sed -nE 's/^CURRENT_PROJECT_VERSION[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' "$ROOT/Config/Version.xcconfig" | head -1)"
fi
FLOOR="${FLOOR:-1}"
export EDGE_EVER_ASC_KEY_ID="$KEY_ID"
export EDGE_EVER_ASC_ISSUER="$ISSUER"
export EDGE_EVER_ASC_KEY_FILE="$KEY_FILE"
export EDGE_EVER_ASC_APP_ID="$APP_ID"
export EDGE_EVER_ASC_FLOOR="$FLOOR"
export EDGE_EVER_ASC_APPLY="$APPLY"
export EDGE_EVER_IOS_ROOT="$ROOT"
python3 <<'PY'
import json, os, pathlib, re, sys, time, urllib.request, urllib.error
def ensure_jwt():
try:
import jwt # type: ignore
return jwt
except ImportError:
import subprocess
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "PyJWT", "cryptography", "-q"],
stdout=subprocess.DEVNULL,
)
import jwt # type: ignore
return jwt
jwt = ensure_jwt()
key_id = os.environ["EDGE_EVER_ASC_KEY_ID"]
issuer = os.environ["EDGE_EVER_ASC_ISSUER"]
key_file = pathlib.Path(os.environ["EDGE_EVER_ASC_KEY_FILE"])
app_id = os.environ["EDGE_EVER_ASC_APP_ID"]
floor = int(os.environ["EDGE_EVER_ASC_FLOOR"])
apply = os.environ.get("EDGE_EVER_ASC_APPLY", "1") == "1"
ios_root = pathlib.Path(os.environ["EDGE_EVER_IOS_ROOT"])
private_key = key_file.read_text()
now = int(time.time())
token = jwt.encode(
{"iss": issuer, "iat": now, "exp": now + 20 * 60, "aud": "appstoreconnect-v1"},
private_key,
algorithm="ES256",
headers={"kid": key_id, "typ": "JWT"},
)
if isinstance(token, bytes):
token = token.decode()
def api(method, path, body=None):
url = "https://api.appstoreconnect.apple.com" + path
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
err = e.read().decode()
try:
err = json.loads(err)
except Exception:
pass
raise SystemExit(f"API {method} {path} -> {e.code}: {err}")
st, builds = api(
"GET",
f"/v1/builds?filter[app]={app_id}&sort=-uploadedDate&limit=30&fields[builds]=version,processingState",
)
latest = 0
for d in builds.get("data", []):
try:
latest = max(latest, int(str(d["attributes"]["version"])))
except (KeyError, TypeError, ValueError):
pass
desired = max(floor, latest + 1)
print(f"latest_asc_build={latest}")
print(f"local_floor={floor}")
print(f"recommended_next_cloud_build={desired}")
print(
"Set App Store Connect → EdgeEver → Xcode Cloud → Settings → Build Number → "
f"next = {desired} (or higher) before Start Build."
)
# Best-effort: list ciProducts for this app (manual UI remains source of truth for next number)
st, products = api("GET", f"/v1/apps/{app_id}/ciProducts?limit=10")
if isinstance(products, dict):
for p in products.get("data", []):
attrs = p.get("attributes") or {}
print(f"ciProduct id={p.get('id')} name={attrs.get('name')} productType={attrs.get('productType')}")
if apply:
version_file = ios_root / "Config" / "Version.xcconfig"
if version_file.is_file():
text = version_file.read_text()
new_text, n = re.subn(
r"^(CURRENT_PROJECT_VERSION\s*=\s*)\d+",
rf"\g<1>{desired}",
text,
count=1,
flags=re.M,
)
if n and new_text != text:
version_file.write_text(new_text)
print(f"Updated {version_file} CURRENT_PROJECT_VERSION={desired}")
else:
print(f"Version.xcconfig already at CURRENT_PROJECT_VERSION>={desired} or unchanged")
yml = ios_root / "project.yml"
if yml.is_file():
ytext = yml.read_text()
ynew, yn = re.subn(
r"(CURRENT_PROJECT_VERSION:\s*)\d+",
rf"\g<1>{desired}",
ytext,
count=1,
)
if yn and ynew != ytext:
yml.write_text(ynew)
print(f"Updated {yml} CURRENT_PROJECT_VERSION={desired}")
print("OK")
print(f"NEXT_BUILD={desired}")
PY
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Upload an already-exported App Store IPA to App Store Connect (altool).
# Use after Xcode Cloud ARCHIVE_EXPORT download, or after local archive-app-store.sh.
#
# Required env:
# APP_STORE_CONNECT_API_KEY_ID
# APP_STORE_CONNECT_API_ISSUER_ID
# APP_STORE_CONNECT_API_KEY_P8_BASE64 OR key file at:
# ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
#
# Usage:
# bash Scripts/upload-app-store-ipa.sh /path/to/EdgeEver.ipa
# bash Scripts/upload-app-store-ipa.sh /path/to/app-store.zip # zip from Cloud
set -euo pipefail
INPUT="${1:-}"
if [[ -z "$INPUT" || ! -e "$INPUT" ]]; then
echo "Usage: $0 <app.ipa|app-store-export.zip>" >&2
exit 1
fi
KEY_ID="${APP_STORE_CONNECT_API_KEY_ID:?APP_STORE_CONNECT_API_KEY_ID required}"
ISSUER="${APP_STORE_CONNECT_API_ISSUER_ID:?APP_STORE_CONNECT_API_ISSUER_ID required}"
KEY_DIR="${HOME}/.appstoreconnect/private_keys"
mkdir -p "$KEY_DIR"
KEY_FILE="${KEY_DIR}/AuthKey_${KEY_ID}.p8"
if [[ ! -f "$KEY_FILE" ]]; then
P8_B64="${APP_STORE_CONNECT_API_KEY_P8_BASE64:?Need API key file at $KEY_FILE or APP_STORE_CONNECT_API_KEY_P8_BASE64}"
printf '%s' "$P8_B64" | base64 -d >"$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
IPA=""
WORKDIR=""
cleanup() {
if [[ -n "$WORKDIR" && -d "$WORKDIR" ]]; then
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT
if [[ "$INPUT" == *.ipa ]]; then
IPA="$INPUT"
elif [[ "$INPUT" == *.zip ]]; then
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/edgeever-ipa.XXXXXX")"
unzip -q -o "$INPUT" -d "$WORKDIR"
IPA="$(find "$WORKDIR" -name '*.ipa' | head -1 || true)"
if [[ -z "$IPA" ]]; then
echo "No .ipa inside $INPUT" >&2
exit 1
fi
else
echo "Expected .ipa or .zip, got: $INPUT" >&2
exit 1
fi
if [[ -z "${DEVELOPER_DIR:-}" && -d /Applications/Xcode.app/Contents/Developer ]]; then
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
fi
echo "IPA=$IPA ($(wc -c <"$IPA" | tr -d ' ') bytes)"
# Show version stamp for operator confirmation
TMP_UNPACK="$(mktemp -d "${TMPDIR:-/tmp}/edgeever-ipa-info.XXXXXX")"
unzip -q -o "$IPA" -d "$TMP_UNPACK" || true
PLIST="$(find "$TMP_UNPACK" -path '*/Payload/*.app/Info.plist' | head -1 || true)"
if [[ -n "$PLIST" ]]; then
echo "CFBundleShortVersionString=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST" 2>/dev/null || true)"
echo "CFBundleVersion=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || true)"
echo "BuildMachineOSBuild=$(/usr/libexec/PlistBuddy -c 'Print :BuildMachineOSBuild' "$PLIST" 2>/dev/null || true)"
fi
rm -rf "$TMP_UNPACK"
echo "Uploading..."
xcrun altool --upload-app --type ios --file "$IPA" --apiKey "$KEY_ID" --apiIssuer "$ISSUER"
echo "UPLOAD SUCCEEDED"
+1
View File
@@ -3,6 +3,7 @@
# Keep this lightweight: local day-to-day builds stay on the developer Mac;
# Cloud is only for App Store / TestFlight archives (manual workflow).
#
# Lifecycle: ci_post_clone → ci_pre_xcodebuild → xcodebuild → ci_post_xcodebuild
# Docs: docs/ios-xcode-cloud.md
set -euo pipefail
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# Xcode Cloud — runs after xcodebuild (success or failure).
# Upload App Store IPA when present so Development/Ad Hoc export failures do not
# leave a store-ready binary stranded without an ASC upload.
#
# Requires Xcode Cloud shared environment variables (secret):
# APP_STORE_CONNECT_API_KEY_ID
# APP_STORE_CONNECT_API_ISSUER_ID
# APP_STORE_CONNECT_API_KEY_P8_BASE64 (entire .p8 file, base64)
#
# Docs: docs/ios-xcode-cloud.md
set -euo pipefail
echo "==> EdgeEver Xcode Cloud post-xcodebuild"
echo "CI_XCODEBUILD_EXIT_CODE=${CI_XCODEBUILD_EXIT_CODE:-unset}"
echo "CI_BUILD_NUMBER=${CI_BUILD_NUMBER:-unset}"
echo "CI_ARCHIVE_PATH=${CI_ARCHIVE_PATH:-unset}"
echo "CI_APP_STORE_SIGNED_APP_PATH=${CI_APP_STORE_SIGNED_APP_PATH:-unset}"
# Locate signed App Store product (.ipa or .app under export)
IPA=""
if [ -n "${CI_APP_STORE_SIGNED_APP_PATH:-}" ] && [ -e "$CI_APP_STORE_SIGNED_APP_PATH" ]; then
if [ -f "$CI_APP_STORE_SIGNED_APP_PATH" ] && echo "$CI_APP_STORE_SIGNED_APP_PATH" | grep -qi '\.ipa$'; then
IPA="$CI_APP_STORE_SIGNED_APP_PATH"
elif [ -d "$CI_APP_STORE_SIGNED_APP_PATH" ]; then
# Sometimes this points at .app; search sibling/export dirs for ipa
PARENT="$(dirname "$CI_APP_STORE_SIGNED_APP_PATH")"
IPA="$(find "$PARENT" -maxdepth 3 -name '*.ipa' 2>/dev/null | head -1 || true)"
fi
fi
if [ -z "$IPA" ]; then
# Common Cloud workspace layouts after archive+export
for root in \
"${CI_PRIMARY_REPOSITORY_PATH:-}" \
"${CI_WORKSPACE:-}" \
/Volumes/workspace \
/Volumes/workspace/repository
do
[ -n "$root" ] && [ -d "$root" ] || continue
# pipefail-safe: find may return 1 when empty
found="$(find "$root" -name '*.ipa' 2>/dev/null | head -1)" || true
if [ -n "${found:-}" ]; then
IPA="$found"
break
fi
done
fi
if [ -z "$IPA" ] || [ ! -f "$IPA" ]; then
echo "No App Store IPA found — skip upload (archive/export may have failed earlier)."
# Do not fail the script: preserve original xcodebuild outcome.
exit 0
fi
echo "Found IPA: $IPA ($(wc -c < "$IPA" | tr -d ' ') bytes)"
KEY_ID="${APP_STORE_CONNECT_API_KEY_ID:-}"
ISSUER="${APP_STORE_CONNECT_API_ISSUER_ID:-}"
P8_B64="${APP_STORE_CONNECT_API_KEY_P8_BASE64:-}"
if [ -z "$KEY_ID" ] || [ -z "$ISSUER" ] || [ -z "$P8_B64" ]; then
echo "ASC API secrets not set in Xcode Cloud env — skip auto-upload."
echo "Set APP_STORE_CONNECT_API_KEY_ID / ISSUER_ID / API_KEY_P8_BASE64 (shared env)."
echo "Fallback: download ARCHIVE_EXPORT from the build and run Scripts/upload-app-store-ipa.sh"
exit 0
fi
# Install API key where altool expects it
KEY_DIR="${HOME}/.appstoreconnect/private_keys"
mkdir -p "$KEY_DIR"
KEY_FILE="${KEY_DIR}/AuthKey_${KEY_ID}.p8"
printf '%s' "$P8_B64" | base64 -d >"$KEY_FILE"
chmod 600 "$KEY_FILE"
echo "Installed API key AuthKey_${KEY_ID}.p8"
# Prefer non-beta Xcode for delivery tools when both exist
if [ -z "${DEVELOPER_DIR:-}" ] && [ -d /Applications/Xcode.app/Contents/Developer ]; then
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
fi
echo "Uploading IPA to App Store Connect via altool..."
if xcrun altool --upload-app --type ios --file "$IPA" --apiKey "$KEY_ID" --apiIssuer "$ISSUER"; then
echo "UPLOAD SUCCEEDED"
else
echo "error: altool upload failed" >&2
# Fail the Cloud action so the dashboard is red when the store binary is not delivered.
exit 1
fi
echo "==> post-xcodebuild complete"
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Xcode Cloud — runs before xcodebuild (after ci_post_clone).
# Align CFBundleVersion with Cloud's managed build number and keep signing Automatic.
#
# Docs: docs/ios-xcode-cloud.md
set -euo pipefail
echo "==> EdgeEver Xcode Cloud pre-xcodebuild"
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-}"
if [ -z "$REPO_ROOT" ]; then
REPO_ROOT="$(CDPATH= cd -- "$(dirname "$0")/../../.." && pwd)"
fi
IOS_ROOT="$REPO_ROOT/apps/ios"
cd "$IOS_ROOT"
# Cloud stamps CFBundleVersion from App Store Connect "next build number".
# Mirror that into Version.xcconfig so the archive and any tooling agree.
if [ -n "${CI_BUILD_NUMBER:-}" ]; then
echo "CI_BUILD_NUMBER=$CI_BUILD_NUMBER"
if [ -f Config/Version.xcconfig ]; then
# portable in-place edit (macOS sed)
sed -i '' -E "s/^(CURRENT_PROJECT_VERSION[[:space:]]*=[[:space:]]*).*/\\1${CI_BUILD_NUMBER}/" Config/Version.xcconfig
echo "Stamped CURRENT_PROJECT_VERSION=${CI_BUILD_NUMBER} into Config/Version.xcconfig"
grep -E '^(MARKETING_VERSION|CURRENT_PROJECT_VERSION)' Config/Version.xcconfig || true
fi
# Also inject into the generated project settings if present
if [ -f EdgeEver.xcodeproj/project.pbxproj ]; then
/usr/libexec/PlistBuddy -c "Print" EdgeEver.xcodeproj/project.pbxproj >/dev/null 2>&1 || true
# pbxproj is not a plist; use sed for CURRENT_PROJECT_VERSION assignments
sed -i '' -E "s/(CURRENT_PROJECT_VERSION = )[0-9]+;/\\1${CI_BUILD_NUMBER};/g" EdgeEver.xcodeproj/project.pbxproj
echo "Stamped CURRENT_PROJECT_VERSION in project.pbxproj"
fi
else
echo "CI_BUILD_NUMBER unset (local dry-run); leaving Version.xcconfig unchanged"
fi
# Defensive: ensure Automatic signing so Cloud can create Dev + Distribution profiles.
# (project.yml already prefers Automatic; this repairs accidental Manual settings.)
if [ -f EdgeEver.xcodeproj/project.pbxproj ]; then
sed -i '' 's/CODE_SIGN_STYLE = Manual;/CODE_SIGN_STYLE = Automatic;/g' EdgeEver.xcodeproj/project.pbxproj
# Drop fixed profile specifiers that force App Store-only profiles (break Dev/Ad Hoc export)
sed -i '' '/PROVISIONING_PROFILE_SPECIFIER/d' EdgeEver.xcodeproj/project.pbxproj
echo "Ensured CODE_SIGN_STYLE=Automatic and cleared PROVISIONING_PROFILE_SPECIFIER"
fi
echo "==> pre-xcodebuild complete"
+9 -13
View File
@@ -20,10 +20,11 @@ settings:
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: false
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
CURRENT_PROJECT_VERSION: 31
CURRENT_PROJECT_VERSION: 33
MARKETING_VERSION: 1.12.6
# Team enables Automatic signing for local Debug/simulator; App Store
# archives override to Manual + distribution profiles in archive-app-store.sh.
# Automatic signing: required for Xcode Cloud (managed certs/profiles for
# App Store + Development exports). Local ExportOptions.plist still pins
# App Store profiles for Scripts/archive-app-store.sh export.
DEVELOPMENT_TEAM: 9KA3NM38B6
CODE_SIGN_STYLE: Automatic
configs:
@@ -66,12 +67,11 @@ targets:
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_TESTABILITY: true
# Always Automatic: Xcode Cloud needs managed Development + Distribution
# profiles. Local App Store export still uses ExportOptions.plist (manual
# profiles) in Scripts/archive-app-store.sh.
DEVELOPMENT_TEAM: 9KA3NM38B6
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE_SPECIFIER: "*[expo] org.edgeever.mobile AppStore 2026-08-01T04:08:38.265Z"
CODE_SIGN_STYLE: Automatic
info:
path: EdgeEver/Resources/Info.plist
properties:
@@ -121,11 +121,7 @@ targets:
SKIP_INSTALL: true
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"
DEVELOPMENT_TEAM: 9KA3NM38B6
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE_SPECIFIER: "*[expo] org.edgeever.mobile.expo-sharing-extension AppStore 2026-08-01T04:09:04.014Z"
CODE_SIGN_STYLE: Automatic
entitlements:
path: ShareExtension/ShareExtension.entitlements
properties:
+73 -38
View File
@@ -15,9 +15,9 @@ Xcode Cloud is billed as **25 compute hours per month** with the Apple Developer
Native store target: **`apps/ios`** (SwiftUI). Expo `apps/mobile` is not the iOS store path.
## One-time setup (Xcode UI)
## One-time setup
Do this once on any Mac that can open the project (beta Mac is fine for *configuring* Cloud; Cloud still builds on Apple’s release OS images).
### Xcode / App Store Connect
1. Open the project:
```sh
@@ -27,39 +27,75 @@ Do this once on any Mac that can open the project (beta Mac is fine for *configu
```
2. **Xcode → Product → Xcode Cloud → Create Workflow…** (or Report navigator → Cloud).
3. Product: **EdgeEver** (`org.edgeever.mobile`), repository `tianma-if/edgeever`.
4. When asked for the project path in a monorepo, select **`apps/ios/EdgeEver.xcodeproj`** (or the `apps/ios` workspace root Xcode shows).
4. When asked for the project path in a monorepo, select **`apps/ios/EdgeEver.xcodeproj`**.
5. **Workflow settings (important):**
- **Start condition:** **Manual** only — do **not** start on every git push / PR (saves the 25h quota).
- **Actions:** **Archive** (App Store / TestFlight distribution).
- **Deployment:** App Store Connect (upload the archive).
- **Xcode version:** latest **release** (or RC when Apple requires it) — not a beta toolchain.
6. Signing: prefer **Xcode Cloud managed signing** for Distribution, or ensure the App Store profiles already used by `ExportOptions.plist` / `project.yml` are available to the team. First Cloud run may ask the Account Holder to confirm certificates in App Store Connect.
7. Grant GitHub access if Xcode prompts (read the monorepo so `ci_post_clone` can see `apps/ios` + `EditorSource`).
- **Actions:** **Archive** (scheme `EdgeEver`, platform iOS).
- **Deployment preparation:** App Store Connect / App Store eligible (or TestFlight Internal Only).
- **Xcode / macOS:** latest **release** — not a beta toolchain.
6. Signing: project uses **Automatic** signing so Xcode Cloud can mint Development + Distribution profiles. First run may ask the Account Holder to confirm certificates in App Store Connect.
7. Grant GitHub access for the primary repo only. Public SPM deps (`GRDB`, `Pow`) do **not** need Connect.
### What the repo already provides
### Shared secrets (auto-upload)
After clone, Xcode Cloud runs:
In **App Store Connect → EdgeEver → Xcode Cloud → Settings → Shared Environment Variables**, add (secret):
`apps/ios/ci_scripts/ci_post_clone.sh`
| Variable | Value |
| --- | --- |
| `APP_STORE_CONNECT_API_KEY_ID` | Key ID (e.g. from Users and Access → Integrations → App Store Connect API) |
| `APP_STORE_CONNECT_API_ISSUER_ID` | Issuer ID |
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Entire `.p8` file contents, base64-encoded |
That script:
`ci_post_xcodebuild.sh` uses these to run `altool` after the archive so the IPA reaches ASC even if secondary Development/Ad Hoc exports fail.
1. Installs **bun** (if missing) and **XcodeGen** (via Homebrew if needed)
2. Builds the TipTap **EditorBundle** (`Scripts/build-editor-bundle.sh`)
3. Runs **`xcodegen generate`**
Encode the key:
You should not need to commit a machine-local `DerivedData` tree. SPM packages (GRDB, Pow) resolve during the Cloud build.
```sh
base64 -i AuthKey_XXXX.p8 | tr -d '\n'
```
## Routine: submit a build (uses Cloud hours)
### Build number (source of truth)
1. Land the code you want on the branch Xcode Cloud tracks (usually `main`).
2. Bump versions if this is a store upload:
- `apps/ios/Config/Version.xcconfig` → `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
- Keep monorepo release rules in `AGENTS.md` when cutting a formal GitHub Release.
3. In Xcode (or App Store Connect → Xcode Cloud): **Start Build** on the **manual** Archive workflow only.
4. Wait for Archive + upload to succeed.
5. In App Store Connect → TestFlight / version page: select the new build, complete compliance, **Submit for Review**.
6. Optional: submit metadata-only steps with Fastlane from any machine (does not re-archive):
Xcode Cloud **overwrites** `CFBundleVersion` with its managed counter:
**App Store Connect → EdgeEver → Xcode Cloud → Settings → Build Number → Next build number**
Rules:
1. Next Cloud number must be **greater than** every build already on ASC for this app.
2. Keep `apps/ios/Config/Version.xcconfig` → `CURRENT_PROJECT_VERSION` in the same ballpark (the repo stamps Cloud’s `CI_BUILD_NUMBER` into the archive via `ci_pre_xcodebuild.sh`).
3. Before a store run, from a machine with API credentials:
```sh
cd apps/ios
export APP_STORE_CONNECT_API_KEY_ID=...
export APP_STORE_CONNECT_API_ISSUER_ID=...
export APP_STORE_CONNECT_API_KEY_P8_BASE64=... # or install AuthKey_*.p8 under ~/.appstoreconnect/private_keys/
bash Scripts/ensure-xcode-cloud-build-number.sh
```
Then set the ASC “next build number” UI to the printed `recommended_next_cloud_build` (or higher) if the UI is not already there.
## What the repo provides
| Path | Role |
| --- | --- |
| `apps/ios/ci_scripts/ci_post_clone.sh` | bun + XcodeGen + EditorBundle + `xcodegen generate` |
| `apps/ios/ci_scripts/ci_pre_xcodebuild.sh` | Stamp `CI_BUILD_NUMBER`; force Automatic signing |
| `apps/ios/ci_scripts/ci_post_xcodebuild.sh` | Upload App Store IPA via `altool` when secrets are set |
| `apps/ios/Scripts/upload-app-store-ipa.sh` | Manual upload of `.ipa` or Cloud `app-store.zip` |
| `apps/ios/Scripts/ensure-xcode-cloud-build-number.sh` | Recommend / sync next build number from ASC history |
| `apps/ios/EdgeEver.xcodeproj/xcshareddata/xcodecloud/manifest.json` | Cloud product linkage (commit this file) |
## Routine: ship a store binary
1. Land the code on the branch Cloud tracks (usually `main`).
2. Bump `MARKETING_VERSION` when the user-facing version changes; ensure Cloud **next build number** &gt; latest ASC build (`ensure-xcode-cloud-build-number.sh`).
3. Start the **manual** Archive workflow (Xcode or ASC → Xcode Cloud → Start Build).
4. Wait for Archive. Prefer a green run; if the run is red only because Development/Ad Hoc export failed, check that `ci_post_xcodebuild` still uploaded the App Store IPA (or download `ARCHIVE_EXPORT` and run `upload-app-store-ipa.sh`).
5. In ASC: wait until the build is **Valid**, attach it to the version, **Submit for Review**.
Optional (metadata already on ASC, binary already uploaded):
```sh
cd apps/ios
@@ -70,31 +106,30 @@ You should not need to commit a machine-local `DerivedData` tree. SPM packages (
fastlane ios submit_review
```
(`skip_binary_upload: true` in the lane — binary must already be on ASC from Cloud.)
## Local archive (release macOS only)
`Scripts/archive-app-store.sh` remains for machines on a **non-beta** macOS:
```sh
cd apps/ios
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer bash Scripts/archive-app-store.sh
bash Scripts/upload-app-store-ipa.sh build/export/EdgeEver.ipa
```
On **macOS beta**, do **not** upload that IPA; use Xcode Cloud instead. The script prints a warning when it detects a beta host OS build.
On **macOS beta**, do **not** upload that IPA; use Xcode Cloud instead. `archive-app-store.sh` refuses beta hosts unless `EDGE_EVER_IOS_ALLOW_BETA_HOST=1`.
## Troubleshooting
| Symptom | Likely cause |
| --- | --- |
| ITMS-90111 after **local** upload | Host macOS is beta (`BuildMachineOSBuild`) |
| Cloud fails in post-clone on bun/xcodegen | Network / Homebrew on Cloud; re-run; check script logs |
| Editor blank in app | EditorBundle not built — confirm `ci_post_clone` log shows bundle build |
| Signing errors on Cloud | Confirm Distribution cert/profile for `org.edgeever.mobile` + share extension; or enable Cloud-managed signing |
| Hours running out | Workflow not Manual — disable push/PR start conditions |
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| ITMS-90111 after **local** upload | Host macOS is beta (`BuildMachineOSBuild`) | Use Xcode Cloud only for store binaries |
| Cloud run **FAILED** but `ARCHIVE_EXPORT` / App Store IPA exists | Development/Ad Hoc export lacks profiles | Automatic signing + Cloud managed certs; `ci_post_xcodebuild` still uploads App Store IPA |
| IPA `CFBundleVersion` is `1` or too low | Cloud “next build number” not advanced | ASC → Xcode Cloud → Build Number; run `ensure-xcode-cloud-build-number.sh` |
| Build never appears on ASC | Auto-upload secrets missing; or only Dev/Ad Hoc failed without App Store IPA | Set shared env secrets; or manual `upload-app-store-ipa.sh` |
| Cloud fails in post-clone on bun/xcodegen | Network / Homebrew on Cloud | Re-run; inspect `ci_post_clone` log |
| Editor blank in app | EditorBundle not built | Confirm `ci_post_clone` log shows bundle build |
| Hours running out | Workflow not Manual | Disable push/PR start conditions |
## Related
- `apps/ios/README.md` — generate / test / Fastlane submit
- `apps/ios/Scripts/archive-app-store.sh` — local archive
- `apps/ios/ci_scripts/ci_post_clone.sh` — Cloud prepare step
- `apps/ios/ci_scripts/*` — Cloud lifecycle hooks
+10 -8
View File
@@ -88,11 +88,13 @@ explicitly requested.
### App Store Connect
EAS Build creates the signed iOS archive from the selected tag and increments
the remote iOS build number. EAS Submit uploads that exact build to App Store
Connect, where it becomes available to TestFlight after Apple finishes
processing it. Fastlane then selects the exact app version and build number,
submits it to App Review with an App Store Connect API key, and configures
automatic release after approval. Missing metadata, agreements, review
information, or credentials cause the workflow to fail without submitting a
different build.
Native iOS store binaries come from **`apps/ios`** (SwiftUI), not Expo EAS.
On macOS beta hosts, archives must go through **Xcode Cloud** (manual Archive
workflow) so `BuildMachineOSBuild` is a release OS image — see
[iOS Xcode Cloud](ios-xcode-cloud.md). Cloud stamps `CFBundleVersion` from the
product’s next build number; `ci_post_xcodebuild.sh` uploads the App Store IPA
with an App Store Connect API key when shared environment secrets are set.
Fastlane (`apps/ios` `submit_review`) then selects the exact app version and
build number, submits App Review, and configures automatic release after
approval. Missing metadata, agreements, review information, or credentials
cause the workflow to fail without submitting a different build.
+9 -5
View File
@@ -82,8 +82,12 @@ Release。默认命令直接使用 Production;只有明确要求测试交付
### App Store Connect
EAS Build 从指定 tag 创建签名 iOS Archive,并递增远端 iOS Build Number。
EAS Submit 将同一个构建上传到 App Store Connect;Apple 处理完成后,它会出现在
TestFlight 中。随后 Fastlane 使用 App Store Connect API Key 精确选择相同的
App Version 和 Build Number,提交 App Review,并设置为审核通过后自动发布。
如果元数据、协议、审核信息或凭据不完整,工作流会失败,不会改为提交其他构建。
原生 iOS 商店二进制来自 **`apps/ios`**(SwiftUI),不再走 Expo EAS。
在 macOS beta 本机上,Archive 必须通过 **Xcode Cloud**(仅手动触发的 Archive
工作流),保证 `BuildMachineOSBuild` 来自正式系统镜像——见
[iOS Xcode Cloud](ios-xcode-cloud.md)。Cloud 用产品「下一个构建版本编号」写入
`CFBundleVersion`;配置共享环境变量后,`ci_post_xcodebuild.sh` 会用
App Store Connect API Key 上传 App Store IPA。随后 Fastlane(`apps/ios` 的
`submit_review`)精确选择相同的 App Version 与 Build Number,提交 App Review,
并设置为审核通过后自动发布。元数据、协议、审核信息或凭据不完整时工作流会失败,
不会改为提交其他构建。