diff --git a/ios-spm-app-packaging/SKILL.md b/ios-spm-app-packaging/SKILL.md new file mode 100644 index 0000000..fc4b44d --- /dev/null +++ b/ios-spm-app-packaging/SKILL.md @@ -0,0 +1,58 @@ +--- +name: ios-spm-app-packaging +description: Scaffold, build, and package SwiftPM-based iOS apps using Tuist. Use when you need a from-scratch iOS app layout, SwiftPM dependencies, automated project generation, or signing/archiving/distribution steps outside manual Xcode project management. +--- + +# iOS SwiftPM App Packaging (Tuist) + +## Overview + +Bootstrap a SwiftPM-based iOS app using Tuist to generate the Xcode project. Build, archive, and distribute without manually managing xcodeproj files. Tuist uses Swift-based manifests for type-safe configuration with IDE autocomplete. + +## Two-Step Workflow + +1) Bootstrap the project folder + - Copy `assets/templates/bootstrap/` into a new repo. + - Rename `MyApp` in `Package.swift`, `Sources/MyApp/`, and `Project.swift`. + - Customize `APP_NAME`, `BUNDLE_ID`, and versions in `Project.swift`. + +2) Build, archive, and distribute + - Install Tuist: `brew install --cask tuist`. + - Generate project: `tuist generate`. + - Build: `tuist xcodebuild build -scheme MyApp -sdk iphonesimulator`. + - Archive: `Scripts/archive.sh`. + - Export IPA: `Scripts/export_ipa.sh`. + - Upload to TestFlight: `Scripts/upload_testflight.sh`. + +## Templates + +- `assets/templates/bootstrap/`: Minimal SwiftPM iOS app skeleton with CI configuration, Gemfile, and SwiftLint config. +- `assets/templates/archive.sh`: Create a release archive (supports `SKIP_SIGNING=1`). +- `assets/templates/export_ipa.sh`: Export IPA from archive for distribution. +- `assets/templates/upload_testflight.sh`: Upload IPA to App Store Connect. +- `assets/templates/release.sh`: Complete release workflow (version bump, build, GitHub release, TestFlight). +- `assets/templates/ExportOptions.plist`: Export options for app-store or ad-hoc distribution. +- `assets/templates/fastlane/`: Fastfile and Appfile for optional Fastlane automation. + +## Notes + +- Tuist regenerates the xcodeproj from `Project.swift`; never edit xcodeproj manually. +- Add `*.xcodeproj` to `.gitignore` since it's generated. +- Provisioning requires Apple Developer account and certificates configured in Keychain. +- For CI without signing, use `SKIP_SIGNING=1 Scripts/archive.sh`. +- Optional: Use `tuist cache warm` for faster builds via caching. + +## Signing + +Tuist defines build settings but doesn't manage certificates. Options: +- **Manual**: Download certificates/profiles from Apple Developer portal. +- **Automatic**: Let Xcode manage signing (local dev only). +- **Fastlane match**: Automated certificate management via git repo (recommended for teams/CI). + +## Reference material + +- See `references/scaffold.md` for project setup details. +- See `references/packaging.md` for build and archive configuration. +- See `references/release.md` for TestFlight and App Store distribution. +- See `references/fastlane.md` for optional Fastlane automation and `match` signing. +- See `references/swiftlint.md` for code style enforcement and linting integration. diff --git a/ios-spm-app-packaging/assets/templates/ExportOptions.plist b/ios-spm-app-packaging/assets/templates/ExportOptions.plist new file mode 100644 index 0000000..5f077e5 --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/ExportOptions.plist @@ -0,0 +1,10 @@ + + + + + method + app-store-connect + destination + upload + + diff --git a/ios-spm-app-packaging/assets/templates/archive.sh b/ios-spm-app-packaging/assets/templates/archive.sh new file mode 100644 index 0000000..56370ae --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/archive.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Archive iOS app for distribution +# Usage: ./archive.sh +# +# Environment variables: +# SKIP_SIGNING=1 - Use CI configuration (no code signing) +# APP_NAME - App name (default: MyApp) +# SCHEME - Xcode scheme (default: APP_NAME or APP_NAME-CI if SKIP_SIGNING) +# CONFIGURATION - Build configuration (default: Release or CI if SKIP_SIGNING) +# BUILD_DIR - Output directory (default: build) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +APP_NAME="${APP_NAME:-MyApp}" +BUILD_DIR="${BUILD_DIR:-build}" + +# Use CI config/scheme when SKIP_SIGNING is set +if [ "${SKIP_SIGNING:-0}" = "1" ]; then + CONFIGURATION="${CONFIGURATION:-CI}" + SCHEME="${SCHEME:-$APP_NAME-CI}" + echo "Running in CI mode (no code signing)..." +else + CONFIGURATION="${CONFIGURATION:-Release}" + SCHEME="${SCHEME:-$APP_NAME}" +fi + +echo "Regenerating Xcode project..." +tuist generate + +echo "Archiving $APP_NAME (scheme: $SCHEME, config: $CONFIGURATION)..." +tuist xcodebuild archive \ + -scheme "$SCHEME" \ + -sdk iphoneos \ + -configuration "$CONFIGURATION" \ + -archivePath "$BUILD_DIR/$APP_NAME.xcarchive" \ + -allowProvisioningUpdates + +echo "Archive created: $BUILD_DIR/$APP_NAME.xcarchive" diff --git a/ios-spm-app-packaging/assets/templates/bootstrap/.gitignore b/ios-spm-app-packaging/assets/templates/bootstrap/.gitignore new file mode 100644 index 0000000..9247da3 --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/bootstrap/.gitignore @@ -0,0 +1,25 @@ +# Xcode (generated by Tuist) +*.xcodeproj +*.xcworkspace +xcuserdata/ +DerivedData/ +*.xcscmblueprint + +# Tuist +.tuist-derived/ +Derived/ + +# Build +build/ +.build/ + +# Swift Package Manager +.swiftpm/ +Package.resolved + +# macOS +.DS_Store + +# Archives +*.xcarchive +*.ipa diff --git a/ios-spm-app-packaging/assets/templates/bootstrap/Package.swift b/ios-spm-app-packaging/assets/templates/bootstrap/Package.swift new file mode 100644 index 0000000..7697d93 --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/bootstrap/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MyApp", + platforms: [.iOS(.v17)], + products: [ + .library(name: "MyApp", targets: ["MyApp"]) + ], + targets: [ + .target( + name: "MyApp", + path: "Sources/MyApp", + resources: [.process("Resources")] + ) + ] +) diff --git a/ios-spm-app-packaging/assets/templates/bootstrap/Project.swift b/ios-spm-app-packaging/assets/templates/bootstrap/Project.swift new file mode 100644 index 0000000..b53c4a3 --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/bootstrap/Project.swift @@ -0,0 +1,54 @@ +import ProjectDescription + +let project = Project( + name: "MyApp", + targets: [ + .target( + name: "MyApp", + destinations: .iOS, + product: .app, + bundleId: "com.example.myapp", + deploymentTargets: .iOS("17.0"), + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": [:], + "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"] + ]), + sources: ["Sources/MyApp/**"], + resources: ["Sources/MyApp/Resources/**"], + settings: .settings( + base: [ + "MARKETING_VERSION": "1.0.0", + "CURRENT_PROJECT_VERSION": "1", + "SWIFT_VERSION": "6.0" + ], + configurations: [ + .debug(name: "Debug", settings: [ + "CODE_SIGN_IDENTITY": "Apple Development" + ]), + .release(name: "Release", settings: [ + "CODE_SIGN_IDENTITY": "Apple Distribution" + ]), + .release(name: "CI", settings: [ + "CODE_SIGNING_REQUIRED": "NO", + "CODE_SIGNING_ALLOWED": "NO", + "CODE_SIGN_IDENTITY": "" + ]) + ] + ) + ) + ], + schemes: [ + .scheme( + name: "MyApp", + buildAction: .buildAction(targets: ["MyApp"]), + runAction: .runAction(configuration: "Debug"), + archiveAction: .archiveAction(configuration: "Release") + ), + .scheme( + name: "MyApp-CI", + buildAction: .buildAction(targets: ["MyApp"]), + runAction: .runAction(configuration: "Debug"), + archiveAction: .archiveAction(configuration: "CI") + ) + ] +) diff --git a/ios-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/MyApp.swift b/ios-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/MyApp.swift new file mode 100644 index 0000000..fedfecf --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/MyApp.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} diff --git a/ios-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/Resources/.keep b/ios-spm-app-packaging/assets/templates/bootstrap/Sources/MyApp/Resources/.keep new file mode 100644 index 0000000..e69de29 diff --git a/ios-spm-app-packaging/assets/templates/export_ipa.sh b/ios-spm-app-packaging/assets/templates/export_ipa.sh new file mode 100644 index 0000000..27c1531 --- /dev/null +++ b/ios-spm-app-packaging/assets/templates/export_ipa.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +# Export IPA from archive +# Usage: ./export_ipa.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +APP_NAME="${APP_NAME:-MyApp}" +BUILD_DIR="${BUILD_DIR:-build}" +EXPORT_OPTIONS="${EXPORT_OPTIONS:-ExportOptions.plist}" + +if [ ! -f "$EXPORT_OPTIONS" ]; then + echo "Creating default ExportOptions.plist for App Store..." + cat > "$EXPORT_OPTIONS" << 'EOF' + + + + + method + app-store-connect + destination + upload + + +EOF +fi + +echo "Exporting IPA..." +xcodebuild -exportArchive \ + -archivePath "$BUILD_DIR/$APP_NAME.xcarchive" \ + -exportPath "$BUILD_DIR/export" \ + -exportOptionsPlist "$EXPORT_OPTIONS" \ + -allowProvisioningUpdates + +echo "IPA exported to: $BUILD_DIR/export/" diff --git a/ios-spm-app-packaging/references/packaging.md b/ios-spm-app-packaging/references/packaging.md new file mode 100644 index 0000000..fd441e5 --- /dev/null +++ b/ios-spm-app-packaging/references/packaging.md @@ -0,0 +1,110 @@ +# Packaging notes + +## Build commands + +Generate project with Tuist: +```bash +tuist generate +``` + +Build with tuist xcodebuild wrapper: +```bash +# Simulator build +tuist xcodebuild build -scheme MyApp -sdk iphonesimulator -configuration Debug + +# Device build (requires signing) +tuist xcodebuild build -scheme MyApp -sdk iphoneos -configuration Release +``` + +## Archive for distribution + +```bash +tuist generate +tuist xcodebuild archive \ + -scheme MyApp \ + -sdk iphoneos \ + -configuration Release \ + -archivePath build/MyApp.xcarchive +``` + +## Export IPA + +Create `ExportOptions.plist`: +```xml + + + + + method + app-store-connect + destination + upload + + +``` + +Export: +```bash +xcodebuild -exportArchive \ + -archivePath build/MyApp.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist ExportOptions.plist +``` + +## Common environment variables + +- `APP_NAME`: App/target name. +- `BUNDLE_ID`: Bundle identifier. +- `TEAM_ID`: Apple Developer Team ID. +- `SCHEME`: Xcode scheme name. +- `CONFIGURATION`: `Debug`, `Release`, or `CI`. +- `SKIP_SIGNING`: Set to `1` to use CI configuration (no code signing). + +## Signing modes + +| Method | Use Case | +|--------|----------| +| Automatic | Local dev with Xcode managing profiles | +| Manual | CI with exported provisioning profiles | +| Ad-hoc | Internal testing outside TestFlight | +| App Store | TestFlight and App Store distribution | + +## CI / no-signing environments + +Archive commands fail without valid signing certificates. The project includes a `CI` configuration and `MyApp-CI` scheme for testing the archive pipeline without signing. + +**Why archive fails without certs:** +- `xcodebuild archive` requires code signing even for test builds +- CI environments often lack provisioning profiles + +**Use the CI configuration:** +```bash +# Archive without signing (uses CI config) +SKIP_SIGNING=1 Scripts/archive.sh + +# Or manually: +tuist xcodebuild archive \ + -scheme MyApp-CI \ + -sdk iphoneos \ + -configuration CI \ + -archivePath build/MyApp.xcarchive +``` + +**For real distribution** (requires Apple Developer account): +```bash +tuist generate +tuist xcodebuild archive \ + -scheme MyApp \ + -sdk iphoneos \ + -configuration Release \ + -allowProvisioningUpdates +``` + +## Tuist caching (optional) + +Speed up builds by caching dependencies: +```bash +tuist cache warm +tuist generate +tuist xcodebuild build +``` diff --git a/ios-spm-app-packaging/references/scaffold.md b/ios-spm-app-packaging/references/scaffold.md new file mode 100644 index 0000000..b05bd47 --- /dev/null +++ b/ios-spm-app-packaging/references/scaffold.md @@ -0,0 +1,130 @@ +# Scaffold a SwiftPM iOS app (Tuist) + +## Steps + +1) Create a repo and initialize SwiftPM: +``` +mkdir MyApp +cd MyApp +swift package init --type library +``` + +2) Create `Project.swift` for Tuist (see template below). + +3) Create the app entry point under `Sources/MyApp/`. + - Use SwiftUI with `@main` App struct. + +4) Add resources directory: +``` +mkdir -p Sources/MyApp/Resources +``` + +5) Install Tuist and generate project: +``` +brew install --cask tuist +tuist generate +open MyApp.xcodeproj +``` + +6) Add generated files to `.gitignore`: +``` +echo "*.xcodeproj" >> .gitignore +echo ".tuist-derived/" >> .gitignore +``` + +## Minimal Package.swift + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MyApp", + platforms: [.iOS(.v17)], + products: [ + .library(name: "MyApp", targets: ["MyApp"]) + ], + targets: [ + .target( + name: "MyApp", + path: "Sources/MyApp", + resources: [.process("Resources")] + ) + ] +) +``` + +## Minimal Project.swift (Tuist) + +```swift +import ProjectDescription + +let project = Project( + name: "MyApp", + targets: [ + .target( + name: "MyApp", + destinations: .iOS, + product: .app, + bundleId: "com.example.myapp", + deploymentTargets: .iOS("17.0"), + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": [:] + ]), + sources: ["Sources/MyApp/**"], + resources: ["Sources/MyApp/Resources/**"], + settings: .settings(base: [ + "MARKETING_VERSION": "1.0.0", + "CURRENT_PROJECT_VERSION": "1", + "SWIFT_VERSION": "6.0" + ]) + ) + ] +) +``` + +## Minimal SwiftUI entry point + +```swift +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +struct ContentView: View { + var body: some View { + Text("Hello") + } +} +``` + +## Adding SPM dependencies + +In `Project.swift`, add packages and dependencies: + +```swift +let project = Project( + name: "MyApp", + packages: [ + .remote(url: "https://github.com/Alamofire/Alamofire", requirement: .upToNextMajor(from: "5.0.0")) + ], + targets: [ + .target( + name: "MyApp", + destinations: .iOS, + product: .app, + bundleId: "com.example.myapp", + sources: ["Sources/MyApp/**"], + dependencies: [ + .package(product: "Alamofire") + ] + ) + ] +) +```