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:

  1. Checkout and Xcode. I select the latest stable Xcode version.
  2. Web build. npm ci and then npm run build, with environment variables passed as secrets.
  3. 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 generated Package.swift by hand: CI produces it.
  4. Swift dependencies. I resolve packages with xcodebuild -resolvePackageDependencies before archiving, so network errors surface early.
  5. 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.
  6. Archive and export. xcodebuild archive in Release configuration, then -exportArchive with an ExportOptions.plist that lives in the repository.
  7. Upload. xcrun altool --upload-app with 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.