diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index 0cdc2187..58f16f2f 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -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 diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 219262c7..4f3e693d 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -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; diff --git a/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcodecloud/manifest.json b/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..2ac3457e --- /dev/null +++ b/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "2b362ad9-6c95-487d-ab5d-1789d9155dc7", + "targets" : [ + { + "id" : "54d8f7fd-92fc-4182-bb3f-83b8d3a89163", + "name" : "EdgeEver" + } + ] +} \ No newline at end of file diff --git a/apps/ios/README.md b/apps/ios/README.md index cc1ad843..f2617245 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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 diff --git a/apps/ios/Scripts/ensure-xcode-cloud-build-number.sh b/apps/ios/Scripts/ensure-xcode-cloud-build-number.sh new file mode 100755 index 00000000..4c2be089 --- /dev/null +++ b/apps/ios/Scripts/ensure-xcode-cloud-build-number.sh @@ -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_.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 diff --git a/apps/ios/Scripts/upload-app-store-ipa.sh b/apps/ios/Scripts/upload-app-store-ipa.sh new file mode 100755 index 00000000..3a73427d --- /dev/null +++ b/apps/ios/Scripts/upload-app-store-ipa.sh @@ -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_.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 " >&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" diff --git a/apps/ios/ci_scripts/ci_post_clone.sh b/apps/ios/ci_scripts/ci_post_clone.sh index d6b8c228..dfa5c275 100755 --- a/apps/ios/ci_scripts/ci_post_clone.sh +++ b/apps/ios/ci_scripts/ci_post_clone.sh @@ -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 diff --git a/apps/ios/ci_scripts/ci_post_xcodebuild.sh b/apps/ios/ci_scripts/ci_post_xcodebuild.sh new file mode 100755 index 00000000..c6a2f1dd --- /dev/null +++ b/apps/ios/ci_scripts/ci_post_xcodebuild.sh @@ -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" diff --git a/apps/ios/ci_scripts/ci_pre_xcodebuild.sh b/apps/ios/ci_scripts/ci_pre_xcodebuild.sh new file mode 100755 index 00000000..a1ec0db7 --- /dev/null +++ b/apps/ios/ci_scripts/ci_pre_xcodebuild.sh @@ -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" diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 93fe9d91..f126870d 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -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: diff --git a/docs/ios-xcode-cloud.md b/docs/ios-xcode-cloud.md index 93a089e5..15546514 100644 --- a/docs/ios-xcode-cloud.md +++ b/docs/ios-xcode-cloud.md @@ -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** > 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 diff --git a/docs/store-delivery.md b/docs/store-delivery.md index 95faad35..8218b6b8 100644 --- a/docs/store-delivery.md +++ b/docs/store-delivery.md @@ -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. diff --git a/docs/store-delivery.zh-CN.md b/docs/store-delivery.zh-CN.md index c969377c..16c36aa1 100644 --- a/docs/store-delivery.zh-CN.md +++ b/docs/store-delivery.zh-CN.md @@ -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, +并设置为审核通过后自动发布。元数据、协议、审核信息或凭据不完整时工作流会失败, +不会改为提交其他构建。