Miru is my app for tracking anime. It is written in React and TypeScript, packaged with Capacitor for iOS and Android, and uses Supabase as its backend. The real cost of a mobile app, for someone working alone, is not writing it: it is publishing it every time. This is how I took publishing off the list of things to do by hand.
The idea
On every push to the main branch, GitHub Actions builds the iOS app, signs it and uploads it to TestFlight. No Xcode open, no manual archive. For Android it works almost the same way, with one difference I describe at the end.
The iOS pipeline, step by step
The job runs on a macOS runner, because building and signing an iOS app needs Xcode:
- Checkout and Xcode. I select the latest stable Xcode version.
- Web build.
npm ciand thennpm run build, with environment variables passed as secrets. npx cap sync ios. It copies the web build into the native project. Capacitor 8 uses Swift Package Manager rather than CocoaPods, so I do not have to commit the generatedPackage.swiftby hand: CI produces it.- Swift dependencies. I resolve packages with
xcodebuild -resolvePackageDependenciesbefore archiving, so network errors surface early. - Signing. I create a temporary keychain on the runner, import the certificate (from a base64 secret) and install the provisioning profiles: one for the app and one for the widget.
- Archive and export.
xcodebuild archivein Release configuration, then-exportArchivewith anExportOptions.plistthat lives in the repository. - Upload.
xcrun altool --upload-appwith an App Store Connect API key, also stored as a secret.
Here is the signing part, simplified:
- name: Setup signing certificate
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
Android
For Android the job runs on Ubuntu with JDK 21: I build the web app, sync Capacitor, decode the keystore from a secret and run ./gradlew bundleRelease. The result is a signed AAB, which I save as a job artifact.
Here is the difference: uploading to the Play Console stays manual. It is a step I have left out of the automation for now.
What I learned
- Secrets are the real project. The YAML is trivial; the long part is getting the right certificates, profiles and keys and keeping them out of the repository.
- A widget doubles the profiles. If the app has an extension, it needs its own provisioning profile too.
- A generous timeout. The iOS job has 60 minutes of margin: macOS builds are slow and a tight timeout fails exactly when you need it.
- Better to fail early. Resolving dependencies in a separate step makes errors more readable.
