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:
@@ -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 \"$@\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`)
|
||||
|
||||
Executable
+13
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
Executable
+196
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "الفوترة غير متاحة الآن. يُرجى المحاولة مرة أخرى لاحقًا."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "Naplata trenutno nije dostupna. Pokušajte ponovo kasnije."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
"faqA4": "ឥតគិតថ្លៃ។ cmux ឥតគិតថ្លៃ និងជាប្រភពកូដបើកចំហសម្រាប់ macOS។"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "ការទូទាត់មិនអាចប្រើបាននៅពេលនេះទេ។ សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ។"
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
"faqA4": "네. cmux는 macOS용 무료 오픈 소스입니다."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "지금은 결제를 사용할 수 없습니다. 나중에 다시 시도해 주세요."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "Betaling er ikke tilgjengelig akkurat nå. Prøv igjen senere."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "O faturamento não está disponível no momento. Tente novamente mais tarde."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "Оплата сейчас недоступна. Повторите попытку позже."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
"faqA4": "ฟรี cmux เป็นโอเพนซอร์สและใช้ฟรีสำหรับ macOS"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "การเรียกเก็บเงินยังไม่พร้อมใช้งานในตอนนี้ โปรดลองอีกครั้งภายหลัง"
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3545,6 +3545,9 @@
|
||||
"faqA4": "Так. cmux безкоштовний і з відкритим кодом для macOS."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "Оплата зараз недоступна. Спробуйте ще раз пізніше."
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
"faqA4": "免费。cmux 是面向 macOS 的免费开源软件。"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "目前无法使用结算功能。请稍后再试。"
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -3462,6 +3462,9 @@
|
||||
"faqA4": "免費。cmux 是面向 macOS 的免費開源軟體。"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"billingUnavailable": "目前無法使用結算功能。請稍後再試。"
|
||||
},
|
||||
"platforms": {
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user