Add iOS App Store production lane (#7644)

* Add iOS App Store release lane

* Fix App Store workflow build output env

* Harden App Store release lane dispatch

* Fix App Store ASC workflow build-number args

* Harden App Store workflow operations

* Block App Store external purchase links

* Require Apple sign-in entitlement for App Store uploads

* Localize App Store billing unavailable copy
This commit is contained in:
Abdulaziz Albahar
2026-07-09 00:00:25 +00:00
committed by GitHub
parent 86e9e56c9e
commit 2e50b815bb
34 changed files with 1054 additions and 50 deletions
+41
View File
@@ -0,0 +1,41 @@
{
"workflows": {
"ios-app-store-validate": {
"description": "Validate the cmux iOS production App Store review package.",
"steps": [
{
"name": "package_files",
"run": "test -s ios/AppStoreReview/review-notes.md && test -s ios/AppStoreReview/metadata-screenshots-checklist.md"
},
{
"name": "readiness",
"run": "set -- --app \"$APP_ID\" --version \"$VERSION\"; if [ -n \"${BUILD_NUMBER:-}\" ]; then set -- \"$@\" --build-number \"$BUILD_NUMBER\"; fi; if [ -n \"${STRICT_VALIDATE:-}\" ]; then set -- \"$@\" --strict; fi; ios/scripts/validate-app-store-release.sh \"$@\""
}
]
},
"ios-app-store-stage-dry-run": {
"description": "Preview App Store version staging after a production iOS upload.",
"steps": [
{
"workflow": "ios-app-store-validate"
},
{
"name": "stage",
"run": "set -- --app \"$APP_ID\" --version \"$VERSION\"; if [ -n \"${BUILD_NUMBER:-}\" ]; then set -- \"$@\" --build-number \"$BUILD_NUMBER\"; fi; if [ -n \"${COPY_METADATA_FROM:-}\" ]; then set -- \"$@\" --copy-metadata-from \"$COPY_METADATA_FROM\"; fi; set -- \"$@\" --stage-dry-run; if [ -n \"${STRICT_VALIDATE:-}\" ]; then set -- \"$@\" --strict; fi; ios/scripts/validate-app-store-release.sh \"$@\""
}
]
},
"ios-app-store-submit-dry-run": {
"description": "Preview App Store review submission for a prepared iOS version.",
"steps": [
{
"workflow": "ios-app-store-validate"
},
{
"name": "submit_dry_run",
"run": "set -- --app \"$APP_ID\" --version \"$VERSION\"; if [ -n \"${BUILD_NUMBER:-}\" ]; then set -- \"$@\" --build-number \"$BUILD_NUMBER\"; fi; set -- \"$@\" --submit-dry-run; if [ -n \"${STRICT_VALIDATE:-}\" ]; then set -- \"$@\" --strict; fi; ios/scripts/validate-app-store-release.sh \"$@\""
}
]
}
}
}
+220
View File
@@ -0,0 +1,220 @@
name: iOS App Store (production)
on:
workflow_dispatch:
inputs:
build_number:
description: CFBundleVersion to stamp (defaults to UTC yyyyMMddHHmmss)
required: false
default: ""
copy_metadata_from:
description: Optional source App Store version to copy metadata from during staging dry run
required: false
default: ""
submit_for_review:
description: Submit the prepared version for App Review after validation
required: false
default: false
type: boolean
concurrency:
group: ios-app-store-production
cancel-in-progress: false
permissions:
contents: read
jobs:
upload-and-validate:
name: Upload and validate production App Store build
if: github.ref == 'refs/heads/main'
runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }}
timeout-minutes: 90
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_APP_ID: ${{ vars.IOS_APPSTORE_APP_ID }}
INPUT_BUILD_NUMBER: ${{ github.event.inputs.build_number }}
INPUT_COPY_METADATA_FROM: ${{ github.event.inputs.copy_metadata_from }}
INPUT_SUBMIT_FOR_REVIEW: ${{ github.event.inputs.submit_for_review }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: recursive
fetch-depth: 0
fetch-tags: true
- name: Select Xcode
run: ./scripts/select-ci-xcode.sh
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Provision GhosttyKit
run: ./scripts/ensure-ghosttykit.sh
- name: Verify asc CLI
run: |
set -euo pipefail
command -v asc
asc --version
- name: Materialize App Store Connect API key
run: |
set -euo pipefail
if [ -z "${ASC_API_KEY_ID:-}" ] || [ -z "${ASC_API_ISSUER_ID:-}" ] || [ -z "${ASC_API_KEY_P8_BASE64:-}" ]; then
echo "Missing App Store Connect API key secrets" >&2
exit 1
fi
if [ -z "${ASC_APP_ID:-}" ]; then
echo "Missing App Store Connect app id repository variable" >&2
exit 1
fi
KEY_DIR="$RUNNER_TEMP/appstoreconnect/private_keys"
KEY_PATH="$KEY_DIR/AuthKey_${ASC_API_KEY_ID}.p8"
mkdir -p "$KEY_DIR"
printf '%s' "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH"
chmod 600 "$KEY_PATH"
echo "ASC_API_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV"
- name: Import iOS distribution signing cert
env:
IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${IOS_DISTRIBUTION_CERTIFICATE_BASE64:-}" ]; then
echo "Missing IOS_DISTRIBUTION_CERTIFICATE_BASE64 secret" >&2
exit 1
fi
if [ -z "${IOS_DISTRIBUTION_CERTIFICATE_PASSWORD:-}" ]; then
echo "Missing IOS_DISTRIBUTION_CERTIFICATE_PASSWORD secret" >&2
exit 1
fi
KEYCHAIN_PASSWORD="$(uuidgen)"
KEYCHAIN_NAME="ios-app-store.keychain"
CERT_PATH="$RUNNER_TEMP/ios-distribution.p12"
printf '%s' "$IOS_DISTRIBUTION_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
security delete-keychain "$KEYCHAIN_NAME" >/dev/null 2>&1 || true
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security import "$CERT_PATH" -k "$KEYCHAIN_NAME" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security list-keychains -d user -s "$KEYCHAIN_NAME"
IOS_DISTRIBUTION_IDENTITY="$(
security find-identity -v -p codesigning "$KEYCHAIN_NAME" |
sed -n 's/.*"\(Apple Distribution: .* (7WLXT3NR37)\)".*/\1/p' |
head -n 1
)"
if [ -z "$IOS_DISTRIBUTION_IDENTITY" ]; then
echo "No Apple Distribution identity for team 7WLXT3NR37 was found in the imported certificate" >&2
exit 1
fi
echo "IOS_DISTRIBUTION_IDENTITY=$IOS_DISTRIBUTION_IDENTITY" >> "$GITHUB_ENV"
- name: Install App Store provisioning profile
env:
IOS_APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: |
set -euo pipefail
if [ -z "${IOS_APPSTORE_PROVISIONING_PROFILE_BASE64:-}" ]; then
echo "Missing App Store production provisioning profile secret" >&2
exit 1
fi
TMP_PROFILE="$RUNNER_TEMP/cmux-appstore.mobileprovision"
TMP_PLIST="$RUNNER_TEMP/cmux-appstore-profile.plist"
printf '%s' "$IOS_APPSTORE_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$TMP_PROFILE"
security cms -D -i "$TMP_PROFILE" > "$TMP_PLIST"
APP_ID="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$TMP_PLIST")"
if [ "$APP_ID" != "7WLXT3NR37.com.cmuxterm.app" ]; then
echo "App Store provisioning profile targets unexpected app ID: $APP_ID" >&2
exit 1
fi
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || true)"
if [ "$APS_ENVIRONMENT" != "production" ]; then
echo "App Store provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
exit 1
fi
APPLE_SIGN_IN="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.applesignin:0" "$TMP_PLIST" 2>/dev/null || true)"
if [ "$APPLE_SIGN_IN" != "Default" ]; then
echo "App Store provisioning profile com.apple.developer.applesignin is '${APPLE_SIGN_IN:-<absent>}', expected 'Default'" >&2
exit 1
fi
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c "Print :UUID" "$TMP_PLIST")"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$TMP_PROFILE" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "IOS_APPSTORE_PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV"
- name: Archive, export, and upload production build
id: upload
env:
CMUX_BUILD_NUMBER_OUT_FILE: ${{ runner.temp }}/cmux-final-appstore-build-number.txt
run: |
set -euo pipefail
ARGS=(--signing manual)
if [ -n "${INPUT_BUILD_NUMBER:-}" ]; then
ARGS+=(--build-number "$INPUT_BUILD_NUMBER")
fi
./ios/scripts/upload-app-store.sh "${ARGS[@]}"
FINAL_BN="$(cat "$CMUX_BUILD_NUMBER_OUT_FILE")"
echo "final_build_number=$FINAL_BN" >> "$GITHUB_OUTPUT"
- name: Validate App Store readiness
env:
FINAL_BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number }}
run: |
set -euo pipefail
VERSION="$(sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' ios/Config/Shared.xcconfig | head -n 1)"
./ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$FINAL_BUILD_NUMBER" \
--wait-build \
--strict
if [ -n "${INPUT_COPY_METADATA_FROM:-}" ]; then
./ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$FINAL_BUILD_NUMBER" \
--copy-metadata-from "$INPUT_COPY_METADATA_FROM" \
--stage-dry-run \
--strict
fi
if [ "${INPUT_SUBMIT_FOR_REVIEW:-false}" = "true" ]; then
./ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$FINAL_BUILD_NUMBER" \
--submit \
--confirm-submit \
--strict
fi
- name: Summary
if: always()
env:
FINAL_BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number || 'unknown' }}
INPUT_SUBMIT_FOR_REVIEW: ${{ github.event.inputs.submit_for_review }}
run: |
{
echo "### iOS App Store production"
echo
echo "- lane: \`appstore\` (bundle id \`com.cmuxterm.app\`)"
echo "- signing: manual (CI-imported iOS distribution cert + App Store profile)"
echo "- build number (CFBundleVersion): \`${FINAL_BUILD_NUMBER}\`"
echo "- submit for review: \`${INPUT_SUBMIT_FOR_REVIEW}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup keychain
if: always()
run: |
rm -f "${ASC_API_KEY_PATH:-}"
security delete-keychain ios-app-store.keychain >/dev/null 2>&1 || true
@@ -12,10 +12,6 @@ struct SetupHelpGateContent {
let identifierSuffix: String
let linkAccessibilityIdentifier: String
/// Founders Edition page: cmux for Mac download plus TestFlight enrollment,
/// used by the "Run cmux on your Mac" gate.
private static let setupHelpMacDownloadURL = URL(string: "https://github.com/manaflow-ai/cmux#founders-edition")!
/// Maps a setup gate to its title, icon, copy, and optional link. Pure and
/// scoped to the content type so the gate guidance is data, separate from
/// the view that lays it out.
@@ -41,10 +37,7 @@ struct SetupHelpGateContent {
"mobile.setupHelp.macAppBody",
defaultValue: "Install cmux on your computer and leave it running, signed in to the same account. The phone pairs to a running cmux build, so a quit or never-installed app is the most common reason pairing does nothing."
),
link: SetupHelpGateLink(
title: L10n.string("mobile.setupHelp.macAppLink", defaultValue: "Download cmux"),
url: setupHelpMacDownloadURL
),
link: nil,
identifierSuffix: "signedInNeverPaired",
linkAccessibilityIdentifier: "MobileSetupHelpMacAppLink"
)
@@ -0,0 +1,26 @@
import CmuxMobileWorkspace
import Testing
@testable import CmuxMobileShellUI
@Suite struct SetupHelpGateContentTests {
@Test func setupGatesDoNotExposeExternalPurchaseLinks() {
let gates: [MobileSetupGuidanceState] = [
.notSignedIn,
.signedInNeverPaired,
.macUnreachable,
.accountMismatch,
]
for gate in gates {
let url = SetupHelpGateContent.content(for: gate).link?.url.absoluteString
#expect(url?.contains("founders-edition") != true)
#expect(url?.contains("github.com/manaflow-ai/cmux") != true)
}
}
@Test func signedInNeverPairedGateUsesInAppInstructionsOnly() {
let content = SetupHelpGateContent.content(for: .signedInNeverPaired)
#expect(content.link == nil)
}
}
+88
View File
@@ -0,0 +1,88 @@
# cmux iOS App Store Review Package
This package is the source of truth for production iOS App Store submission
readiness. Keep it separate from the TestFlight beta lane.
## Production Lane
Upload a production App Store Connect build:
```bash
ios/scripts/upload-app-store.sh
```
Useful dry run:
```bash
ios/scripts/upload-app-store.sh --export-only
```
Defaults:
- Bundle ID: `com.cmuxterm.app`
- Display name: `cmux`
- Provisioning profile: `cmux App Store Distribution`
- Entitlements: `Config/cmux-release.entitlements`
- Review submission: not automatic
The beta lane remains:
```bash
ios/scripts/upload-testflight.sh --lane beta
```
## Validation
Run the App Store readiness package after upload:
```bash
ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$(sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' ios/Config/Shared.xcconfig | head -1)" \
--build-number "$CF_BUNDLE_VERSION" \
--wait-build \
--strict
```
Preview staging with copied metadata:
```bash
ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$CF_BUNDLE_VERSION" \
--copy-metadata-from "$PREVIOUS_VERSION" \
--stage-dry-run \
--strict
```
Preview review submission:
```bash
ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$CF_BUNDLE_VERSION" \
--submit-dry-run \
--strict
```
Only submit after the checklist is complete:
```bash
ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$CF_BUNDLE_VERSION" \
--submit \
--confirm-submit \
--strict
```
## Files
- `review-notes.md` contains the notes to paste into App Store Connect Review Information.
- `metadata-screenshots-checklist.md` lists the metadata, screenshots, privacy, and payment gates that must be complete before submission.
Do not commit demo account passwords. Add them only in App Store Connect Review
Information fields.
@@ -0,0 +1,79 @@
# cmux iOS Metadata and Screenshots Checklist
Do not submit a production iOS App Store build until every blocking item is
complete in App Store Connect or in the submitted binary.
## App Identity
- [ ] Bundle ID is `com.cmuxterm.app`.
- [ ] Display name is `cmux`, not `cmux BETA` or any dev tag.
- [ ] `MARKETING_VERSION` in `ios/Config/Shared.xcconfig` matches the App Store version.
- [ ] `CURRENT_PROJECT_VERSION` is higher than every prior ASC build for the app.
- [ ] `ITSAppUsesNonExemptEncryption` remains `false` only if the build uses no non-exempt encryption beyond standard TLS.
## Review Information
- [ ] App Review contact name, email, and phone are filled in ASC.
- [ ] Demo account credentials are entered only in ASC Review Information, never in git.
- [ ] `review-notes.md` is pasted into ASC notes and edited with the exact review Mac or manual pairing code for this build.
- [ ] Backend services needed by the demo account are live before submission.
- [ ] Account deletion is available in app or the submission is blocked until the account lifecycle satisfies App Review Guideline 5.1.1(v).
## Metadata
- [ ] App name is 30 characters or fewer and does not include pricing text.
- [ ] Subtitle, description, keywords, support URL, marketing URL, and privacy policy URL are complete for every ASC localization.
- [ ] Description accurately says cmux is an iOS companion for the cmux macOS terminal.
- [ ] Metadata does not describe TestFlight, beta access, dev builds, unfinished features, or unsupported platforms.
- [ ] Category and age rating are complete and match the app's terminal, remote-control, notifications, and account features.
- [ ] Content rights declaration is complete.
- [ ] App Privacy answers match collected data: account identifiers, device token when push is enabled, analytics if enabled, and pairing/device metadata.
## Screenshots
- [ ] Screenshots show the actual app in use, not only splash, login, title art, or marketing copy.
- [ ] Include signed-in workspace list, pairing/computers, terminal detail, input bar, and notification opt-in surfaces.
- [ ] Use fictional workspace names, terminal text, email addresses, device names, and account data.
- [ ] Include required iPhone screenshots, at minimum `IPHONE_65`.
- [ ] Include required iPad screenshots if the binary remains universal (`TARGETED_DEVICE_FAMILY = 1,2`), at minimum `IPAD_PRO_3GEN_129`.
- [ ] Local screenshot assets pass `asc screenshots validate` before upload.
## Payments
- [ ] The iOS App Store build exposes no Stripe, Stack checkout, external purchase, external upgrade, or billing-management link.
- [ ] `/app-pricing?cmux_app=1&cmux_distribution=appstore` renders without `/api/billing/checkout`, `/api/billing/portal`, or enterprise sales CTAs.
- [ ] `/api/billing/checkout?cmux_distribution=appstore` redirects before creating Stack or Stripe checkout state.
- [ ] Existing paid entitlement state is read-only in iOS. If an iOS purchase flow is added later, it must use StoreKit and restore purchases.
## Permissions and Privacy
- [ ] Camera purpose string says QR pairing scan.
- [ ] Local Network purpose string says Mac pairing and terminal sync.
- [ ] Microphone and speech recognition purpose strings say voice transcription in the message box.
- [ ] Photo library purpose string says attaching photos to terminal-agent messages.
- [ ] Permissions are requested only when the user starts the relevant feature.
- [ ] Push notifications are opt-in and can be disabled after enabling.
## ASC Validation Commands
```bash
ios/scripts/validate-app-store-release.sh \
--app "$ASC_APP_ID" \
--version "$VERSION" \
--build-number "$CF_BUNDLE_VERSION" \
--wait-build \
--strict
```
```bash
asc validate --app "$ASC_APP_ID" --version "$VERSION" --platform IOS --strict --output table
```
```bash
asc metadata validate --dir ios/AppStoreReview/metadata --output table
```
```bash
asc screenshots validate --path ios/AppStoreReview/screenshots --device-type IPHONE_65 --output table
asc screenshots validate --path ios/AppStoreReview/screenshots --device-type IPAD_PRO_3GEN_129 --output table
```
+47
View File
@@ -0,0 +1,47 @@
# cmux iOS Review Notes
cmux for iOS is a companion app for the cmux macOS terminal. It lets a signed-in
user pair with their Mac, view workspaces, receive terminal notifications, and
send input to an active terminal session from iPhone or iPad.
Reviewer access:
- Use the demo account entered in App Store Connect Review Information. Do not
put demo credentials in this repository.
- After sign-in, use the pairing flow shown in the app. Pairing can be tested
with a prepared review Mac, or with a manual pairing code supplied in the
Review Information notes for the submitted build.
- The app may request Local Network permission during pairing so it can discover
and connect to the user's Mac.
- Camera permission is used only to scan cmux pairing QR codes.
- Microphone and speech recognition permissions are used only when the reviewer
chooses voice transcription in the message box.
- Photo library permission is used only when the reviewer attaches a photo to a
terminal-agent message.
Payments:
- The iOS App Store build does not sell digital goods and does not expose Stripe,
Stack checkout, external purchase links, or billing management links.
- The web billing surface is gated for App Store mode with
`cmux_distribution=appstore`; direct checkout requests with that distribution
are redirected before Stack or Stripe checkout creation.
- Existing paid access from web or desktop accounts is read-only entitlement
state in the iOS app. There is no in-app upsell or purchase call to action.
Privacy and account handling:
- Sign in supports Apple, Google, and email code through Stack Auth.
- Push notifications are opt-in. The device token is uploaded only after the user
enables phone notifications.
- `ITSAppUsesNonExemptEncryption` is `false`; the app uses standard platform
networking and TLS.
Primary review path:
1. Sign in with the demo account supplied in App Store Connect.
2. Pair with the prepared Mac or enter the supplied manual pairing code.
3. Open the workspace list, then open a workspace detail.
4. Send a short terminal input from the message box.
5. Enable phone notifications and verify the opt-in prompt, then disable them
again from the same surface.
+39
View File
@@ -136,3 +136,42 @@ Required GitHub secrets:
- `IOS_DISTRIBUTION_CERTIFICATE_BASE64` (base64-encoded `.p12` for an Apple Distribution certificate on team `7WLXT3NR37`)
- `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD`
- `IOS_BETA_PROVISIONING_PROFILE_BASE64` (base64-encoded App Store profile for `dev.cmux.app.beta`, with `aps-environment=production`)
## App Store production lane
The production App Store lane is separate from the TestFlight beta lane. It uses
the same archive/export/re-sign verification path, but switches the submitted
identity to the App Store bundle id and stops before App Review submission unless
the operator explicitly confirms submission in CI.
```bash
# Build, export, re-sign, verify, and upload the production App Store build
ios/scripts/upload-app-store.sh
# Dry run: export + re-sign + verify aps-environment=production, no upload
ios/scripts/upload-app-store.sh --export-only
# Run the read-only ASC readiness package after upload
ios/scripts/validate-app-store-release.sh --app "$ASC_APP_ID" --version "$VERSION" --build-number "$CF_BUNDLE_VERSION" --wait-build --strict
```
Defaults:
- Bundle ID: `com.cmuxterm.app`
- Display name: `cmux`
- Provisioning profile: `cmux App Store Distribution`
- Entitlements: `Config/cmux-release.entitlements`
The review package lives in `ios/AppStoreReview/`:
- `review-notes.md` is the pasteable App Store Connect Review Information notes source.
- `metadata-screenshots-checklist.md` is the blocking checklist for metadata, screenshots, privacy, account deletion, and payment gating.
`.github/workflows/ios-app-store.yml` is manual-only. It uploads a production
build, waits for ASC processing, runs `ios/scripts/validate-app-store-release.sh`,
and submits for review only when `submit_for_review` is set.
Additional production workflow requirements:
- Repository variable `IOS_APPSTORE_APP_ID`
- Secret `IOS_APPSTORE_PROVISIONING_PROFILE_BASE64` (base64-encoded App Store profile for `com.cmuxterm.app`, with `aps-environment=production`)
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for arg in "$@"; do
case "$arg" in
--lane|--lane=*)
echo "error: upload-app-store.sh always uses --lane appstore; do not pass --lane" >&2
exit 2
;;
esac
done
exec "$SCRIPT_DIR/upload-testflight.sh" --lane appstore "$@"
+100 -22
View File
@@ -13,7 +13,7 @@ set -euo pipefail
# automatic pre-upload gate) so the two paths can't drift.
verify_ipa_aps_environment_production() {
local ipa="$1"
local workdir app ent aps rc
local workdir app ent aps apple_sign_in
workdir="$(mktemp -d)"
if ! ( cd "$workdir" && unzip -q "$ipa" ); then
echo "error: could not unzip IPA to verify entitlements: $ipa" >&2
@@ -38,16 +38,50 @@ verify_ipa_aps_environment_production() {
rm -rf "$workdir"
return 1
fi
# PlistBuddy exits non-zero (and prints to stdout) when the key is absent, so
# capture rc and require an exact "production" match.
aps="$(/usr/libexec/PlistBuddy -c 'Print :aps-environment' "$ent" 2>/dev/null)"
rc=$?
if [[ $rc -ne 0 || "$aps" != "production" ]]; then
# PlistBuddy exits non-zero when a key is absent; tolerate that read and then
# require exact entitlement values so the error explains the missing capability.
aps="$(/usr/libexec/PlistBuddy -c 'Print :aps-environment' "$ent" 2>/dev/null || true)"
if [[ "$aps" != "production" ]]; then
echo "error: signed app aps-environment is '${aps:-<absent>}', expected 'production' (push would silently fail): $app" >&2
plutil -p "$ent" >&2 || true
rm -rf "$workdir"
return 1
fi
apple_sign_in="$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.applesignin:0' "$ent" 2>/dev/null || true)"
if [[ "$apple_sign_in" != "Default" ]]; then
echo "error: signed app com.apple.developer.applesignin is '${apple_sign_in:-<absent>}', expected 'Default' (Sign in with Apple would fail): $app" >&2
plutil -p "$ent" >&2 || true
rm -rf "$workdir"
return 1
fi
rm -rf "$workdir"
return 0
}
verify_app_store_ipa_has_no_external_purchase_links() {
local ipa="$1"
local workdir app matches
workdir="$(mktemp -d)"
if ! ( cd "$workdir" && unzip -q "$ipa" ); then
echo "error: could not unzip IPA to verify App Store review links: $ipa" >&2
rm -rf "$workdir"
return 1
fi
app="$(find "$workdir/Payload" -maxdepth 1 -name '*.app' -type d 2>/dev/null | head -n 1)"
if [[ -z "$app" || ! -d "$app" ]]; then
echo "error: IPA has no Payload/*.app to verify App Store review links: $ipa" >&2
rm -rf "$workdir"
return 1
fi
matches="$(LC_ALL=C grep -R -a -l -E 'github\.com/manaflow-ai/cmux#founders-edition|founders-edition' "$app" 2>/dev/null || true)"
if [[ -n "$matches" ]]; then
echo "error: App Store IPA contains an external Founders Edition purchase/enrollment link; refusing to upload" >&2
printf '%s\n' "$matches" >&2
rm -rf "$workdir"
return 1
fi
rm -rf "$workdir"
return 0
}
@@ -55,16 +89,23 @@ verify_ipa_aps_environment_production() {
usage() {
cat <<'EOF'
Usage:
ios/scripts/upload-testflight.sh [--lane beta] [--build-number <number>]
ios/scripts/upload-testflight.sh [--lane beta|appstore] [--build-number <number>]
[--signing manual|automatic] [--external]
[--archive-path <path>] [--export-only]
Archives cmux iOS, exports an App Store Connect IPA, and uploads it to
TestFlight. The default lane is beta:
App Store Connect. The default lane is beta and preserves the existing
TestFlight behavior:
bundle id: dev.cmux.app.beta
profile: cmux Beta Distribution
The production App Store lane uses:
bundle id: com.cmuxterm.app
profile: cmux App Store Distribution
display: cmux
On the manual signing path the exported app is RE-SIGNED with the full
entitlements before upload. The archive is built unsigned (to avoid
distribution-cert churn), so -exportArchive re-adds only the profile baseline
@@ -99,7 +140,9 @@ or:
APPLE_PROVIDER_PUBLIC_ID
Options:
--lane <beta> Distribution lane. Only beta is currently defined.
--lane <beta|appstore> Distribution lane. beta is the existing TestFlight
path. appstore uploads the production App Store
build and skips TestFlight notes/group assignment.
--build-number <number> CFBundleVersion. Defaults to UTC yyyyMMddHHmmss.
Self-healed up to (App Store Connect max + 1) if it
would not be the highest build (TestFlight only offers
@@ -280,6 +323,12 @@ case "$LANE" in
beta)
PRODUCT_BUNDLE_IDENTIFIER="dev.cmux.app.beta"
PROVISIONING_PROFILE_NAME="${IOS_BETA_PROVISIONING_PROFILE_NAME:-cmux Beta Distribution}"
PRODUCT_DISPLAY_NAME="${IOS_BETA_DISPLAY_NAME:-cmux BETA}"
;;
appstore)
PRODUCT_BUNDLE_IDENTIFIER="${IOS_APPSTORE_BUNDLE_ID:-com.cmuxterm.app}"
PROVISIONING_PROFILE_NAME="${IOS_APPSTORE_PROVISIONING_PROFILE_NAME:-cmux App Store Distribution}"
PRODUCT_DISPLAY_NAME="${IOS_APPSTORE_DISPLAY_NAME:-cmux}"
;;
*)
echo "error: unsupported lane '$LANE'" >&2
@@ -288,6 +337,16 @@ case "$LANE" in
;;
esac
if [[ "$LANE" == "appstore" && "$EXTERNAL_TESTING" -eq 1 ]]; then
echo "error: --external is TestFlight-only and cannot be used with --lane appstore" >&2
exit 2
fi
if [[ "$LANE" == "appstore" && "$AUTO_VERSION" -eq 1 ]]; then
echo "error: --auto-version is beta-only. Set ios/Config/Shared.xcconfig MARKETING_VERSION intentionally before an App Store upload." >&2
exit 2
fi
case "$SIGNING" in
manual|automatic) ;;
*)
@@ -373,7 +432,7 @@ fi
# where the actual marketing version is known. Skipped when there is no upload to
# annotate (--export-only), notes are turned off (--skip-notes), or notes come from
# a commit range (range-notes mode) rather than the changelog.
if [[ "$EXPORT_ONLY" -ne 1 && "$SKIP_NOTES" -ne 1 && "$RANGE_NOTES_MODE" -ne 1 ]]; then
if [[ "$LANE" == "beta" && "$EXPORT_ONLY" -ne 1 && "$SKIP_NOTES" -ne 1 && "$RANGE_NOTES_MODE" -ne 1 ]]; then
if ! "$SCRIPT_DIR/set-testflight-notes.sh" --validate-only --audience "$NOTES_AUDIENCE"; then
echo "error: TestFlight What to Test notes preflight failed (see above). Fix ios/CHANGELOG.md before uploading, or pass --skip-notes to upload without notes." >&2
exit 1
@@ -502,7 +561,7 @@ if [[ -n "${CMUX_BUILD_NUMBER_OUT_FILE:-}" ]]; then
printf '%s\n' "$SHIPPED_BUILD_NUMBER" > "$CMUX_BUILD_NUMBER_OUT_FILE"
fi
OUT_DIR="${CMUX_IOS_UPLOAD_DIR:-/tmp/cmux-ios-testflight-$BUILD_NUMBER}"
OUT_DIR="${CMUX_IOS_UPLOAD_DIR:-/tmp/cmux-ios-$LANE-$BUILD_NUMBER}"
DERIVED_DATA="$OUT_DIR/DerivedData"
EXPORT_PATH="$OUT_DIR/export"
EXPORT_OPTIONS="$OUT_DIR/ExportOptions.plist"
@@ -536,6 +595,7 @@ if [[ -z "$ARCHIVE_PATH" ]]; then
"${XCODE_AUTH_ARGS[@]}" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
PRODUCT_BUNDLE_IDENTIFIER="$PRODUCT_BUNDLE_IDENTIFIER" \
PRODUCT_DISPLAY_NAME="$PRODUCT_DISPLAY_NAME" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
${MARKETING_VERSION_ARGS[@]+"${MARKETING_VERSION_ARGS[@]}"} \
CODE_SIGN_STYLE=Automatic \
@@ -558,6 +618,7 @@ if [[ -z "$ARCHIVE_PATH" ]]; then
-derivedDataPath "$DERIVED_DATA" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
PRODUCT_BUNDLE_IDENTIFIER="$PRODUCT_BUNDLE_IDENTIFIER" \
PRODUCT_DISPLAY_NAME="$PRODUCT_DISPLAY_NAME" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
${MARKETING_VERSION_ARGS[@]+"${MARKETING_VERSION_ARGS[@]}"} \
CODE_SIGNING_ALLOWED=NO \
@@ -572,6 +633,12 @@ else
fi
fi
ARCHIVE_BUNDLE_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleIdentifier' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || true)"
if [[ -n "$ARCHIVE_BUNDLE_IDENTIFIER" && "$ARCHIVE_BUNDLE_IDENTIFIER" != "$PRODUCT_BUNDLE_IDENTIFIER" ]]; then
echo "error: archive bundle id is '$ARCHIVE_BUNDLE_IDENTIFIER' but lane '$LANE' requires '$PRODUCT_BUNDLE_IDENTIFIER'. Re-archive for the selected lane." >&2
exit 1
fi
# Now that the archive exists, its marketing version (CFBundleShortVersionString)
# is the version testers will see. Re-run the notes preflight WITH that version so
# a deterministic mismatch (changelog top is 1.0.3 but the archived build is 1.0.0)
@@ -581,7 +648,7 @@ fi
# Skipped in range-notes mode: the notes come from the commit range, not the
# changelog, and --auto-version intentionally stamps a version the changelog would
# not match.
if [[ "$EXPORT_ONLY" -ne 1 && "$SKIP_NOTES" -ne 1 && "$RANGE_NOTES_MODE" -ne 1 ]]; then
if [[ "$LANE" == "beta" && "$EXPORT_ONLY" -ne 1 && "$SKIP_NOTES" -ne 1 && "$RANGE_NOTES_MODE" -ne 1 ]]; then
ARCHIVE_MARKETING_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleShortVersionString' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || true)"
if [[ "$ARCHIVE_MARKETING_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then
if ! "$SCRIPT_DIR/set-testflight-notes.sh" --validate-only \
@@ -604,13 +671,15 @@ plutil -insert method -string app-store-connect "$EXPORT_OPTIONS"
plutil -insert destination -string export "$EXPORT_OPTIONS"
plutil -insert teamID -string "$DEVELOPMENT_TEAM" "$EXPORT_OPTIONS"
plutil -insert manageAppVersionAndBuildNumber -bool NO "$EXPORT_OPTIONS"
if [[ "$EXTERNAL_TESTING" == "1" ]]; then
# External-eligible: omit/clear the internal-only restriction so the build can
# be added to an external group (after Apple Beta App Review).
plutil -insert testFlightInternalTestingOnly -bool NO "$EXPORT_OPTIONS"
echo "note: --external set; build will be eligible for external TestFlight testers (requires Apple Beta App Review per version)." >&2
else
plutil -insert testFlightInternalTestingOnly -bool YES "$EXPORT_OPTIONS"
if [[ "$LANE" == "beta" ]]; then
if [[ "$EXTERNAL_TESTING" == "1" ]]; then
# External-eligible: omit/clear the internal-only restriction so the build can
# be added to an external group (after Apple Beta App Review).
plutil -insert testFlightInternalTestingOnly -bool NO "$EXPORT_OPTIONS"
echo "note: --external set; build will be eligible for external TestFlight testers (requires Apple Beta App Review per version)." >&2
else
plutil -insert testFlightInternalTestingOnly -bool YES "$EXPORT_OPTIONS"
fi
fi
plutil -insert uploadSymbols -bool YES "$EXPORT_OPTIONS"
if [[ "$SIGNING" == "automatic" ]]; then
@@ -820,6 +889,13 @@ fi
echo "IPA_PATH=$IPA_PATH"
if [[ "$LANE" == "appstore" ]]; then
if ! verify_app_store_ipa_has_no_external_purchase_links "$IPA_PATH"; then
exit 1
fi
echo "App Store IPA verified to omit external purchase/enrollment links: $IPA_PATH"
fi
if [[ "$EXPORT_ONLY" -eq 1 ]]; then
exit 0
fi
@@ -859,7 +935,7 @@ elif [[ -n "${APPLE_ID:-}" || -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" || -n "${APP
| tee "$OUT_DIR/upload.log"
else
cat >&2 <<EOF
error: missing TestFlight upload credentials.
error: missing App Store Connect upload credentials.
Set ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH, or set
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_PROVIDER_PUBLIC_ID. You can
@@ -881,7 +957,9 @@ fi
# Audience: --external uses the External audience; the default internal cut uses
# the terse Internal block. SHIPPED_BUILD_NUMBER is the CFBundleVersion that
# actually shipped (post-guard, or the reused archive's embedded version).
if [[ "$SKIP_NOTES" -eq 1 ]]; then
if [[ "$LANE" != "beta" ]]; then
echo "note: lane '$LANE' is not a TestFlight lane; skipping TestFlight What to Test notes" >&2
elif [[ "$SKIP_NOTES" -eq 1 ]]; then
echo "note: --skip-notes set; not setting TestFlight What to Test notes" >&2
elif [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || ( -z "${ASC_API_KEY_PATH:-}" && -z "${ASC_API_KEY_P8_BASE64:-}" ) ]]; then
echo "note: no ASC API key (JWT) available; skipping TestFlight What to Test notes (set ASC_API_KEY_ID/ASC_API_ISSUER_ID/ASC_API_KEY_PATH, or run ios/scripts/set-testflight-notes.sh later)" >&2
@@ -945,7 +1023,7 @@ fi
# Beta App Review submission when Apple requires one for a new
# MARKETING_VERSION. This is fatal: a red CI/upload is preferable to claiming
# the external lane tracked main when the build never reached the founders lane.
if [[ "$EXPORT_ONLY" -ne 1 && "$EXTERNAL_TESTING" -eq 1 && "$ASSIGN_EXTERNAL_GROUP" -eq 1 ]]; then
if [[ "$LANE" == "beta" && "$EXPORT_ONLY" -ne 1 && "$EXTERNAL_TESTING" -eq 1 && "$ASSIGN_EXTERNAL_GROUP" -eq 1 ]]; then
if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || ( -z "${ASC_API_KEY_PATH:-}" && -z "${ASC_API_KEY_P8_BASE64:-}" ) ]]; then
echo "warning: no ASC API key (JWT) available; uploaded the external-eligible build but skipped automatic external-group assignment and Beta App Review submission. Supply ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH (or ASC_API_KEY_P8_BASE64) to distribute the build automatically." >&2
exit 0
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
APP="${ASC_APP_ID:-${IOS_APPSTORE_APP_ID:-${IOS_APPSTORE_BUNDLE_ID:-com.cmuxterm.app}}}"
VERSION=""
BUILD_NUMBER=""
BUILD_ID=""
STRICT=0
WAIT_BUILD=0
STAGE_DRY_RUN=0
SUBMIT_DRY_RUN=0
SUBMIT_REQUESTED=0
SUBMIT_CONFIRMED=0
COPY_METADATA_FROM=""
METADATA_DIR="${IOS_APPSTORE_METADATA_DIR:-$IOS_DIR/AppStoreReview/metadata}"
SCREENSHOTS_DIR="${IOS_APPSTORE_SCREENSHOTS_DIR:-$IOS_DIR/AppStoreReview/screenshots}"
REVIEW_NOTES="$IOS_DIR/AppStoreReview/review-notes.md"
CHECKLIST="$IOS_DIR/AppStoreReview/metadata-screenshots-checklist.md"
SCREENSHOT_DEVICE_TYPES=(IPHONE_65 IPAD_PRO_3GEN_129)
SCREENSHOT_DEVICE_TYPES_EXPLICIT=0
VALIDATE_DIGITAL_GOODS="${CMUX_APP_STORE_VALIDATE_DIGITAL_GOODS:-0}"
usage() {
cat <<'EOF'
Usage:
ios/scripts/validate-app-store-release.sh [--app <app-id-or-bundle>]
[--version <X.Y.Z>] [--build-number <CFBundleVersion> | --build-id <id>]
[--strict] [--wait-build] [--metadata-dir <dir>] [--screenshots-dir <dir>]
[--screenshot-device-type <ASC_DEVICE_TYPE>] [--copy-metadata-from <version>]
[--stage-dry-run] [--submit-dry-run | --submit --confirm-submit]
Runs the cmux iOS App Store validation package. The default path is read-only:
it checks the checked-in review package, runs canonical `asc validate`, and
validates local metadata/screenshots when those directories exist.
Mutating submission is deliberately split:
--stage-dry-run previews ASC release staging without mutation.
--submit-dry-run previews review submission without mutation.
--submit --confirm-submit submits the prepared version for review.
EOF
}
die() { printf 'validate-app-store-release: %s\n' "$*" >&2; exit 1; }
note() { printf 'validate-app-store-release: %s\n' "$*" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:-}"; shift 2 ;;
--version) VERSION="${2:-}"; shift 2 ;;
--build-number) BUILD_NUMBER="${2:-}"; shift 2 ;;
--build-id) BUILD_ID="${2:-}"; shift 2 ;;
--strict) STRICT=1; shift ;;
--wait-build) WAIT_BUILD=1; shift ;;
--metadata-dir) METADATA_DIR="${2:-}"; shift 2 ;;
--screenshots-dir) SCREENSHOTS_DIR="${2:-}"; shift 2 ;;
--screenshot-device-type)
[[ -n "${2:-}" ]] || die "--screenshot-device-type requires a value"
if [[ "$SCREENSHOT_DEVICE_TYPES_EXPLICIT" -eq 0 ]]; then
SCREENSHOT_DEVICE_TYPES=()
SCREENSHOT_DEVICE_TYPES_EXPLICIT=1
fi
SCREENSHOT_DEVICE_TYPES+=("$2")
shift 2
;;
--copy-metadata-from) COPY_METADATA_FROM="${2:-}"; shift 2 ;;
--stage-dry-run) STAGE_DRY_RUN=1; shift ;;
--submit-dry-run) SUBMIT_DRY_RUN=1; shift ;;
--submit) SUBMIT_REQUESTED=1; shift ;;
--confirm-submit) SUBMIT_CONFIRMED=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown argument: $1" ;;
esac
done
[[ -n "$APP" ]] || die "App Store app id is required; pass --app or configure the App Store app id"
if [[ -z "$VERSION" ]]; then
VERSION="$(
sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*([0-9]+(\.[0-9]+){1,2}).*/\1/p' \
"$IOS_DIR/Config/Shared.xcconfig" 2>/dev/null | head -n 1
)"
fi
[[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]] || die "--version must be X.Y or X.Y.Z (got '${VERSION:-}')"
if [[ -n "$BUILD_NUMBER" && -n "$BUILD_ID" ]]; then
die "--build-number and --build-id are mutually exclusive"
fi
if [[ "$SUBMIT_REQUESTED" -eq 1 && "$SUBMIT_CONFIRMED" -ne 1 ]]; then
die "--submit requires --confirm-submit"
fi
if [[ "$SUBMIT_CONFIRMED" -eq 1 && "$SUBMIT_REQUESTED" -ne 1 ]]; then
die "--confirm-submit requires --submit"
fi
if [[ "$SUBMIT_DRY_RUN" -eq 1 && "$SUBMIT_REQUESTED" -eq 1 ]]; then
die "--submit-dry-run and --submit --confirm-submit are mutually exclusive"
fi
command -v asc >/dev/null || die "asc CLI is required. Install/authenticate asc, then rerun."
[[ -s "$REVIEW_NOTES" ]] || die "missing review notes: $REVIEW_NOTES"
[[ -s "$CHECKLIST" ]] || die "missing metadata/screenshots checklist: $CHECKLIST"
selector_args=()
if [[ -n "$BUILD_ID" ]]; then
selector_args=(--build-id "$BUILD_ID")
elif [[ -n "$BUILD_NUMBER" ]]; then
selector_args=(--app "$APP" --build-number "$BUILD_NUMBER" --version "$VERSION" --platform IOS)
fi
build_json=""
if [[ "${#selector_args[@]}" -gt 0 ]]; then
if [[ "$WAIT_BUILD" -eq 1 ]]; then
note "waiting for build processing"
asc builds wait "${selector_args[@]}" --fail-on-invalid --output table
fi
build_json="$(mktemp "${TMPDIR:-/tmp}/cmux-appstore-build.XXXXXX")"
trap '[[ -n "${build_json:-}" ]] && rm -f "$build_json"' EXIT
asc builds info "${selector_args[@]}" --output json --pretty > "$build_json"
if [[ -z "$BUILD_ID" ]]; then
BUILD_ID="$(
python3 - "$build_json" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
body = json.load(handle)
data = body.get("data") if isinstance(body, dict) else None
if isinstance(data, dict):
print(data.get("id", ""))
elif isinstance(data, list) and data and isinstance(data[0], dict):
print(data[0].get("id", ""))
elif isinstance(body, dict):
print(body.get("id", ""))
PY
)"
fi
fi
validate_args=(validate --app "$APP" --version "$VERSION" --platform IOS --output table)
[[ "$STRICT" -eq 1 ]] && validate_args+=(--strict)
note "running canonical App Store readiness validation for $APP $VERSION"
asc "${validate_args[@]}"
if [[ -d "$METADATA_DIR" ]]; then
note "validating local metadata at $METADATA_DIR"
asc metadata validate --dir "$METADATA_DIR" --output table
else
note "no local metadata dir at $METADATA_DIR; use $CHECKLIST before staging"
fi
if [[ -d "$SCREENSHOTS_DIR" ]]; then
for device_type in "${SCREENSHOT_DEVICE_TYPES[@]}"; do
note "validating screenshots at $SCREENSHOTS_DIR for $device_type"
asc screenshots validate --path "$SCREENSHOTS_DIR" --device-type "$device_type" --output table
done
else
note "no local screenshots dir at $SCREENSHOTS_DIR; use $CHECKLIST before staging"
fi
if [[ "$VALIDATE_DIGITAL_GOODS" == "1" ]]; then
note "validating IAP and subscription readiness"
asc validate iap --app "$APP" --output table
asc validate subscriptions --app "$APP" --output table
else
note "skipping IAP/subscription validation because the iOS App Store build exposes no purchase flow; payment gating is covered by web tests and $REVIEW_NOTES"
fi
if [[ "$STAGE_DRY_RUN" -eq 1 ]]; then
[[ -n "$BUILD_ID" ]] || die "--stage-dry-run requires --build-id or --build-number"
stage_args=(release stage --app "$APP" --version "$VERSION" --build "$BUILD_ID" --dry-run --platform IOS --output table)
if [[ -d "$METADATA_DIR" ]]; then
stage_args+=(--metadata-dir "$METADATA_DIR")
elif [[ -n "$COPY_METADATA_FROM" ]]; then
stage_args+=(--copy-metadata-from "$COPY_METADATA_FROM")
else
die "--stage-dry-run requires --metadata-dir or --copy-metadata-from"
fi
[[ "$STRICT" -eq 1 ]] && stage_args+=(--strict-validate)
note "previewing ASC release staging"
asc "${stage_args[@]}"
fi
if [[ "$SUBMIT_DRY_RUN" -eq 1 || "$SUBMIT_REQUESTED" -eq 1 ]]; then
[[ -n "$BUILD_ID" ]] || die "review submission requires --build-id or --build-number"
submit_args=(review submit --app "$APP" --version "$VERSION" --build "$BUILD_ID" --platform IOS --output table)
if [[ "$SUBMIT_DRY_RUN" -eq 1 ]]; then
submit_args+=(--dry-run)
note "previewing App Store review submission"
else
submit_args+=(--confirm)
note "submitting the prepared App Store version for review"
fi
asc "${submit_args[@]}"
fi
note "validation package complete"
+52 -12
View File
@@ -1,8 +1,9 @@
import type { StackServerApp } from "@stackframe/stack";
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { stackServerApp } from "../../../lib/stack";
import { validatedNativeCallbackScheme } from "../../../lib/native-callback";
import { isAppStoreDistributionMode } from "../../../lib/billing";
import { cloudDb } from "../../../../db/client";
import { stripeCustomers } from "../../../../db/schema";
import {
@@ -23,11 +24,23 @@ import {
export const dynamic = "force-dynamic";
type CheckoutStackServerApp = StackServerApp<true>;
// One-click upgrade entrypoint. Signed-out visitors become anonymous Stack
// users first, then go straight to the hosted purchase page. Stack keeps the
// product grant attached to that anonymous user until the buyer completes
// account setup with an email.
export async function GET(request: NextRequest) {
if (
isAppStoreDistributionMode({
cmux_distribution: request.nextUrl.searchParams.get("cmux_distribution"),
cmux_ios_app_store: request.nextUrl.searchParams.get("cmux_ios_app_store"),
})
) {
return NextResponse.redirect(appStorePricingRedirect(request));
}
const stackServerApp = await checkoutStackServerApp();
if (!stackServerApp) {
return NextResponse.redirect(new URL("/pricing?billing=unavailable", request.url));
}
@@ -38,19 +51,22 @@ export async function GET(request: NextRequest) {
}
if (plan === "pro" && isStripeBillingConfigured()) {
return stripeProCheckout(request);
return stripeProCheckout(request, stackServerApp);
}
if (plan === "team" && isStripeBillingConfigured()) {
return stripeTeamCheckout(request);
return stripeTeamCheckout(request, stackServerApp);
}
return legacyStackCheckout(request, plan);
return legacyStackCheckout(request, stackServerApp, plan);
}
async function stripeProCheckout(request: NextRequest) {
async function stripeProCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
) {
const user =
(await stackServerApp!.getUser({ or: "return-null" })) ??
(await stackServerApp!.getUser({ or: "anonymous" }));
(await stackServerApp.getUser({ or: "return-null" })) ??
(await stackServerApp.getUser({ or: "anonymous" }));
const status = await resolveProPlanStatus(user);
if (status.isPro) {
@@ -102,10 +118,13 @@ async function stripeProCheckout(request: NextRequest) {
}
}
async function stripeTeamCheckout(request: NextRequest) {
async function stripeTeamCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
) {
const user =
(await stackServerApp!.getUser({ or: "return-null" })) ??
(await stackServerApp!.getUser({ or: "anonymous" }));
(await stackServerApp.getUser({ or: "return-null" })) ??
(await stackServerApp.getUser({ or: "anonymous" }));
const team = await checkoutTeamCustomer(user);
const teamId = team.id;
if (!teamId) {
@@ -162,11 +181,12 @@ async function stripeTeamCheckout(request: NextRequest) {
async function legacyStackCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
plan: "pro" | "team",
) {
const user =
(await stackServerApp!.getUser({ or: "return-null" })) ??
(await stackServerApp!.getUser({ or: "anonymous" }));
(await stackServerApp.getUser({ or: "return-null" })) ??
(await stackServerApp.getUser({ or: "anonymous" }));
if (plan === "pro" && (await hasActiveProSubscription(user))) {
await syncProPlanMetadata(user, true);
@@ -304,6 +324,26 @@ function checkoutInterval(raw: string | null): ProBillingInterval {
return raw === "year" ? "year" : "month";
}
async function checkoutStackServerApp(): Promise<CheckoutStackServerApp | null> {
const { getStackServerApp, isStackConfigured } = await import("../../../lib/stack");
if (!isStackConfigured()) return null;
return getStackServerApp();
}
function appStorePricingRedirect(request: NextRequest): URL {
const redirectURL = new URL("/app-pricing", request.url);
redirectURL.searchParams.set("cmux_app", "1");
redirectURL.searchParams.set("cmux_distribution", "appstore");
redirectURL.searchParams.set("billing", "unavailable");
for (const key of ["cmux_scheme", "appearance", "background"]) {
const value = request.nextUrl.searchParams.get(key);
if (value) redirectURL.searchParams.set(key, value);
}
return redirectURL;
}
function isAlreadyGrantedError(error: unknown): boolean {
const text =
error instanceof Error ? `${error.name} ${error.message}` : String(error);
+25 -8
View File
@@ -6,7 +6,7 @@ import { getStackServerApp, isStackConfigured } from "../lib/stack";
import { validatedNativeCallbackScheme } from "../lib/native-callback";
import { FREE_PLAN_ID, resolveProPlanStatus } from "../../services/billing/pro";
import enMessages from "../../messages/en.json";
import { appPricingCheckoutURL } from "../lib/billing";
import { appPricingCheckoutURL, isAppStoreDistributionMode } from "../lib/billing";
import { DOWNLOAD_CONFIRMATION_HREF } from "../lib/download";
import {
CurrentPlanBadge,
@@ -46,6 +46,7 @@ export default async function AppPricingPage({
firstParam(params.cmux_scheme),
appPricingRequest(headersList),
);
const appStorePaymentGated = isAppStoreDistributionMode(params);
const proCheckoutURL = appPricingCheckoutURL("pro", requestOrigin, cmuxScheme);
const teamCheckoutURL = appPricingCheckoutURL("team", requestOrigin, cmuxScheme);
const banner = appPricingBanner(params);
@@ -114,7 +115,7 @@ export default async function AppPricingPage({
{snapshot.isPro ? (
<div className="space-y-2">
<DisabledButton>{pricing.currentPlan}</DisabledButton>
{snapshot.billingManagement === "stripe" ? (
{snapshot.billingManagement === "stripe" && !appStorePaymentGated ? (
<SecondaryLink href="/api/billing/portal">
{pricing.manageBilling}
</SecondaryLink>
@@ -124,6 +125,8 @@ export default async function AppPricingPage({
</p>
)}
</div>
) : appStorePaymentGated ? (
<DisabledButton>{pricing.billingUnavailable}</DisabledButton>
) : (
<PrimaryLink href={proCheckoutURL}>{pricing.pro.cta}</PrimaryLink>
)}
@@ -138,7 +141,11 @@ export default async function AppPricingPage({
price={pricing.team.price}
period={pricing.perUserMonth}
>
<PrimaryLink href={teamCheckoutURL}>{pricing.team.cta}</PrimaryLink>
{appStorePaymentGated ? (
<DisabledButton>{pricing.billingUnavailable}</DisabledButton>
) : (
<PrimaryLink href={teamCheckoutURL}>{pricing.team.cta}</PrimaryLink>
)}
<p className="mt-5 text-sm font-medium">
{pricing.team.featuresLead}
</p>
@@ -149,9 +156,13 @@ export default async function AppPricingPage({
name={pricing.enterprise.name}
price={pricing.enterprise.price}
>
<SecondaryLink href={ENTERPRISE_CTA_URL}>
{pricing.enterprise.cta}
</SecondaryLink>
{appStorePaymentGated ? (
<DisabledButton>{pricing.billingUnavailable}</DisabledButton>
) : (
<SecondaryLink href={ENTERPRISE_CTA_URL}>
{pricing.enterprise.cta}
</SecondaryLink>
)}
<p className="mt-5 text-sm font-medium">
{pricing.enterprise.featuresLead}
</p>
@@ -186,17 +197,23 @@ export default async function AppPricingPage({
),
pro: snapshot.isPro ? (
<DisabledButton size="compact">{pricing.currentPlan}</DisabledButton>
) : appStorePaymentGated ? (
<DisabledButton size="compact">{pricing.billingUnavailable}</DisabledButton>
) : (
<PrimaryLink href={proCheckoutURL} size="compact">
{pricing.pro.cta}
</PrimaryLink>
),
team: (
team: appStorePaymentGated ? (
<DisabledButton size="compact">{pricing.billingUnavailable}</DisabledButton>
) : (
<PrimaryLink href={teamCheckoutURL} size="compact">
{pricing.team.cta}
</PrimaryLink>
),
enterprise: (
enterprise: appStorePaymentGated ? (
<DisabledButton size="compact">{pricing.billingUnavailable}</DisabledButton>
) : (
<SecondaryLink href={ENTERPRISE_CTA_URL} size="compact">
{pricing.enterprise.cta}
</SecondaryLink>
+16
View File
@@ -10,6 +10,8 @@ export const TEAM_CHECKOUT_URL = withCheckoutExternalBrowserIntent(TEAM_CHECKOUT
const DEFAULT_APP_PRICING_CHECKOUT_URL = "https://cmux.com/api/billing/checkout";
type SearchParamValue = string | string[] | null | undefined;
export function withCheckoutExternalBrowserIntent(href: string): string {
return withSearchParam(href, CHECKOUT_EXTERNAL_BROWSER_PARAM, "1");
}
@@ -30,6 +32,15 @@ export function appPricingCheckoutURL(
return href;
}
export function isAppStoreDistributionMode(params: {
cmux_distribution?: SearchParamValue;
cmux_ios_app_store?: SearchParamValue;
}): boolean {
const distribution = firstSearchParam(params.cmux_distribution)?.trim().toLowerCase();
if (distribution === "appstore" || distribution === "app-store") return true;
return firstSearchParam(params.cmux_ios_app_store) === "1";
}
function withSearchParam(href: string, name: string, value: string): string {
const [withoutHash, hash] = href.split("#", 2);
const separator = withoutHash.includes("?") ? "&" : "?";
@@ -37,6 +48,11 @@ function withSearchParam(href: string, name: string, value: string): string {
return hash === undefined ? nextHref : `${nextHref}#${hash}`;
}
function firstSearchParam(value: SearchParamValue): string | null {
if (Array.isArray(value)) return value[0] ?? null;
return value ?? null;
}
function configuredAppPricingCheckoutURL(requestOrigin: string | null): string {
const configured = process.env.CMUX_APP_PRICING_CHECKOUT_URL?.trim();
if (configured && configured.length > 0) return configured;
+3
View File
@@ -3462,6 +3462,9 @@
}
}
},
"pricing": {
"billingUnavailable": "الفوترة غير متاحة الآن. يُرجى المحاولة مرة أخرى لاحقًا."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
}
}
},
"pricing": {
"billingUnavailable": "Naplata trenutno nije dostupna. Pokušajte ponovo kasnije."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Ja. cmux er gratis og open source til macOS."
}
},
"pricing": {
"billingUnavailable": "Betaling er ikke tilgængelig lige nu. Prøv igen senere."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Ja. cmux ist kostenlos und quelloffen für macOS."
}
},
"pricing": {
"billingUnavailable": "Die Abrechnung ist derzeit nicht verfügbar. Bitte versuche es später erneut."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Sí. cmux es gratuita y de código abierto para macOS."
}
},
"pricing": {
"billingUnavailable": "La facturación no está disponible ahora. Inténtalo de nuevo más tarde."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Oui. cmux est gratuit et open source pour macOS."
}
},
"pricing": {
"billingUnavailable": "La facturation n'est pas disponible pour le moment. Réessayez plus tard."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Sì. cmux è gratuito e open source per macOS."
}
},
"pricing": {
"billingUnavailable": "La fatturazione non è disponibile al momento. Riprova più tardi."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "ឥតគិតថ្លៃ។ cmux ឥតគិតថ្លៃ និងជាប្រភពកូដបើកចំហសម្រាប់ macOS។"
}
},
"pricing": {
"billingUnavailable": "ការទូទាត់មិនអាចប្រើបាននៅពេលនេះទេ។ សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ។"
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "네. cmux는 macOS용 무료 오픈 소스입니다."
}
},
"pricing": {
"billingUnavailable": "지금은 결제를 사용할 수 없습니다. 나중에 다시 시도해 주세요."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
}
}
},
"pricing": {
"billingUnavailable": "Betaling er ikke tilgjengelig akkurat nå. Prøv igjen senere."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Tak. cmux jest darmowy i open source dla macOS."
}
},
"pricing": {
"billingUnavailable": "Płatności są teraz niedostępne. Spróbuj ponownie później."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
}
}
},
"pricing": {
"billingUnavailable": "O faturamento não está disponível no momento. Tente novamente mais tarde."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
}
}
},
"pricing": {
"billingUnavailable": "Оплата сейчас недоступна. Повторите попытку позже."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "ฟรี cmux เป็นโอเพนซอร์สและใช้ฟรีสำหรับ macOS"
}
},
"pricing": {
"billingUnavailable": "การเรียกเก็บเงินยังไม่พร้อมใช้งานในตอนนี้ โปรดลองอีกครั้งภายหลัง"
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "Evet. cmux, macOS için ücretsiz ve açık kaynaktır."
}
},
"pricing": {
"billingUnavailable": "Faturalandırma şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3545,6 +3545,9 @@
"faqA4": "Так. cmux безкоштовний і з відкритим кодом для macOS."
}
},
"pricing": {
"billingUnavailable": "Оплата зараз недоступна. Спробуйте ще раз пізніше."
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "免费。cmux 是面向 macOS 的免费开源软件。"
}
},
"pricing": {
"billingUnavailable": "目前无法使用结算功能。请稍后再试。"
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+3
View File
@@ -3462,6 +3462,9 @@
"faqA4": "免費。cmux 是面向 macOS 的免費開源軟體。"
}
},
"pricing": {
"billingUnavailable": "目前無法使用結算功能。請稍後再試。"
},
"platforms": {
"macos": "macOS",
"ios": "iOS",
+38
View File
@@ -118,6 +118,22 @@ describe("app pricing page", () => {
expect(html).not.toContain("/api/billing/portal");
});
test("removes external purchase links in App Store distribution mode", async () => {
const element = await AppPricingPage({
searchParams: Promise.resolve({
cmux_app: "1",
cmux_distribution: "appstore",
cmux_scheme: "cmux-dev-test",
}),
});
const html = renderToStaticMarkup(element);
expect(html).not.toContain("/api/billing/checkout");
expect(html).not.toContain("checkout.stripe.com");
expect(html).not.toContain("/api/billing/portal");
expect(html).toContain("Billing is not available right now. Please try again later.");
});
test("renders the external billing note without a portal link for Stack Pro users", async () => {
stackConfigured = true;
currentUser = proUser;
@@ -137,6 +153,28 @@ describe("app pricing page", () => {
expect(html).toContain("Current plan");
});
test("hides Stripe billing management in App Store distribution mode", async () => {
stackConfigured = true;
currentUser = proUser;
stripeSubscriptionRows = [{ id: "sub_123" }];
const element = await AppPricingPage({
searchParams: Promise.resolve({
cmux_app: "1",
cmux_distribution: "appstore",
cmux_scheme: "cmux-dev-test",
}),
});
const html = renderToStaticMarkup(element);
expect(html).not.toContain('href="/api/billing/portal"');
expect(html).not.toContain("Manage billing");
expect(html).toContain(
"Your subscription is managed by our previous billing system. Contact support to make changes.",
);
expect(html).toContain("Current plan");
});
test("renders Manage billing for Stripe-managed Pro users", async () => {
stackConfigured = true;
currentUser = proUser;
+19
View File
@@ -169,6 +169,25 @@ describe("billing checkout route", () => {
});
});
test("blocks direct checkout requests from the iOS App Store distribution", async () => {
stripeConfigured = true;
userResponses = [null, anonymousUser];
const response = await GET(
new NextRequest(
"https://cmux.test/api/billing/checkout?plan=pro&cmux_distribution=appstore&cmux_scheme=cmux",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable&cmux_scheme=cmux",
);
expect(getUser).not.toHaveBeenCalled();
expect(createStripeSession).not.toHaveBeenCalled();
expect(anonymousUser.createCheckoutUrl).not.toHaveBeenCalled();
});
test("keeps signed-in checkout on the existing Stack user", async () => {
userResponses = [signedInUser];