-
Notifications
You must be signed in to change notification settings - Fork 38
feat(swift): ship native iOS app to TestFlight #2629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||||
| #!/usr/bin/env bun | ||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Archive the native Swift PackRat iOS app and upload it to TestFlight. | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * This targets a SEPARATE App Store Connect record from the production Expo | ||||||||||||||||||||
| * app: bundle id `com.andrewbierman.packrat.swift`. Register that app record | ||||||||||||||||||||
| * in App Store Connect once before the first upload. | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Auth uses an Apple ID + app-specific password (no App Store Connect API key | ||||||||||||||||||||
| * required). Generate a password at appleid.apple.com -> Sign-In & Security -> | ||||||||||||||||||||
| * App-Specific Passwords. | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Required env (put in apps/swift/.env.local, gitignored): | ||||||||||||||||||||
| * APPLE_ID your Apple ID email | ||||||||||||||||||||
| * APPLE_APP_PASSWORD app-specific password (xxxx-xxxx-xxxx-xxxx) | ||||||||||||||||||||
| * APPLE_TEAM_ID the team that owns the record (e.g. 7WV9JYCW55) | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Optional env: | ||||||||||||||||||||
| * BUILD_NUMBER CFBundleVersion for this upload (default: timestamp) | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Usage: | ||||||||||||||||||||
| * bun apps/swift/scripts/upload-testflight.ts | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| import { execFileSync } from 'node:child_process'; | ||||||||||||||||||||
| import { mkdtempSync, writeFileSync } from 'node:fs'; | ||||||||||||||||||||
| import { tmpdir } from 'node:os'; | ||||||||||||||||||||
| import { join } from 'node:path'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const SWIFT_DIR = new URL('..', import.meta.url).pathname; | ||||||||||||||||||||
| const PROJECT = join(SWIFT_DIR, 'PackRat.xcodeproj'); | ||||||||||||||||||||
| const SCHEME = 'PackRat-iOS'; | ||||||||||||||||||||
| const BUNDLE_ID = 'com.andrewbierman.packrat.swift'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function req(name: string): string { | ||||||||||||||||||||
| const v = process.env[name]; | ||||||||||||||||||||
| if (!v) { | ||||||||||||||||||||
| console.error(`Missing required env var: ${name}. See script header.`); | ||||||||||||||||||||
| process.exit(1); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return v; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const appleId = req('APPLE_ID'); | ||||||||||||||||||||
| const appPassword = req('APPLE_APP_PASSWORD'); | ||||||||||||||||||||
| const teamId = req('APPLE_TEAM_ID'); | ||||||||||||||||||||
| const buildNumber = process.env.BUILD_NUMBER ?? String(Math.floor(Date.now() / 1000)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const work = mkdtempSync(join(tmpdir(), 'packrat-tf-')); | ||||||||||||||||||||
| const archivePath = join(work, 'PackRat.xcarchive'); | ||||||||||||||||||||
| const exportDir = join(work, 'export'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function run(cmd: string, args: string[]) { | ||||||||||||||||||||
| console.log(`\n$ ${cmd} ${args.join(' ')}`); | ||||||||||||||||||||
|
mikib0 marked this conversation as resolved.
Dismissed
|
||||||||||||||||||||
| execFileSync(cmd, args, { stdio: 'inherit' }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+52
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win Fix pipeline failure and prevent credential leak. The custom linter failed because the Refactor 🔒️ Proposed fixes for the function and call sites1. Update the function definition: -function run(cmd: string, args: string[]) {
- console.log(`\n$ ${cmd} ${args.join(' ')}`);
- execFileSync(cmd, args, { stdio: 'inherit' });
+function run([cmd, ...args]: [string, ...string[]]) {
+ const safeArgs = args.map((arg, i) => args[i - 1] === '--password' ? '***' : arg);
+ console.log(`\n$ ${cmd} ${safeArgs.join(' ')}`);
+ execFileSync(cmd, args, { stdio: 'inherit' });
}2. Update the three call sites: // 1. Archive for a real device (TestFlight cannot accept a simulator build).
-run('xcodebuild', [
+run([
+ 'xcodebuild',
'archive', // 2. Export a signed .ipa for App Store distribution.
const exportOptions = join(work, 'ExportOptions.plist');
...
-run('xcodebuild', [
+run([
+ 'xcodebuild',
'-exportArchive', // 3. Upload to TestFlight via altool (app-specific-password auth).
const ipa = join(exportDir, 'PackRat-iOS.ipa');
-run('xcrun', [
+run([
+ 'xcrun',
'altool',📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: Checks / 0_checks.txt[error] 52-52: Owned functions with too many params found (1). run has 2 params 🪛 GitHub Actions: Checks / checks[error] 52-52: lint:custom failed: Owned functions with too many params found (1). Function 'run' has 2 params. 🤖 Prompt for AI AgentsSource: Pipeline failures |
||||||||||||||||||||
|
|
||||||||||||||||||||
| // 1. Archive for a real device (TestFlight cannot accept a simulator build). | ||||||||||||||||||||
| run('xcodebuild', [ | ||||||||||||||||||||
| 'archive', | ||||||||||||||||||||
| '-project', | ||||||||||||||||||||
| PROJECT, | ||||||||||||||||||||
| '-scheme', | ||||||||||||||||||||
| SCHEME, | ||||||||||||||||||||
| '-destination', | ||||||||||||||||||||
| 'generic/platform=iOS', | ||||||||||||||||||||
| '-archivePath', | ||||||||||||||||||||
| archivePath, | ||||||||||||||||||||
| // Lets Xcode register the App IDs and generate provisioning profiles for | ||||||||||||||||||||
| // the (new) bundle ids on the fly, using the signed-in account. | ||||||||||||||||||||
| '-allowProvisioningUpdates', | ||||||||||||||||||||
| `CURRENT_PROJECT_VERSION=${buildNumber}`, | ||||||||||||||||||||
| `DEVELOPMENT_TEAM=${teamId}`, | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 2. Export a signed .ipa for App Store distribution. | ||||||||||||||||||||
| const exportOptions = join(work, 'ExportOptions.plist'); | ||||||||||||||||||||
| writeFileSync( | ||||||||||||||||||||
| exportOptions, | ||||||||||||||||||||
| `<?xml version="1.0" encoding="UTF-8"?> | ||||||||||||||||||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||||||||||||||||
| <plist version="1.0"> | ||||||||||||||||||||
| <dict> | ||||||||||||||||||||
| <key>method</key><string>app-store-connect</string> | ||||||||||||||||||||
| <key>teamID</key><string>${teamId}</string> | ||||||||||||||||||||
| <key>destination</key><string>export</string> | ||||||||||||||||||||
| <key>signingStyle</key><string>automatic</string> | ||||||||||||||||||||
| <key>uploadSymbols</key><true/> | ||||||||||||||||||||
| </dict> | ||||||||||||||||||||
| </plist> | ||||||||||||||||||||
| `, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| run('xcodebuild', [ | ||||||||||||||||||||
| '-exportArchive', | ||||||||||||||||||||
| '-archivePath', | ||||||||||||||||||||
| archivePath, | ||||||||||||||||||||
| '-exportPath', | ||||||||||||||||||||
| exportDir, | ||||||||||||||||||||
| '-exportOptionsPlist', | ||||||||||||||||||||
| exportOptions, | ||||||||||||||||||||
| // Export also needs to generate the App Store distribution profiles for the | ||||||||||||||||||||
| // new bundle ids on the fly. | ||||||||||||||||||||
| '-allowProvisioningUpdates', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 3. Upload to TestFlight via altool (app-specific-password auth). | ||||||||||||||||||||
| // `--asc-provider` (team short name) is required when the Apple ID belongs to | ||||||||||||||||||||
| // more than one team, so altool knows which one to deliver to. | ||||||||||||||||||||
| const ipa = join(exportDir, 'PackRat-iOS.ipa'); | ||||||||||||||||||||
| run('xcrun', [ | ||||||||||||||||||||
| 'altool', | ||||||||||||||||||||
| '--upload-app', | ||||||||||||||||||||
| '--type', | ||||||||||||||||||||
| 'ios', | ||||||||||||||||||||
| '--file', | ||||||||||||||||||||
| ipa, | ||||||||||||||||||||
| '--username', | ||||||||||||||||||||
| appleId, | ||||||||||||||||||||
| '--password', | ||||||||||||||||||||
| appPassword, | ||||||||||||||||||||
| '--asc-provider', | ||||||||||||||||||||
| teamId, | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| console.log(`\n✓ Uploaded build ${buildNumber} to TestFlight (${BUNDLE_ID}).`); | ||||||||||||||||||||
| console.log('It will appear in App Store Connect after processing (usually 5-15 min).'); | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use
import.meta.dirto prevent path resolution failures.new URL(...).pathnamereturns a URL-encoded string. If the repository's path contains spaces,.pathnamewill contain%20instead of literal spaces, causing downstreamxcodebuildsteps to fail because the.xcodeprojfile cannot be found. Since this is a Bun script, you can use the built-inimport.meta.dir.🛠 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents