The Fourth Submission Was One Command — App Store Automation with fastlane
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.

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.

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:
| lane | what it does |
|---|---|
metadata | text metadata only |
screenshots | screenshots only (overwrites) |
store | metadata + screenshots (no binary, no submit) |
build_upload | flutter build ipa + uploads the binary to ASC |
release | submits 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

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_upload → store → release. 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.

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.