Yunus Emre Alpak

The Fourth Submission Was One Command — App Store Automation with fastlane

2026 · 07 · 128 min read

I did the first submission by hand. On the second I mistyped a string, on the third I uploaded screenshots at the wrong size. That's where I stopped: a release process shouldn't be a sequence of clicks, it should be a versioned file. This is what I learned handing Vird's App Store release to fastlane — from the real breakthrough of .p8 auth to the four moments deliver rejected me.

One command to the App Store — automating Vird's release with fastlane

The work you think is done

I finished Vird. Widgets rendered, the paywall took purchases, theme switches landed. "Done," I said, "let's ship it." Then I met App Store Connect's submission screen: metadata in several languages, screenshots for each device size, binary upload, export-compliance questions, IDFA declaration, content rights... each by hand, each in a web form, each from scratch on the next build.

I did the first submission by hand. On the second I mistyped a string. On the third I uploaded screenshots at the wrong size. That's where I stopped: this has to be repeatable. I set up fastlane.

fastlane in one sentence

fastlane collects iOS/Android release steps into lanes (ordered sequences of steps) written in Ruby. Each lane calls a series of actions: upload_to_app_store, build_app, increment_build_number. You type fastlane release, and it pushes metadata, screenshots, and the binary to App Store Connect and submits for review. The key shift: your release process is no longer a sequence of clicks, it's a versioned file.

The real breakthrough: a .p8 key, not a password

Most fastlane stories are set in Apple ID + 2FA hell: verification codes on every CI run, expiring session tokens. I lived none of it, because I used an App Store Connect API key from the start.

You download a .p8 private key from ASC (along with a Key ID and Issuer ID). fastlane uses it to mint a short-lived, ES256-signed JWT for every request. No Apple ID, no password, no 2FA.

def asc_key
  app_store_connect_api_key(
    key_id: ENV.fetch("ASC_KEY_ID"),
    issuer_id: ENV.fetch("ASC_ISSUER_ID"),
    key_filepath: ENV.fetch("ASC_KEY_PATH"),
    duration: 1200,
    in_house: false
  )
end

The key itself never enters the repo: the values live in a git-ignored .env, the .p8 sits outside the repo. That separation matters — leak the key, leak write access to your account.

A .p8 private key mints a short-lived, signed token on every request — no password

Metadata becomes files

deliver's (that is, upload_to_app_store) best quality: metadata isn't a web form, it's a folder tree.

fastlane/
  metadata/
    tr/
      description.txt
      keywords.txt
      ...
    en-US/
      description.txt
      ...
  screenshots/
    tr/
      01-today.png
      ...

You write the description in a text editor, commit it, run fastlane metadata. Diffable, revertable, reviewable in a PR. Never pasting into a web form again.

The lanes split by need:

lanewhat it does
metadatatext metadata only
screenshotsscreenshots only (overwrites)
storemetadata + screenshots (no binary, no submit)
build_uploadflutter build ipa + uploads the binary to ASC
releasesubmits for review

The binary: from Flutter to IPA

Vird is SwiftPM-only (no CocoaPods). fastlane didn't care at all — because Flutter produces the binary, fastlane just uploads it:

lane :build_upload do
  Dir.chdir("../..") do
    sh("flutter", "build", "ipa", "--release", "--export-method", "app-store")
  end
  upload_to_app_store(
    api_key: asc_key,
    ipa: "../../build/ios/ipa/vird.ipa",
    skip_metadata: true,
    skip_screenshots: true
  )
end

Submitting for review

The actual "submit" lane also fills, programmatically, the three declarations Apple asks for — those radio buttons in the web form:

lane :release do
  upload_to_app_store(
    api_key: asc_key,
    app_version: "1.0.0",
    build_number: "3",
    skip_binary_upload: true,
    skip_metadata: true,
    skip_screenshots: true,
    submit_for_review: true,
    automatic_release: false,  # release manually after approval
    submission_information: {
      add_id_info_uses_idfa: false,
      export_compliance_uses_encryption: false,
      content_rights_contains_third_party_content: false
    }
  )
end

automatic_release: false is deliberate: let Apple approve, let me pick the release moment.

Spaceship: asking ASC questions

deliver is the write side; the read side is Spaceship (fastlane's ASC API wrapper). Which build is in which state, is the version "Waiting for Review" — I queried this with a status lane:

Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
  key_id: ENV.fetch("ASC_KEY_ID"),
  issuer_id: ENV.fetch("ASC_ISSUER_ID"),
  filepath: ENV.fetch("ASC_KEY_PATH")
)
app = Spaceship::ConnectAPI::App.find("com.yemrealpak.vird")

The walls I hit along the way

Four walls on the automation path: name conflict, screenshot size, gem clash, Hash vs Token

Setup looks "clean," but reality always bleeds a little. What I noted down:

1. App name conflict. On my first metadata run, deliver blew up: "The app name you entered is already being used." "Vird" was taken. The fix: I removed name.txt from metadata/ entirely — I manage the name in ASC by hand, and deliver no longer touches it. A field absent from metadata is a field metadata can't overwrite.

2. Screenshot size. deliver rejected 1320×2868: "Invalid screen size." deliver's validator only knew the older 6.9" size (1290×2796). I resized to width 1290 and center-cropped to 2796, and it passed.

3. rexml/gem conflict. I wanted to query ASC from a standalone script; require 'spaceship' broke on a gem version clash (rexml). The fix: I stopped running the script standalone and moved it into a status lane running inside fastlane's own gem environment. Stay inside the tool's environment, not outside it.

4. Token or Hash? The sneakiest one. app_store_connect_api_key returns a Hash — exactly what deliver's api_key: parameter wants. But a direct Spaceship call wants a Token object. Mix them and you get undefined method 'expired?' for Hash. Same .p8, two different wrappings: a Hash for deliver, Token.create for Spaceship.

What's left

Now Vird's next version is a single chain: build_uploadstorerelease. Metadata in git, screenshots in git, declarations in code. There's no chance I mistype a string, because now I write the string once and commit it.

One chain: build_upload, store, release — no manual clicks

What fastlane taught me isn't really about fastlane: every repeated manual step is quietly waiting to become a bug. I saw it on the third submission. The fourth was one command.