Filmtone / Release Engineering
Splitting an iOS release into six fastlane lanes — Filmtone's archive / screenshots / metadata / beta / release / submit_review design
Fastlane is split into six lanes by responsibility: `archive` (IPA build), `screenshots` (UI test snapshots), `metadata` (ASC localized metadata + review info), `beta` (TestFlight upload), `release` (binary + metadata + screenshots upload), `submit_review` (resubmits an existing build for review). The record covers two production traps and their fixes: gym 2.233 passing auth args into both `xcargs` and `export_xcargs` (causing `Exit status: 64` on IPA export), and deliver missing `release_notes` in upload options (causing ASC to reject the review submission for missing `whatsNew`). Each is patched with minimum-viable Fastfile edits.
- Published
- April 29, 2026
- Topics
- Filmtone · iOS · App Store · Fastlane · Release Engineering
Splitting fastlane into six lanes by responsibility
Filmtone iOS's `fastlane/Fastfile` is split into six lanes. Each lane owns a single stage of the pipeline — build the IPA, stage review assets, submit for review — with no spillover side effects (no implicit re-archive, no surprise screenshot regeneration, no quiet metadata overwrite). The same Fastfile drives both CI and a laptop without surprises.
- `archive` — builds the IPA at `build/fastlane/Filmtone.ipa` with Xcode automatic signing
- `screenshots` — runs the UI test rail on iPhone 17 Pro Max (hard-coded UDID), then stages output for `ja` and `en-US`
- `metadata` — uploads localized metadata, review info, and release notes to App Store Connect. Never touches binary or screenshots
- `beta` — uploads an existing IPA to TestFlight. Always requires explicit `IPA_PATH`
- `release` — uploads IPA + metadata + screenshots to ASC. `SUBMIT_FOR_REVIEW=1` submits for review; `AUTOMATIC_RELEASE=1` auto-releases on approval
- `submit_review` — re-submits an already-uploaded build for review without touching binary or screenshots, given `APP_VERSION` and `BUILD_NUMBER`
Fail-fast when no IPA is provided
`beta`, `release`, and `submit_review` all funnel through `explicit_ipa_path!(options, lane:)`. Skip `ipa:` and the lane bails immediately with `UI.user_error!`. Without this, CI quietly reuses a stale archive, or running release locally with no archive triggers a surprise 30-minute build.
def explicit_ipa_path!(options, lane:)
explicit_path = options[:ipa] || options["ipa"]
UI.user_error!(
"Pass ipa:... to `fastlane ios #{lane}`. Implicit archive fallback is disabled."
) if explicit_path.nil?
ipa_path = File.expand_path(explicit_path, APP_ROOT)
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
ipa_path
endTrap 1 — gym 2.233 passes auth args twice
Fastlane 2.233.0's gym (`build_app`) forwards both `export_xcargs` and `xcargs` to `xcodebuild -exportArchive` during the export step. If the Fastfile sets the same App Store Connect auth args (`-authenticationKeyIssuerID` / `-authenticationKeyID` / `-authenticationKeyPath`) in both `xcargs` and `export_xcargs`, `xcodebuild -exportArchive` receives them twice and dies with `Exit status: 64`. The Xcode archive itself succeeds, only IPA export fails.
Minimum-viable fix: keep auth args in `xcargs` and don't set `export_xcargs`. Fastlane internally reuses `xcargs` during export, so the args reach `xcodebuild -exportArchive` once.
lane :archive do
xcargs_parts = ["-allowProvisioningUpdates"]
if asc_api_key_configured?
key_file_path = temp_key_path || File.expand_path(env!("ASC_KEY_PATH"), APP_ROOT)
xcargs_parts.concat([
"-authenticationKeyIssuerID", env!("ASC_ISSUER_ID"),
"-authenticationKeyID", env!("ASC_KEY_ID"),
"-authenticationKeyPath", key_file_path,
])
end
xcargs = xcargs_parts.map(&:shellescape).join(" ")
build_app(
workspace: IOS_WORKSPACE,
scheme: "App",
clean: true,
export_method: "app-store",
output_directory: BUILD_OUTPUT_DIR,
output_name: "Filmtone",
build_path: BUILD_OUTPUT_DIR,
xcargs: xcargs,
export_options: {
method: "app-store",
signingStyle: "automatic",
teamID: DEVELOPMENT_TEAM_ID,
}
)
endTrap 2 — release_notes silently dropped means `whatsNew` is missing
Even when `fastlane/metadata/{ja,en-US}/release_notes.txt` exists on disk, calling `upload_to_app_store` without explicitly passing `release_notes:` makes the ASC API treat `whatsNew` as unset. Binary upload, screenshot upload, precheck, and build selection all succeed — then `submit_for_review: true` fails with:
appStoreVersions ... is not in valid state.
The provided entity is missing a required attribute - You must provide a value for the attribute 'whatsNew' with this requestFix: pass `release_notes:` explicitly. The same payload is needed in `metadata`, `release`, and `submit_review`, so factor it into a helper and reference it from each lane. TestFlight (`beta` lane) wants `localized_build_info` shaped as `{ locale => { whats_new: text } }`, so a second helper re-shapes the same source.
def localized_release_note_texts
{
"ja" => read_text_if_present(File.join(METADATA_TEMPLATE_PATH, "ja", "release_notes.txt")),
"en-US" => read_text_if_present(File.join(METADATA_TEMPLATE_PATH, "en-US", "release_notes.txt")),
}.compact
end
def localized_build_release_notes
localized_release_note_texts.each_with_object({}) do |(locale, notes), memo|
memo[locale] = { whats_new: notes }
end
endsubmit_review — re-submit an existing build without touching the binary
When `release` failed at `submit_for_review` due to missing `whatsNew`, the binary was already at ASC. No need to re-archive — only metadata and the review submission need to land. That's exactly what `submit_review` does. It pulls `APP_VERSION` and `BUILD_NUMBER` from env or lane options, then runs `upload_to_app_store` with `skip_binary_upload: true`, `skip_screenshots: true`, `skip_metadata: false`.
lane :submit_review do |options|
app_version = normalized_env("APP_VERSION") || options[:app_version]
build_number = normalized_env("BUILD_NUMBER") || options[:build_number]
UI.user_error!("Set APP_VERSION or pass app_version:") unless app_version
UI.user_error!("Set BUILD_NUMBER or pass build_number:") unless build_number
upload_to_app_store(
api_key: load_asc_api_key!,
app_identifier: APP_IDENTIFIER,
app_version: app_version,
build_number: build_number,
platform: "ios",
metadata_path: stage_metadata!,
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: false,
submit_for_review: true,
automatic_release: truthy_env?("AUTOMATIC_RELEASE"),
release_notes: localized_release_note_texts,
# ...
)
endClosing the screenshot-asymmetry door upstream
Because `release` will overwrite ASC screenshots, mismatched per-locale counts or filenames corrupt the App Store screenshot panel. The Fastfile runs `validate_release_screenshots!` before any upload: both `ja` and `en-US` must have 1–10 screenshots, with identical filename order across locales. Anything else fails fast via `UI.user_error!`.
def validate_release_screenshots!
screenshot_paths = staged_release_screenshot_paths
missing_locales = screenshot_paths.select { |_, paths| paths.empty? }.keys
unless missing_locales.empty?
UI.user_error!("Missing staged screenshots for locales: #{missing_locales.join(', ')}")
end
counts = screenshot_paths.transform_values(&:count)
unless counts.values.all? { |count| count.between?(1, 10) }
UI.user_error!("Each locale must have between 1 and 10 screenshots.")
end
unless counts.values.uniq.one?
UI.user_error!("Screenshot counts differ by locale.")
end
expected_basenames = nil
screenshot_paths.each do |locale, paths|
basenames = paths.map { |path| File.basename(path) }
expected_basenames ||= basenames
next if basenames == expected_basenames
UI.user_error!("Screenshot filenames must match across locales.")
end
endFinal v1.2 submission flow
# 1. archive once
bun run release:archive # → build/fastlane/Filmtone.ipa
# 2. upload metadata and binary (does not submit for review yet)
IPA_PATH=build/fastlane/Filmtone.ipa \
bun run release:appstore
# 3. when whatsNew rejects review submission, re-send metadata only and submit
set -a; . ./.env.local; set +a
APP_VERSION=1.2 BUILD_NUMBER=1 AUTOMATIC_RELEASE=1 \
./scripts/bundle.sh exec fastlane ios submit_review