Create a small, Node/Bun-based CLI package that bumps the iOS build number, builds an Xcode archive, exports an IPA, and uploads it to TestFlight. The v1 scope is intentionally narrow and maps to the existing scripts/release-testflight.ts behavior.
This plan is detailed to enable an external agent to implement the package in a new repo without prior context.
- One-command TestFlight release flow for iOS.
- Minimal API surface, low dependency count, Bun-first.
- Works with Expo-style configs (
app.config.ts,app.config.js,app.json). - Clear preflight errors and deterministic output paths.
- Safe defaults with an optional dry run.
- Android build or upload support (explicit v2).
- App Store Connect API key auth.
- Complex config evaluation (e.g., TypeScript runtime execution of
app.config.ts). - CI integration beyond basic non-interactive operation.
- Node 18+ or Bun is installed to run the CLI.
- Xcode command line tools are installed.
- Transporter app is installed, and the user is signed in (required for
iTMSTransporter). - Repo includes an
ios/directory with an Xcode workspace. - Repo contains one of:
app.config.ts,app.config.js, orapp.json. - iOS
Info.plistexists atios/<AppName>/Info.plist(or similar viaIOS_APP_NAME).
paperplane [options]
--build-number <n>: Set explicit build number (default: current + 1).--message <msg>: Override git commit message.--dry-run: Show actions without modifying files or running build/upload.--allow-dirty: Skip clean git working tree check.--skip-upload: Build/export only; do not upload to TestFlight.-h, --help: Show usage.
ASC_APPLE_ID(required for upload)ASC_APP_PASSWORD(required for upload)ASC_ITC_PROVIDER(optional; iTunes Connect provider short name)
IOS_APP_NAME: iOS target folder + default scheme (defaults to workspace name).IOS_SCHEME: Override Xcode scheme.IOS_WORKSPACE: Override workspace path (relative to repo root or absolute).
- Root directory =
process.cwd() ios/directory must exist.- Workspace:
- If
IOS_WORKSPACEis set, resolve it (absolute or relative) and verify it exists. - Otherwise, pick the first
*.xcworkspaceunderios/. - Error if none found.
- If
- App name:
- Default to
basename(workspace, ".xcworkspace"). - Override with
IOS_APP_NAME.
- Default to
- Info.plist:
ios/<AppName>/Info.plistmust exist.
Support these config files (first match wins):
app.config.tsapp.config.jsapp.json
- For
app.config.ts/app.config.js:- Treat file as text. Do not execute/require it.
- Find the first
buildNumberstring in the file. - Prefer
ios: { buildNumber: "N" }if present. - Fallback to any
buildNumber: "N"match.
- For
app.json:- Parse as JSON; read
expo.ios.buildNumber(primary), thenexpo.buildNumber(fallback).
- Parse as JSON; read
- Update the same file where the build number was found.
- Ensure the update is minimal and stable (single replacement).
- If no build number is found in any config file, exit with a helpful error.
- Read
CFBundleVersionfromios/<AppName>/Info.plist. - Replace its value with the new build number.
- Error if the key is not found or if replacement fails.
- If
--allow-dirtyis not set,git status --porcelainmust be empty. - Stage modified config file and Info.plist (only if tracked).
- Commit message default:
chore(release): bump iOS build to <N> - Allow overriding commit message via
--message.
- Output root:
ios/build/testflight/<runId>/ runIdformat: ISO timestamp with safe characters (e.g.,2026-01-31-185501).- Archive path:
<buildRoot>/<AppName>.xcarchive - Export path:
<buildRoot>/export - Export options plist should be generated in
<buildRoot>/exportOptions.plist.
- Archive
xcodebuild \
-workspace <workspacePath> \
-scheme <scheme> \
-configuration Release \
-sdk iphoneos \
-destination "generic/platform=iOS" \
-archivePath <archivePath> \
-allowProvisioningUpdates \
archive
- Export
xcodebuild \
-exportArchive \
-archivePath <archivePath> \
-exportOptionsPlist <exportOptionsPlist> \
-exportPath <exportPath> \
-allowProvisioningUpdates
method: app-store-connect
signingStyle: automatic
uploadBitcode: false
compileBitcode: false
- Find
*.ipadirectly under<exportPath>. - If not found, search one level deeper.
- Error if no IPA found.
- Check that
iTMSTransporteris available viaxcrun --find iTMSTransporter. - Build upload command using Apple ID + app-specific password:
xcrun iTMSTransporter \
-m upload \
-assetFile <ipaPath> \
-u <ASC_APPLE_ID> \
-p <ASC_APP_PASSWORD> \
-itc_provider <ASC_ITC_PROVIDER> # only if provided
- If
--skip-uploadis set, skip upload and output IPA path.
- Print resolved values:
- App name, workspace, scheme
- Build number current -> next
- Info.plist CFBundleVersion current -> next
- Confirm no files are written, no git commit, no build/export, no upload.
- Use clear, actionable error messages.
- Exit non-zero on failure.
- Fail fast on missing files, build number parse/update failure, or missing credentials.
- Keep console output short, human-readable.
- Always output the IPA path on success.
- Node/Bun package with a single
binentry. - Prefer minimal dependencies (built-ins only).
- README + LICENSE + small example.
- Title + one-liner
- Requirements (Bun, Xcode CLI, Transporter)
- Install
- Quick start
- Usage (options)
- Environment variables
- Project assumptions
- Output paths
- Examples (dry run, build only, upload)
- FAQ (why Bun, troubleshooting Transporter)
- CLI arg parsing + help
.envloader (do not override existing env vars)- File discovery for workspace + config file
- Build number extraction + validation
- Update app config + Info.plist
- Git commit flow
- xcodebuild archive + export
- IPA discovery
- Transporter check + upload
- Dry run pathway
- README + example
- Android build/export (AAB/APK) support
- App Store Connect API key auth
- Config path override flag
- Additional reporters (JSON output for CI)