Add ios-spm-app-packaging skill with Tuist scaffold and build

This commit is contained in:
Michael 2026-01-07 19:57:42 -06:00
parent a66c570852
commit 98b11b8ba6
11 changed files with 508 additions and 0 deletions

View file

@ -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.

View file

@ -0,0 +1,10 @@
<?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>destination</key>
<string>upload</string>
</dict>
</plist>

View file

@ -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"

View file

@ -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

View file

@ -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")]
)
]
)

View file

@ -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")
)
]
)

View file

@ -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()
}
}

View file

@ -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'
<?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>destination</key>
<string>upload</string>
</dict>
</plist>
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/"

View file

@ -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
<?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>destination</key>
<string>upload</string>
</dict>
</plist>
```
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
```

View file

@ -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")
]
)
]
)
```